Functions#
Learning Objectives#
Define and call functions in Julia
Understand multiple dispatch and implement function methods that handle different types of inputs
Understand the
!
naming convention and when to use itUse anonymous (lambda) functions for short snippets of functionality
Understand the value of organising code into functions
Defining Functions#
In Julia, you define a function using the function
keyword (and terminate with end
) or using a shorter one-line syntax. Functions are objects in Julia, and you can assign them to variables, pass them as arguments, etc.
A simple function definition would be:
function say_hello(name)
println("Hello, ", name, "!")
end
This function, say_hello
, takes one argument name
and prints a greeting. You would call it like say_hello("Alice")
.
We return values from a function by using the return
keyword. If you omit return
, Julia will return the value of the last expression in the function (much like in R). For example:
# Function to square a number (explicit return)
function square(x)
return x * x
end
# Function to cube a number (implicit return of last expression)
function cube(x)
x
x * x
x * x * x # last expression's value will be returned
end
println(square(3))
println(cube(3))
9
27
Julia also provides a concise single-line function definition syntax:
add(a, b) = a + b
This defines a function add
that returns a+b
. It’s equivalent to writing a multi-line function that returns a+b
. You can use whichever style you find clearer; the multi-line form is helpful for longer-function bodies.
For example, the function below is a function that can be used to calculate the area of a circle given its radius:
# Calculate area of a circle with given radius
function calculate_circle_area(radius)
area = π * radius^2 # Julia has π predefined (or use 3.14159 for Pi)
return area
end
r = 5.0
println("Radius: ", r, ", Area: ", calculate_circle_area(r))
Radius: 5.0, Area: 78.53981633974483
Note: In the REPL or in most editors with the Julia extension (e.g. VS Code), to get the character π
you can type a backslash, pi
, then hit Tab. Julia will replace it with the single-character π.
The different components of a function within Julia are:
Function Name: e.g.
calculate_circle_area
in the example above. By convention, use lowercase and underscores for multi-word names.Parameters (Arguments): e.g.
radius
. You can optionally add a type annotation to a parameter (likeradius::Float64
) to indicate this function is for a specific type. Still, if you leave it untyped, it will accept any types that support the operations used inside.Function Body: the code inside, which can use the parameters. In our case, we compute
area
.Return value: the value returned by the function. In the circle example, we used
return
for clarity, but we could also writeπ * radius^2
as the last line and omitreturn
.
Exercise: Writing a function#
Write a function f_to_c(fahrenheit)
that converts a temperature from Fahrenheit to Celsius. The formula is \(C = (5/9) \times (F - 32)\). Test your function with a couple of values; we should have
f_to_c(32) == 0
f_to_c(212) == 100
# Answer here
Keyword Arguments#
Similarly to Python and R, Julia functions can also accept keyword arguments, which are specified by a name/value pair, with a given default value. These are defined after a semicolon (;
) in the function signature:
# Return a string that describes a number with given unit and precision
# Note: `round` rounds a number to a given number of decimal places
function describe(x; unit="kg", precision=2)
formatted = round(x; digits=precision)
return "$formatted ($unit)"
end
# Calls:
println(describe(12.3456)) # uses defaults: "12.35 (kg)"
println(describe(12.3456; precision=3)) # "12.346 (kg)"
println(describe(12.3456; unit="meters")) # "12.35 (meters)"
println(describe(12.3456; unit="m", precision=1)) # "12.3 (m)"
12.35 (kg)
12.346 (kg)
12.35 (meters)
12.3 (m)
Note that the general syntax for calling a function with arguments and keyword arguments is:
fn(arg1, arg2; kwarg1=value1, kwarg2=value2)
Note also that
Argument order matters:
fn(arg1, arg2)
is not the same asfn(arg2, arg1)
Keyword argument order does not matter, because the keyword names make order unnecessary:
# These are equivalent fn(arg; kwarg1=value1, kwarg2=value2) fn(arg; kwarg2=value2, kwarg1=value1)
Multiple Dispatch#
Multiple dispatch was introduced conceptually earlier; now, let’s see how to implement it with functions.
In Julia, a function should really be thought of as a collection of methods for implementing it. We define generic functions which dispatch to specific methods of implementation, depending on the type of the arguments supplied. Put another way, we can define different methods for the same function name which correspond to different parameter type signatures. Julia will dispatch (choose) the method that most precisely matches the types of the actual arguments you pass.
Julia will check the types of all the arguments of a function call when determing which method to apply. This is what is meant by multiple dispatch: here, the ‘multiple’ refers to the fact that the types of all the arguments are considered, rather than e.g. just the first argument (which is what is done in R’s ‘generic functions’, for example).
It’s important to remember that there is no dispatch on keyword arguments. Julia’s multiple-dispatch mechanism examines only the type of positional arguments.
Extended example: an add
function#
Imagine we want an add function that behaves differently based on argument types:
# Define add for two Ints
function add(x::Int, y::Int)
x + y
end
# Define add for two Strings (concatenate)
function add(x::String, y::String)
return string(x, y)
end
println(add(10, 20))
println(add("Hello", "world!"))
30
Helloworld!
We defined two methods for add
: one that accepts two Int
and one that accepts two String
. When we call add(10, 20)
, Julia sees both arguments as Int
and uses the integer addition method. When we call add("Hello", "world!")
, both are String
, so it uses the string concatenation method. The same function name, add
, is used, but the behaviour differs by the type of arguments.
If we try to call the function add
on a set of arguments for which there is no method defined, a MethodError
will be thrown:
# Error: not defined for Int, String
add(1, "hello")
MethodError: no method matching add(::Int64, ::String)
Closest candidates are:
add(::String, ::String)
@ Main In[5]:7
add(::Int64, ::Int64)
@ Main In[5]:2
Stacktrace:
[1] top-level scope
@ In[6]:2
# Error: not defined for more than two Ints
add(1, 2, 3)
MethodError: no method matching add(::Int64, ::Int64, ::Int64)
Closest candidates are:
add(::Int64, ::Int64)
@ Main In[5]:2
Stacktrace:
[1] top-level scope
@ In[7]:2
Let’s now implement extra methods to handle these cases. We’ll handle the case where a user can supply and Int
and a String
: our function add
will convert the integer to a string and then use string concatenation.
# Add an Int and a String (using one-line method definition)
add(x::Int, y::String) = add(string(x), y)
# And vice-versa
add(x::String, y::Int) = add(x, string(y))
add (generic function with 4 methods)
Notice how Julia keeps track of the number of methods we’ve defined for add
.
Notice also how the above implementations utilise other methods of add
that we defined earlier. This is a common pattern: it allows us to avoid repeating similar code, which makes it easier to maintain. It also implements add
for these mixed types on a more conceptual level: first convert the integer to a string, then use whatever method we defined for adding two strings together.
Exercise: adding more methods#
Add methods to handle the cases where
A single
Int
orString
is providedThree
Int
s are provided (performs usual arithmetic addition)Three
String
s are provided (performs string concatenation)
Try to use previously implementated methods of add
where possible.
# Answer here
Extending to handle more general types#
How could we extend add
to handle more types? It is clearly impractical to keep adding more methods on concrete types (for example, adding an add(x::Float64, y::Float64)
or a mixed add(x::Int, y::Float64)
etc.) Let’s make the decision that, for any two objects x
and y
, the function add
should perform usual numerical addition if x
and y
are both numbers, otherwise they should be converted to strings and concatenated. Julia makes this simple to do. We define a method for add
that works on any two objects as follows:
# Generic add for any two objects
function add(x, y)
return string(x, y) # converts x and y to strings then concatenates
end
add (generic function with 5 methods)
Note that this is equivalent to specifying
function add(x::Any, y::Any)
return string(x, y) # converts x and y to strings then concatenates
end
Let’s now evaluate add
on some different combinations of object. As mentioned briefly above, the key thing to remember is
When applying a function, Julia will use the method whose type signature most precisely matches (i.e. is most ‘specialized’ to) the types of the given arguments.
Consider the following calls to add
:
# String, NamedTuple
println("(String, NamedTuple): ", add("foo", (a = 1, b = 2)))
# Int, Array
println("(Int, Array): ", add(1, [2, 3]))
# Int, Int
println("(Int, Int): ", add(1, 2))
(String, NamedTuple): foo(a = 1, b = 2)
(Int, Array): 1[2, 3]
(Int, Int): 3
Notice that the method that got evaluated for (Int, Int)
was the one we defined earlier:
function add(x::Int, y::Int)
return x + y
end
since this is more specialized to the types of the arguments than the general add(::Any, ::Any)
version we defined a moment ago.
In contrast, notice what happens if we supply an Int
and a Float64
:
# Int, Float64
println("(Int, Float64): ", add(1, 2.2))
(Int, Float64): 12.2
The version of add
that gets applied is the add(::Any, ::Any)
version, because there isn’t a more specialized method available. To fix this, let’s recall that all the numerical types defined in Julia are (concrete) subtypes of the abstract type Number
. Julia knows how to evaluate a + b
where a
and b
are instances of Number
. We can therefore define
# Add for any two numbers
function add(x::Number, y::Number)
return x + y
end
add (generic function with 6 methods)
This method is specialized to the case where x
and y
are subtypes of Number
, so now add
will perform numerical addition whenever we supply two numbers (be they Int
, Float64
, Complex
, etc.)
# Int, Float64 (again)
println("(Int, Float64): ", add(1, 2.2))
(Int, Float64): 3.2
Listing methods#
You can list out all methods defined for a function with the methods
function:
# See all methods for `add`
methods(add)
- add(x::String, y::Int64) in Main at In[8]:5
- add(x::Int64, y::String) in Main at In[8]:2
- add(x::String, y::String) in Main at In[5]:7
- add(x::Int64, y::Int64) in Main at In[5]:2
- add(x::Number, y::Number) in Main at In[13]:2
- add(x, y) in Main at In[10]:2
Exercise: summing up#
Knowing what you now know about Julia’s dispatch mechanism, what’s the minimum number of methods you would need to define if we were to implement add(x, y)
again from scratch (for two arguments only)?
Exercise: extension to 3 args#
Joe Bloggs is looking to extend add
to handle 3 arguments. He claims “Ah, this is easy, all we need to do is define
add(x, y, z) = add(add(x, y), z)
and we’re done!” Is Joe correct? Why / why not?
Why Multiple Dispatch Matters#
Allowing functions to handle a range of argument types allows us to build up flexible and general code rather quickly: it’s not uncommon to define a new function in Julia that is implemented with other functions in such a way that the new function automatically support a large variety of different argument types ‘for free’. This is supported by Julia’s standard library, which provides a rich set of functions with methods supporting a variety of standard types.
The benefits of this are:
Performance: Julia will pick the most specific method and compile optimised machine code for that method. This is especially valuable for methods that get called multiple times (e.g. within a loop).
Extensibility and flexibility: Code can be written in a way to provide special cases for specific types when needed. Users can add new methods to functions (even those from the standard library or other packages) for new types they define without modifying the original code, a form of polymorphism.
Clarity: We can use the dispatch mechanism instead of writing functions with long chains of type-checking
if-else
statements.
Multiple dispatch is particularly well-suited to mathematical code, where operations might naturally be defined by any combinations of operand types (e.g., mixing units, numeric types, etc.) Other languages typically only dispatch on a single argument (R generic functions) or class type (Python, Java), meaning there is one argument / object that ‘owns’ the method. This latter approach can sometimes feel unnatural in scientific code.
The !
naming convention for mutating functions#
It is a good idea to make it clear when a function mutates (i.e. changes) one or more of its arguments: it is very easy to end up with incorrect code when some function has quietly mutated the value of a variable. In Julia, there is a convention that any function mutating one or more of its arguments should have a name ending in !
. This “bang” signals to users that the function will perform in-place modification rather than returning a new object.
This convention is used with some of the base functions provided in Julia. Arrays are a good source of examples:
arr = [3, 1, 2]
# Sort in-place
sort!(arr)
println(arr)
# Append 4 to the array
push!(arr, 4)
println(arr)
# Remove and return the last element
pop!(arr)
println(arr)
# Concatenate another collection into the array
append!(arr, [5,6])
println(arr)
[1, 2, 3]
[1, 2, 3, 4]
[1, 2, 3]
[1, 2, 3, 5, 6]
Anonymous Functions#
Sometimes, you need a small throwaway function to pass as an argument to another function. Julia allows creating anonymous functions using the -> syntax. These are similar to the “lambda” functions in Python.
For example, suppose we have an array of numbers, and we want to square each of them. We can use the map
function to do this, takes a function and an array as arguments and returns the result of applying the function to each element of the array:
arr = [x1, x2, x3, ...] ==> map(f, arr) = [f(x1), f(x2), f(x3), ...]
We could define a function called square
and then use this in map:
square(x) = x^2
map(square, arr)
Alternatively, we can write this in one line by using an anonymous function for squaring: x -> x^2
. Here, x
is the input, and x^2
is the output. (It doesn’t matter that we used x
for the variable, we could have used another name, like y
or foo
.)
numbers = [1, 2, 3, 4, 5]
# Square each number
map(x -> x^2, numbers)
5-element Vector{Int64}:
1
4
9
16
25
You can assign an anonymous function to a variable if you want to reuse it:
# Define a square function using an anonymous function
square = x -> x^2
square(10)
invalid redefinition of constant Main.square
Stacktrace:
[1] top-level scope
@ In[18]:2
However, if you’re going to name it, a standard function definition is often clearer, So typically, we only use the ->
syntax inline when passing to other functions or for short-lived usage.
Note: unlike named functions, anonymous functions cannot have multiple methods added to them; each anonymous function carries exactly one method. If you need multiple dispatch (i.e., different behaviours for different argument types), define a standard function with function … end
and type‐annotated methods instead.
Exercise: anonymous Functions#
Using an anonymous function, create an array evens
that contains only the even numbers from an existing array vals = [1, 2, 34, 8, 11, 14]
. Hint: You might want to use the filter
function!
# Answer here
Exercise: mutation#
Modify the code you wrote for the previous exercise so that the vals
array is modified in-place.
# Answer here
Organising Code with Functions#
It’s good practice to wrap logic inside functions rather than writing everything in global code. Functions:
Functions make code reusable (you can call the same code with different inputs easily)
They clarify intent (a function name can describe what the code does)
In Julia, functions are important for performance: code inside functions is optimised and JIT-compiled, whereas code in global scope is more complex for the compiler to optimise.
As you build larger programs, you’ll likely have many small functions, each handling a specific task, which together solve your problem.