Straightforward Functional Programming Examples in Julia

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 and mapreduce

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.