Straightforward Functional Programming Examples in Julia
Functional programming has gained quite some popularity in recent years. Yet if you code with the Julia language you probably already used a lot of functional programming concepts without really thinking about it. In it's essence functional programming simply means that functions can be used as arguments in other functions.
I noticed recently that I have been using more functional programming concepts in my daily coding. Mostly I am moving away from vectorized code to using "higher order functions". This might sound fancy, but it's pretty straightforward. Let me explain with some examples.
Here are a few simple ways to check whether an array has any values less than 3.
numbers = [1, 2, 3, 4, 5]
# vectorized
any(numbers .< 3)
# functional, using a pre-defined function
less_than_three(x) = x < 3
any(less_than_three, numbers)
# functional, using a lambda/anonymous function
any(x -> x < 3, numbers)
# functional, using a 'currying' function
any(<(3), numbers)
You can see that the any
function can either take a single (boolean) vector as input, or it can take a function and a vector as input. Passing a function into a function is a form of functional programming. Such functions that use functions are called higher order functions.
You can input any kind of function into any
that returns a boolean. It can just be the name of an existing function, an "anonymous" function like x -> x < 3
or as shown above a "curried" function. The functional programming people love inventing new names for concepts. Currying just means that a function can return a function with some arguments already filled in. So f(1, 2, 3)
can become f(1)(2)(3)
. This is what happened with the function call <(3)
, it will return a function similar to x -> x < 3
. Essentially you called something like <(y) = x -> x < y
, so all of these examples are equivalent:
julia> 2 < 3
true
julia> <(2,3)
true
julia <(3)(2)
true
I nowadays always choose the functional style of programming like any(<(3), numbers)
, since the vectorized form will first allocate the boolean vector numbers .< 3
in memory before calling any
. The functional form does not need to create this vector in memory. So the functional style is typically more performant, especially if the any
function can stop early:
julia> using BenchmarkTools
julia> numbers = 5 .* ones(10_000);
julia> @btime any($numbers .< 3);
4.271 μs (3 allocations: 5.55 KiB)
julia> @btime any(<(3), $numbers);
3.750 μs (0 allocations: 0 bytes)
julia> numbers[5] = 0.0;
julia> @btime any($numbers .< 3);
4.229 μs (3 allocations: 5.55 KiB)
julia> @btime any(<(3), $numbers);
3.400 ns (0 allocations: 0 bytes)
Next to any
I mostly use all
, filter
(and filter!
), map
, reduce
and mapreduce
in my daily coding. The functions any
, all
and filter
seem obvious in their behavior:
julia> numbers = [1, 2, 3, 4, 5];
julia> any(<(3), numbers)
true
julia> all(isequal(3), numbers)
false
julia> filter(<(3), numbers)
2-element Vector{Int64}:
1
2
The map
function is typically similar to a simple broadcast, it just applies (in other word maps) a function to each element in a collection. map(f, x)
is equivalent to f.(x)
in many cases, so you can choose whichever you like:
julia> numbers = [1, 2, 3];
julia> map(x -> x^2, numbers)
5-element Vector{Int64}:
1
4
9
julia> numbers.^2
5-element Vector{Int64}:
1
4
9
However, in some cases map
is more efficient, see this discussion here.
What I find more interesting are reduce
and mapreduce
. The reduce
function essentially applies a function iteratively to two subsequent elements in a collection. I think a simple example is more clear:
julia> numbers = [1, 2, 3, 4, 5];
julia> reduce(+, numbers)
15
julia> sum(numbers)
15
More powerful is the mapreduce
function, which as the name suggests, combines both a map and a reduce:
julia> numbers = [1, 2, 3, 4, 5];
julia> mapreduce(x -> x^2, +, numbers)
55
julia> sum(numbers.^2)
55
As before the broadcast/vectorized code will create another vector in memory (the numbers.^2
) and only then does the summing, while the mapreduce
doesn't need to do this, so that's a big advantage for mapreduce
. To be fair, Julia also allows a mapreduce with sum(x -> x^2, numbers)
, which might be more readable in this case.
Wow, so we actually discussed a lot of functional programming concepts here, without going into the details:
higher order functions like
any(f, collection)
anonymous functions like
x -> x^2
curried functions like
<(3)
reduce functions like
reduce
andmapreduce
Another concept that functional programmers love, but which I barely use in Julia is function composition. Here's an example:
# let's say we have two functions
add_one(x) = x + 1
double(x) = 2x
# we can define our own compose function
compose(f,g) = x -> f(g(x))
add_one_and_double = compose(double, add_one)
add_one_and_double(5) # returns 12
# or using the compose operator ∘, which does the above
add_one_and_double = double ∘ add_one
add_one_and_double(5) # returns 12
It may look very elegant, but I only occasionally see a use for such composition. And it's unintuitive to many programmers unfamiliar to the concept. You can already compose functions the old-fashioned way: add_one_and_double(x) = double(add_one(x))
and that serves most purposes in my opinion.
So that's it! These are all functional programming concepts that I use on a daily basis in my Julia programming. Especially the use of "higher order functions", like any(<(3), [1,2,3,4])
I use a lot and actively try to favor over any vectorized broadcasting. If you've been coding in Julia for a while now, I bet you've secretly already been doing lots of functional programming.
Subscribe to my newsletter
Read articles from Matthijs Cox directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Matthijs Cox
Matthijs Cox
A scientific software developer with over a decade of experience in academia, startups and industry. My mission is to turn you into an elite numerical computing professional.