Skip to main content

Command Palette

Search for a command to run...

Straightforward Functional Programming Examples in Julia

Updated
5 min read
Straightforward Functional Programming Examples in Julia
M

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.

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.

W

Regarding the first example it's interesting to note that while for small arrays and searching for a frequent occurence using x->xx<num seems directly related to the rarity of the searched for condition. Even for small arrays where the condition does not occur, this approah is significantly slower than the other approaches (and the vectorized approach is fastest).

M

Hi Wolf, not sure what you are testing. Note that the timing depends on how you test it.

The any(x -> x < 3, numbers) might look slow if you use @time, but that's because the anonymous function keeps getting recompiled for every call. Maybe that's what you mean?

You can easily bypass this by placing it inside another function. Also you can use BenchmarkTools.@btime instead to ignore the compilation time.

julia> numbers = [1, 2, 3, 4, 5];

julia> @time any(x -> x < 3, numbers)
  0.006801 seconds (2.74 k allocations: 183.680 KiB, 99.01% compilation time)
true

julia> @time any(numbers .< 3)
  0.000012 seconds (3 allocations: 128 bytes)
true

julia> any_less(numbers, value) = any(x -> x < value, numbers)
any_less (generic function with 1 method)

julia> @time any_less(numbers, 3) # first call compiles the `any_less`
  0.009657 seconds (6.90 k allocations: 456.047 KiB, 99.76% compilation time)
true

julia> @time any_less(numbers, 3) # now it's fast
  0.000005 seconds
true

But perhaps you are talking about something else?

W

I had made a mistake, wrote my code as a script as such:

using BenchmarkTools

numbers = rand(100)
num = 0.001

println("Size of test array $(length(numbers))")
println("Roughly $(num*100)% of entries satisfy condition (entry < $num)")
println()

println("result with <($num)")
@btime any(<(num), numbers)

println("result with x -> x < $num")
@btime any(x -> x < num, numbers)

println("numbers .< $num")
@btime any(numbers .< num)

However this has to allocate space for the array and number for each call to any. In the any(x -> x < num) case it even has to allocate num for each iteration done by any.

This lead to confusion on my part as any(x -> x < num) slowed down more than the other approaches when more iterations were required to find a match which I initially did not expect.