Functions#
A function packages up a piece of logic so you can name it, reuse it, and reason about it in isolation. That’s true in every language. In Julia, functions carry more weight than usual for two reasons: they’re the unit of compilation (as I hinted at the end of the last chapter), and they’re the unit of dispatch, which is the organizing principle of the entire language.
So this chapter matters more than the title suggests. Let’s take it slowly.
Defining a function#
The long form:
function net_pay(gross, tax_rate)
deduction = gross * tax_rate
return gross - deduction
end
And the short form, for one-liners, which is used constantly in Julia:
net_pay(gross, tax_rate) = gross - gross * tax_rate
Both define exactly the same thing. The short form is not a lesser “lambda” — it’s a full function definition in fewer characters.
return is optional#
A Julia function returns the value of its last expression automatically:
function net_pay(gross, tax_rate)
gross - gross * tax_rate # this value is returned
end
Both styles are common. I use an explicit return when a function has early exits or is long enough that I want the exit point to be obvious, and I leave it off for short mathematical functions. Do whichever makes the code clearer.
To return nothing at all, write return nothing (or just return). Julia has a real nothing value of type Nothing, which is distinct from missing (missing data) and from NaN (a floating-point non-number). Keeping those three straight will save you confusion later.
Arguments#
Positional and optional#
function apply_discount(price, rate = 0.10)
return price * (1 - rate)
end
apply_discount(100) # 90.0 — uses the default
apply_discount(100, 0.25) # 75.0
Keyword arguments#
Anything after a semicolon in the signature is a keyword argument, and must be passed by name at the call site:
function post_entry(account, amount; currency = "USD", reversal = false)
sign = reversal ? -1 : 1
println("$account: $(sign * amount) $currency")
end
post_entry("GL-4001", 250)
post_entry("GL-4001", 250; currency = "EUR", reversal = true)
The semicolon is the important character. This distinction between positional and keyword arguments isn’t just style — positional arguments participate in dispatch (which method gets called), and keyword arguments do not. That’s a rule you’ll want to remember when we get to the dispatch chapter.
There’s also a nice shorthand when your variable name matches the keyword name:
currency = "GBP"
post_entry("GL-4001", 250; currency) # same as currency = currency
Variable numbers of arguments#
Three dots — the “splat” operator — collects extra arguments:
function total(items...)
s = 0
for item in items
s += item
end
return s
end
total(1, 2, 3) # 6
total(1, 2, 3, 4, 5) # 15
The same dots work in reverse at a call site, spreading a collection into individual arguments:
values = [1, 2, 3, 4]
total(values...) # 10
Type annotations on arguments#
You can restrict what a function accepts:
function net_pay(gross::Float64, tax_rate::Float64)
return gross - gross * tax_rate
end
Now here’s the thing that surprises people coming from static languages: this does not make the function faster. Julia was already going to compile a specialized version for whatever concrete types you passed. Adding ::Float64 doesn’t give the compiler information it didn’t have; it just refuses other types.
So why annotate at all? Two real reasons:
To dispatch — to say “when the arguments are these types, run this implementation.” That’s the next chapter, and it’s the main reason.
To document and constrain — to make a wrong call fail loudly at the boundary rather than producing a confusing error five frames deep.
What annotating does not do is help performance, and over-annotating actively hurts you by making your code needlessly rigid. The version above can’t take an Int, can’t take a Float32, can’t take a BigFloat. The unannotated version handles all of them, each with its own specialized compiled code. In Julia, leaving types off is how you write generic, reusable, fast code — which is the opposite of the instinct you may have brought with you.
If you want a middle ground, annotate abstractly:
function net_pay(gross::Real, tax_rate::Real)
return gross - gross * tax_rate
end
That accepts any real number type — Int, Float64, Rational, BigFloat — while still rejecting a String. This is usually the right instinct, and we’ll formalize it in the Type System chapter.
Anonymous functions#
Functions that don’t need a name, usually because you’re handing them to another function:
xs = [1, 2, 3, 4, 5]
map(x -> x^2, xs) # [1, 4, 9, 16, 25]
filter(x -> x % 2 == 0, xs) # [2, 4]
For multi-line anonymous functions there’s a block form:
map(xs) do x
y = x^2
y + 1
end
That do block syntax looks strange the first time. All it means is: take this block, make it an anonymous function, and pass it as the first argument. That’s why open("file.txt") do f ... end worked in the last chapter — open takes a function as its first argument and takes care of closing the file afterwards.
Functions are values#
This is worth stating explicitly because it unlocks a lot of Julia code:
function apply_twice(f, x)
return f(f(x))
end
apply_twice(sqrt, 16.0) # 2.0
apply_twice(x -> x + 1, 10) # 12
Functions can be stored in variables, put in arrays, passed as arguments, and returned from other functions. And unlike in many dynamic languages, this costs you nothing — Julia specializes apply_twice on the specific function you passed, so f(f(x)) compiles down to a direct call with no indirection.
The ! convention#
We met this in chapter three, and now that we’re writing functions it’s worth stating as a rule you should follow in your own code: if your function modifies its arguments, end its name with !.
sort(xs) # returns a new sorted array; xs is untouched
sort!(xs) # sorts xs in place, and returns it
The compiler doesn’t enforce this. It’s a promise between programmers, and the entire ecosystem keeps it, which means you can read unfamiliar Julia and know at a glance which calls are destructive. Keep the promise in your own code.
There’s a related convention: when a function mutates one of its arguments, that argument goes first. push!(collection, item), sort!(array), copyto!(destination, source).
Composition and piping#
Two small operators that make data transformations read nicely:
# Piping: value |> function
[1, 4, 9] .|> sqrt |> sum # 6.0
# Composition: build a new function out of two
normalize = strip ∘ lowercase # \circ + Tab
normalize(" Amit Shukla ") # "amit shukla"
|> sends a value into a function. ∘ glues two functions into one, applied right to left, exactly like the mathematical notation. Neither is required, but pipelines of small transformations are common in data work and these read well.
A small, real example#
Here’s the shape of a function I’d actually write. Take a vector of raw ledger amounts, clean it, and summarize it.
"""
summarize(amounts; drop_negatives = true)
Return a named tuple with the count, total, and mean of `amounts`.
Negative entries are treated as data-entry errors and dropped by default.
"""
function summarize(amounts; drop_negatives = true)
clean = drop_negatives ? filter(>=(0), amounts) : amounts
n = length(clean)
total = sum(clean; init = zero(eltype(clean)))
return (count = n, total = total, mean = n == 0 ? NaN : total / n)
end
julia> summarize([1200, 800, -50, 1500])
(count = 3, total = 3500, mean = 1166.6666666666667)
julia> summarize([1200, 800, -50, 1500]; drop_negatives = false)
(count = 4, total = 3450, mean = 862.5)
Three things in there worth pointing out.
The docstring. That triple-quoted string above the function isn’t a comment — Julia attaches it to the function, and it shows up when anyone types ?summarize. Writing these is a habit worth forming early; the convention is to show the signature indented on the first line, then describe what it does.
No type annotations. summarize works on a vector of Int, a vector of Float64, even a vector of Rational, each compiled separately and each fast. If I’d written amounts::Vector{Float64} I’d have thrown all of that away for nothing.
zero(eltype(clean)). This is a small but very Julian move. Rather than hardcoding 0 (an Int) or 0.0 (a Float64) as the starting value for the sum, I ask for “the zero of whatever type this collection holds.” The function stays generic. You’ll see zero, one, eltype, and similar used this way throughout the ecosystem — they’re how generic code stays type-correct.
Try it yourself#
Write a function
celsius_to_fahrenheit(c)and use it on a whole array withmap.Write a function
describe(name; greeting = "Hello")that prints"Hello, name!"and lets the caller change the greeting.Write
apply_n(f, x, n)that applies functionftox,ntimes. Test it withapply_n(x -> x * 2, 1, 10).Write a mutating function
clip!(xs, lo, hi)that clamps every element ofxsinto the range[lo, hi], in place. Name it correctly.
Solutions#
# 1.
celsius_to_fahrenheit(c) = c * 9/5 + 32
map(celsius_to_fahrenheit, [0, 20, 37, 100]) # [32.0, 68.0, 98.6, 212.0]
# 2.
function describe(name; greeting = "Hello")
println("$greeting, $name!")
end
describe("Amit")
describe("Amit"; greeting = "Namaste")
# 3.
function apply_n(f, x, n)
for _ in 1:n
x = f(x)
end
return x
end
apply_n(x -> x * 2, 1, 10) # 1024
# 4.
function clip!(xs, lo, hi)
for i in eachindex(xs)
xs[i] = clamp(xs[i], lo, hi)
end
return xs
end
(For that last one — clamp already exists in Base, and clamp! does exactly this. Checking whether the standard library already has your function is a habit worth building.)
You can now write and compose functions. Next we’ll look at the collections you’ll be feeding them: arrays, tuples, dictionaries, and sets.