pic
Personal
Website

3c. Defining Your Own Functions

PhD in Economics

Introduction

Recall that functions can be classified into i) built-in functions, ii) third-party functions, and iii) user-defined functions. The previous section has covered the first two, and we now focus on iii).

User-Defined Functions

When writing a script, you'll need to define your own functions. Function names follow similar rules to variable names. In particular, they accept Unicode characters, enabling the user to define functions such as ∑(x). Once you create them, functions can be called without invoking any prefix. This means that a function foo can be called by simply executing foo(x). [note] The method to call a function actually depends on the module in which it's defined, and whether this module has been "imported" or "used". We won't cover modules on this website. However, they're essential when working for large projects, as each module operates as an independent workspace with its own variables. When initiating a new session in Julia, you're actually working within a module called Main.

There are two approaches to defining functions. We'll refer to each as the standard form and the compact form. The standard form is the most general and allows you to write both short and long functions. Conversely, the compact form is employed for single-line functions and is reminiscent of mathematical definitions. To illustrate each form, consider a function foo that sums two variables x and y.

function foo(x,y)
    x + y
end

foo(x,y) = x + y

The output of the compact form is given by the only operation contained. For its part, the standard form returns the last line as its output. You can alternatively specify the output to return through the keyword return. You can also return multiple outputs by defining a collection as the function's result. For this purpose, tuples are the most common choice. [note] This is in fact because tuples are more performant than vectors, as long as the number of elements is small.

These approaches to specifying an output are illustrated below.

function foo(x,y)
    term1 = x + y
    term2 = x * y 

    return term2
end

Output in REPL
julia>
foo(10,2)
20

function foo(x,y)   
    term1 = x + y
    term2 = x * y           # output returned
end

Output in REPL
julia>
foo(10,2)
20

function foo(x,y)
    term1 = x + y
    term2 = x * y 

    return term1, term2     # a tuple, using the notation that omits the parentheses
end

Output in REPL
julia>
foo(10,2)
2-element Vector{Int64}: 12 20

function foo(x,y)
    term1 = x + y
    term2 = x * y
    
    return term1 + term2
end

Output in REPL
julia>
foo(10,2)
32

Functions without Arguments
It's possible to define functions that don't require arguments, as we show below.

Functions without Arguments

function foo()
    a = 1
    b = 1
    return a + b
end

An example of functions without arguments is Pkg.update(), which was introduced when we studied packages.

The Order In Which Functions Are Defined is Irrelevant
A function can be defined anywhere in the code. In fact, you can define a function that calls another function, even if the latter hasn't been defined yet. To illustrate this, consider the following two code snippets, which are functionally equivalent.

foo1(x) = 2 + foo2(x)

foo2(x) = 1 + x

Output in REPL
julia>
foo1(2)
5

foo2(x) = 1 + x

foo1(x) = 2 + foo2(x)

Output in REPL
julia>
foo1(2)
5

Positional and Keyword Arguments

Up to this point, we've been defining and calling functions using the notation foo(x,y). The distinctive feature of this syntax is that arguments are passed in a specific order. Thus, foo(1,2) assigns the first argument to x and the second to y. Function arguments of this kind are known as positional arguments.

One major drawback of positional arguments is their susceptibility to silent errors: if we accidentally swap the positions of arguments, the function may still provide an output. As the number of arguments grows, the likelihood of introducing bugs increases, making it more challenging to detect and debug errors.

One way to circumvent this issue is to use keyword arguments. They require function calls to explicitly specify their arguments using a syntax like foo(x=2,y=4). In this case, the order of arguments also becomes irrelevant, rendering foo(x=2,y=4) and foo(y=4,x=2) valid and equivalent.

The following examples illustrate how to define and call functions using positional and keyword arguments. Additionally, we show that the approaches can be combined. Note that positional arguments necessarily require a semicolon for its definition, but accept a semicolon or a comma during calls.

foo(x, y) = x + y

Output in REPL
julia>
foo(1,2)
2

foo(; x, y) = x + y

Output in REPL
julia>
foo(x=1,y=1)
2
julia>
foo(; x=1, y=1) # alternative notation (only for calling 'foo')
2

foo(x; y) = x + y

Output in REPL
julia>
foo(1 ; y=1)
2
julia>
foo(1 , y=1) # alternative notation
2

Keyword Arguments with Default Values

An additional advantage of keyword arguments is that they allow us to set default values for their arguments.

foo(x; y=1) = x + y

Output in REPL
julia>
foo(1) # equivalent to foo(1,y=1)
2

foo(; x=1, y=1) = x + y

Output in REPL
julia>
foo() # equivalent to foo(x=1,y=1)
2

julia>
foo(x=2) # equivalent to foo(x=2,y=1)
3

Using Arguments as Inputs of Other Arguments

When a function is called, its arguments are evaluated sequentially from left to right. This property enables the user to define subsequent arguments in terms of previous ones. For example, given foo(;x,y), the default value of y could be based on the value of x.

Previous Arguments to Define Default Values

