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.