Encapsulate your Julia code into functions, for convenience and performance.
Functions play a key role in structuring Julia code. Arguably, most programs in Julia are basically constructed by applying and composing functions.
Structuring code into functions is also key for performance. As Julia is structured around a JIT compiler, in order to get our code compiled we need to wrap it up inside a function. As a consequence, performance-critical sections of our code should always be written inside functions.
There are many ways to define and use functions in Julia. Let’s review them.
Defining a function
As an example, let’s define a function that will sum the first terms of the Riemman Zeta function (see Wiki)
function sum_zeta(s,nterms) x = 0 for n in 1:nterms x = x + (1/n)^s end return x end
After executing that code, we can call our function from the REPL, by typing:
sum_zeta(2,100000) # returns 1.6449240668982423
In Julia, we can also define a function with a single line of code. For example, by doing:
sum_zeta(s,nterms) = sum(1/n^s for n=1:nterms)
We can then call it just like above:
sum_zeta(2,100000) # returns 1.6449240668982423
Functions with optional and keyword arguments
In many cases, it can be preferable to have a function with some optional arguments. For example, let’s say we want to make the number of terms in our
sum_zeta an optional variable, which, naturally, will have a certain value assigned by default when left unspecified. The syntax to implement this is quire straightforward, just adding
nterms=100000 as one of the arguments does the job:
sum_zeta(s, nterms=10000) = sum(1/n^s for n=1:nterms) sum_zeta(2) # returns 1.6449240668982423
To make things even easier for the caller, a function can be designed to accept arguments which are identified by name rather than their position. Such arguments are known as keyword arguments, and their syntax is very similar to optional arguments, the only difference being that they are placed after a semicolon in the function definition. In the present example, we could make
nterms a keyword argument:
sum_zeta(s; nterms=10000) = sum(1/n^s for n=1:nterms) sum_zeta(2) # returns 1.6449240668982423 sum_zeta(2, nterms = 1e6) # returns 1.64493306684877
This is particularly useful for functions which have a large number of arguments — forcing the caller to remember their exact order can become inconvenient.
Functions with multiple outputs
An other common requirement is to be able to write functions which return multiple arguments. This can be done in Julia in a very straightforward manner.
function circle(r) area = π * r^2 circumference = 2π * r return area, circumference end a, c = circle(1.5)
What is happening under the hood is that the
circle function is returning a
tuple, and that
tuple is being destructured into two variables.
In fact, we can also call our
circle function and expect a single output (a tuple), and use it as follows:
shape = circle(1.5) # returns (7.0685834705770345, 2.356194490192345) shape # 7.0685834705770345 shape # 2.356194490192345 a, c = shape # destructures the tuple as in the original
Note that tuples are inmutable structures: we won’t be able to modify the values of
shape. But we can modify the values of
Functions which modify their input (! notation)
In Julia, values are normally not copied when they are passed to function. As a consequence, a function could change the content of input arguments. To let the caller know if this is indeed the case, it’s a convention to append an exclamation mark to names of functions that do modify their arguments.
Note that not every type of variable can modified by a function when passed as an input: the variable has to be mutable. For example,
Arrays are mutable by default (see more in the Array section of this tutorial):
function add_one!(x) x .= x .+ 1 end x = [1,2,3] add_one!(x); # x is now [2,3,4]
Importantly, this procedure won’t work for many variable types (which are inmutable). In such case, the value of a variable like
x will change inside the function
add_one, but the process won’t affect the value of
x outside the function.
This (bang!) notation is also used, for example, in the Plots.jl visualization library, to add more data to an existing
plot object (see the section about Plotting).
Sometimes we don’t need to assign a name to a function. For example, when we need to quickly define a function, to pass it as an argument to another function.
Let’s consider the following example. Let’s say we have written (or we are using) the following code which finds the root of a given function
f, with the secant method (see Wikipedia).
function secant(f,a,b,rtol,maxIters) iter = 0 while abs(b-a) > rtol*abs(b) && iter < maxIters c,a = a,b b = b + (b-c)/(f(c)/f(b)-1) iter = iter + 1 end return b end
Of course, this code can be applied to any function. We could define a function and pass it as an argument, or use anonymous functions as a shorthand.
For example, let’s call this secant procedure to find the so-called “golden ratio”, which is the positive root of the polynomial . We can do this by resorting to an anonymous function, as follows:
φ = secant( x-> x^2 - x - 1, 1, 2, 1e-15, 10 )
Storing and calling functions in a separate file
In order to put our functions in a separate file, we first create a new
myFunctions.jl file, where we only add our functions
function sum_series(n) x = 0 for k in 1:n x = x + (1/k)^2 end return x end function other_function(n) (...) end
and then we can call this from another file by using the
include keyword. Then, Julia then behaves exactly in the same as if we had the functions defined within our current file.
For example, let’s consider that in addition to
myFunctions.jl, we have another file called
test_myFunctions.jl with the following content
include("myFunctions.jl") x = sum_series(100000)
This post covered the basics of how to structure computations in Julia with functions. There is a lot more to learn in this topic! Stay tuned as this is a growing and evolving series of tutorials.