foo(; x, y = x+1) = x + y

Output in REPL
julia>
foo(x=2) #function run with implicit value 'y=3'
5

Splatting

Given a function foo(x,y), it's possible to set the values of x and y through a tuple or vector z. The implementation relies on the splat operator ..., which unpacks the individual elements of a collection and passes them as separate arguments.

foo(x,y) = x + y

z = (2,3)

Output in REPL
julia>
foo(z...)
5

foo(x,y) = x + y

z = [2,3]

Output in REPL
julia>
foo(z...)
5

Anonymous Functions

Anonymous functions represent a third way to define functions. Unlike the previous definitions of functions, they're commonly introduced with a different purpose: as inputs to other functions. [note] Anonymous functions are also known as lambda functions in other languages.

As the name suggests, anonymous functions aren't referenced by a name. Their syntax resembles the arrow notation from mathematics (e.g. \(x\mapsto \sqrt{x}\)). Specifically, single-argument functions are expressed as x -> <body of the function>. Likewise, anonymous functions that depend on two or more arguments are expressed by (x,y) -> <body of the function>.

For the demonstration, let's consider the built-in function map(<function>, <collection>). This function takes another function as its first argument, which is then applied element-wise to the collection passed as the second argument. For example, map(add_two, x) applies the function add_two(a) = a + 2 to each element of x = [1,2,3], thus returning [3,4,5]. In subsequent sections, we'll explain the function map in detail. For now, just take it as an example for anonymous functions.

The execution of map(add_two, x) requires defining add_two previous to calling it. The creation of temporary function not only clutters our workspace, but it's additionally inconvenient if add_two won't be reused. In this context, an anonymous function represents an elegant solution, eliminating the need to create a temporary function like add_two.

x          = [1, 2, 3]
add_two(a) = a + 2

result     = map(add_two, x)

Output in REPL
julia>
result
3-element Vector{Int64}: 3 4 5

x          = [1, 2, 3]


result     = map(a -> a + 2, x)

Output in REPL
julia>
result
3-element Vector{Int64}: 3 4 5

By using map, we can also demonstrate the syntax of anonymous functions having multiple arguments. For operations requiring more than one argument, its syntax is map(<function>, <array1>, <array2>). For instance, map(+, [1,2], [2,4]) provides the sum of each pair of numbers, yielding [3,6].

x        = [1,2,3]
y        = [4,5,6]

add(a,b) = a + b
result   = map(add_two, x, y)

Output in REPL
julia>
result
3-element Vector{Int64}: 3 4 5

x        = [1,2,3]
y        = [4,5,6]


result   = map((a,b) -> a + b, x, y)

Output in REPL
julia>
result
3-element Vector{Int64}: 5 7 9

The "Do-Block" Syntax (OPTIONAL)

Anonymous functions can be useful to keep our code tidy. However, they aren't particularly practical if functions span over several lines. Nonetheless, the inconvenience can be mitigated by what's known as a do-block. This allows us to insert the anonymous function separately, and then pass it as the first argument to a function call. Its syntax for a function foo(<inner function>, <vector>) is as follows.

Do-Block Syntax

foo(<vector>) do <arguments of inner function>
    # body of inner function
    end

To illustrate the notation with a concrete scenario, let's revisit the map function example and rewrite it using a do-block.

x          = [1, 2, 3]
add_two(a) = a + 2

result     = map(add_two, x)

Output in REPL
julia>
result
3-element Vector{Int64}: 3 4 5

x          = [1, 2, 3]


result     = map(a -> a + 2, x)

Output in REPL
julia>
result
3-element Vector{Int64}: 3 4 5

x          = [1, 2, 3]

result     = map(x) do a
                a + 2
                end

Output in REPL
julia>
result
3-element Vector{Int64}: 3 4 5

Do-blocks also accept anonymous functions with multiple arguments, as shown below.

x = [1,2,3]
y = [4,5,6]

add(a,b) = a + b
result   = map(add_two, x, y)

Output in REPL
julia>
result
3-element Vector{Int64}: 5 7 9

x        = [1,2,3]
y        = [4,5,6]


result   = map((a,b) -> a + b, x, y)

Output in REPL
julia>
result
3-element Vector{Int64}: 5 7 9

x        = [1,2,3]
y        = [4,5,6]

result   = map(x,y) do a,b      # not (a,b)
                a + b
                end

Output in REPL
julia>
result
3-element Vector{Int64}: 5 7 9

A Function's Documentation (OPTIONAL)

To conclude this section, we cover how to document user-defined functions. This involves adding a string expression immediately before the function definition.

After this, the documentation can be accessed in the same manner as with built-in functions: you can type the function's name in the REPL after pressing ?, or directly hover over the function's name in VS Code. [note] Here, we explained how to access a function's documentation, under the subtitle "To See The Documentation of a Function".

"This function is written in a standard way. It takes a number and adds two to it."
function add_two(a)
   a + 2
end

"This function is written in a compact form. It takes a number and adds three to it."
add_three(a) = a + 3

For further details, see the official documentation.