In this module, we will learn how we can write functions that return other functions. Because this seems a little weird at first, we will also discuss situations in which this is a useful design pattern, and see how this approach can be used together with Julia’s powerful dispatch system.
Julia has a very interesting function called isequal
. Let’s see how it
works (don’t forget to look at ?isequal
for more):
is_two = isequal(2.0)
(::Base.Fix2{typeof(isequal), Float64}) (generic function with 1 method)
When we call this function, we get back another function. We can apply this new function to a few arguments:
is_two(π)
false
is_two(2)
true
This is actually rather helpful! In this module, we will illustrate how returning functions works, and see a simple example from population dynamics. But first, why should we want to return functions? A common example is when we want to run multiple parameterizations of a model. We could write the model function in such a way that we would give it all of the parameters, but it might lead to long function calls. What if we could have a function that only depends on the variables? To do this, we can write a fist function to generate the function we will actually call.
An illustration is probably more useful here. Let’s assume that we are interesting in the logistic growth of a population in discrete time, which is a simple process we can represent with the following model:
$$ n(t+1) = n(t)\times(1 + r (1 - n(t)/K)) $$
A way to simulate this could rely on a function that would be called with
something like f(n, r, K)
– but within a simulation, we do not expect $r$
or $K$ to change. So let’s define a function that would only be a function
of $n$:
foo(n::Float64)::Float64 = n * (1.0 + r * (1 - n / K))
foo (generic function with 1 method)
Now, this function will not work because we do not have defined r
or K
.
We can do that by having a function taking values of r
and K
as arguments,
and return a version of our function with these values “replaced”:
function discrete_logistic_growth(r::T, K::T)::Function where {T <: AbstractFloat}
return model(n::T)::T = n * (one(T) + r * (one(T) - n / K))
end
discrete_logistic_growth (generic function with 1 method)
Note that we are using a number of tricks from the previous modules: we ensure
that r
and K
have the same type (a floating point value), we let Julia
know that discrete_logistic_growth
returns a function, and we re-use the
type of the parameters to constrain the type of the variables.
What does it looks like in practice? We will use Float32
values to get a
sense that all of the annotations on our function were not in vain:
discrete_logistic_growth(1.0f0, 2.0f0)
(::Main.var"##252".var"#model#1"{Float32, Float32, Float32}) (generic function with 1 method)
Excellent, this is a generic function with a single method! We can double
check that it is, indeed, a Function
, by using the isa
operator (it also
works as a function!):
discrete_logistic_growth(1.0f0, 2.0f0) isa Function
true
Excellent! Let’s take a step back. That we are able to return functions should
not be very surprising, because functions are just another category of things.
There’s not really a lot of conceptual difference between returning a number
and returning a function. But some of the difficulty comes from the fact that
the parameters of discrete_logistic_growth
are now baked in the function we
return.
So how do we use this function? We can simply add a set of parentheses at the end. For example, if our population has a growth rate of 1, a carrying capacity of 2, and a current population size of 2.2, we should get a value lower than 2:
discrete_logistic_growth(1.0f0, 2.0f0)(2.2f0)
1.98f0
Now, we can of course assign the first part of this expression to a variable:
parameterized_model = discrete_logistic_growth(1.0f0, 2.0f0)
(::Main.var"##252".var"#model#1"{Float32, Float32, Float32}) (generic function with 1 method)
We now have a fully usable function:
parameterized_model(2.2f0)
1.98f0
But why? Think of our function this way: as soon as it is created, using
discrete_logistic_growth
, we know the parameters (because we specified
them), and we know that they will not change.
Can we make this approach intersect nicely with Julia’s dispatch system? Of course! Let’s assume we want to run the model sometimes on a single point (one population), and sometimes on a series of populations (a vector of abundances). We can do this by having, for example, one function taking a number as input, and another function taking a vector of number as inputs. But can we create both these functions when we create our model?
Yes!
function better_discrete_logistic_growth(r::T, K::T)::Function where {T <: AbstractFloat}
model(n::T)::T = n * (one(T) + r * (one(T) - n / K))
model(n::Vector{T})::Vector{T} =
n .* (one(eltype(T)) .+ r .* (one(eltype(T)) .- n ./ K))
return model
end
better_discrete_logistic_growth (generic function with 1 method)
Remember from the module on dispatch that a function is “just” a name, a big ol’ bag of methods. We can declare as many methods as we want, and the output is still going to be a function.
model
(with the
model(n::Vector{T})::Vector{T}
) signature uses the dot notation, and we
will see what it is in the next module.Let’s verify this!
parameterized_better_model = better_discrete_logistic_growth(0.2, 1.0)
(::Main.var"##252".var"#model#2"{Float64, Float64, Float64}) (generic function with 2 methods)
This is indeed a generic function with two methods. We can see it in action:
parameterized_better_model(0.5)
0.55
parameterized_better_model([0.0, 0.2, 1.0, 1.4])
4-element Vector{Float64}:
0.0
0.23200000000000004
1.0
1.288
This is another reason why the dispatch system is so important in Julia – we can use it to write a lot of different things that make the syntax of our code much more pleasant to read!