Monday, March 29, 2021

Functions: Introduction

Functions allow you to package your code for easy reuse, and this post shows the basics of writing our own functions as well as calling them. Functions are pivotal to the Julia language, and the topic is too big to fit into three blog posts, let alone into this one.

In some elementary math textbooks, functions are pictured as a machine with a hopper at the top into which one inserts numbers, and an exit port where the answer comes out. Functions in Julia are similar to regular mathematical functions: they transform input values into one (or more) output values. This intuition carries over to programming languages.

Customary Way of Defining Functions

Julia supports multiple ways for defining functions. The way that is most similar to other languages is this:

In [1]:
Out[1]:
addTwoNumbers (generic function with 1 method)

To use this function (to "call" it), just pass-in some values:

In [2]:
Out[2]:
8

In this example, "addTwoNumbers" is the name of the function, and the two values in parentheses are called arguments. The statement(s) after the name and arguments but before the end is called the body of the function.

The valid naming of functions follows the same rules as naming variables - no reserved words, etc. There is a convention, however, that function names ending with an exclamation point (like the built-in functions push!() and pop!()) behave in a certain way. We'll see what this means in the "Arrays" chapter. For now, name your functions anyway you want, just no exclamation marks.

In comparison to other languages like C or C++, there are three things to notice:

  • The data types of x and y are not specified
  • There is no "return" statement indicating what value(s) the function should return
  • There is nothing indicating the type of value that the function should return.

Type specifications are optional in Julia; we'll see how to do this in a later post.

As it goes, the last calculation performed is what gets returned. There are times when a "return" would be helpful, and Julia indeed has a "return" statement.

So, this function can be rewritten as:

In [3]:
Out[3]:
addTwoNumbers (generic function with 1 method)

and this behaves exactly the same as above.

Function bodies can include more than one line, for example:

In [4]:
Out[4]:
findMax (generic function with 1 method)

Of course, we can use the trinary operator to write an equivalent function with a much shorter body:

In [5]:
Out[5]:
findMax (generic function with 1 method)
In [6]:
Out[6]:
17
In [7]:
Out[7]:
43

Default Values for Arguments

Sometimes it makes sense to give default values to a function's arguments. We'll see practical examples later, here's how to define a function with some arguments having default values:

In [8]:
Out[8]:
testDefaultValues (generic function with 3 methods)
In [9]:
x = 7
y = 8
z = 9
In [10]:
x = 7
y = 8
z = 3
In [11]:
x = 7
y = 2
z = 3
In [12]:
MethodError: no method matching testDefaultValues()
Closest candidates are:
  testDefaultValues(::Any) at In[8]:1
  testDefaultValues(::Any, ::Any) at In[8]:1
  testDefaultValues(::Any, ::Any, ::Any) at In[8]:1

Stacktrace:
 [1] top-level scope
   @ In[12]:3
 [2] eval
   @ ./boot.jl:360 [inlined]
 [3] include_string(mapexpr::typeof(REPL.softscope), mod::Module, code::String, filename::String)
   @ Base ./loading.jl:1094

So y and z are optional arguments, and when omitted, the default values are used. However, x is required, and when it is omitted, there is an error!

One-Line Function Definitions

Functions can be enormously more complicated than the examples thus far! But single line functions are sufficiently common as to warrant a short-hand way of defining them. The addTwoNumbers function can be rewritten in this notation as:

In [13]:
Out[13]:
addTwoNumbers (generic function with 1 method)

Just about every formula used for calculating areas and volumes, performing unit conversions, and etc., can be written as a one-line function. For example:

In [14]:
Out[14]:
convertMetersToYards (generic function with 1 method)

Anonymous Functions

Sometimes we will use a function only once and be done with it, so that the function doesn't even need to have a name. These "one off" functions are called anonymous functions, and are written with an -> pointing from the arguments to the result. An anonymous function that behaves just like the addTwoNumbers function can be written as:

In [15]:
Out[15]:
#1 (generic function with 1 method)

"Wait!" you say, "if this function has no name, then how can it be called? Anonymous functions are supposed to be for single use - how can we even use this function once?"

The answer is, you can't easily do so. Anonymous functions are mostly used as arguments to other functions - yes, functions can be passed into other functions.

We will see practical examples of this in the "Arrays" chapter, which will make all this extremely intuitive. In the meantime, here is a very contrived example:

In [16]:
Out[16]:
applyToTwoArguments (generic function with 1 method)

So, this function is expecting us to pass-in a function and two additional arguments, and the result will be that passed-in function applied to these two extra agruments.

How to call applyToTwoArguments? There are two possibilities:

  • We pass-in a previously defined function for the first argument
  • We pass-in an anonymous function
In [17]:
Out[17]:
96
In [18]:
Out[18]:
96

Functions that Return Other Functions

We just saw that we can use a function (anonymous or otherwise) as an argument to another function. Can functions return other functions? Yes, and here is a practical example.

In [19]:
Out[19]:
lineThroughTwoPoints (generic function with 1 method)
In [20]:
Out[20]:
(::var"#y#5"{Float64, Float64}) (generic function with 1 method)

Great, but how do we call this new function myLine? Just like any other function with one argument:

In [21]:
Out[21]:
-19.0
In [22]:
DivideError: integer division error

Stacktrace:
 [1] lineThroughTwoPoints(x1::Int64, y1::Int64, x2::Int64, y2::Int64)
   @ Main ./In[19]:10
 [2] top-level scope
   @ In[22]:3
 [3] eval
   @ ./boot.jl:360 [inlined]
 [4] include_string(mapexpr::typeof(REPL.softscope), mod::Module, code::String, filename::String)
   @ Base ./loading.jl:1094

Here's another example. In the finite difference calculus, the forward difference of a given function f(x) is f(x + 1) minus f(x), and is usually denoted Δf. So, given a function f(x), the forward difference Δf(x) is a new function. Here's how to implement this (typing \Delta [TAB] to get the capital delta):

In [23]:
Out[23]:
Δ (generic function with 1 method)

We could, if we wanted to, use this the same way as lineThroughTwoPoints, namely pass in a function into Δ, and store the result in a new function, then apply that new function on a particluar numeric argument, like this:

In [24]:
Out[24]:
7

Instead of storing the function returned by Δ into a variable like forwardDiff then feeding 3 into forwardDiff, we can just perform both actions like this:

In [25]:
Out[25]:
7

Returning Multiple Values

Functions can return more than one value, and the Julia assignment statement lets us "unpack" this result.

In [26]:
Out[26]:
findMinAndMax (generic function with 1 method)
In [27]:
Out[27]:
(14, 190)
In [28]:
Out[28]:
14
In [29]:
Out[29]:
190

Varargs Functions

So far, we have seen functions taking a pre-defined number of arguments: addTwoNumbers and findMinAndMax have two arguments, lineThroughTwoPoints takes four arguments, and etc. There are times when we need to write functions that take a variable number of arguments. These functions are usually called varargs functions.

In [30]:
Out[30]:
addThemUp (generic function with 1 method)
In [31]:
Out[31]:
0
In [32]:
Out[32]:
12
In [33]:
Out[33]:
22

Let's examine the definition of the addThemUp function

  • To indicate that zero or more values can be passed-in, we put ellipsis ("...") after the argument called nums
  • Once inside the function, nums is what is called a tuple - a list of values that we can iterate over
  • Initialize a variable called "total" to zero
  • Loop over the items in the tuple, and add each item to the total
  • Finally, return the running total.

When ellipsis is used as part of the argument list in a function definition, this is called "slurping" - when the function is called, all the actual arguments are slurped together and made into a tuple.

Can we have more than one positional argument with ellipsis, something like this: testMultipleTuples(x..., y...)?

No: how would Julia tell when the first tuple ends and the second begins? How would slurping be performed?

The argument with the ellipsis must be the last argument of the function, and there can only be one of them in an positional argument list.

Sometimes, we want to pass-in some fixed arguments in addition to the variable number of arguments. This is still a varargs function. In order for Julia to differentiate the fixed arguments from the ones that will be slurped into a tuple, the tuple must again be the last argument in a varargs function.

Here's an example that takes a multiplier and a variable number of numbers; it adds those numbers up, the returns the sum times the multiplier.

In [34]:
Out[34]:
sumThenMultiply (generic function with 1 method)
In [35]:
Out[35]:
30

Let's examine this code very carefully...

  • We pass in an argument called m (the multiplier), followed by a variable number of numbers called nums
  • Like we saw in addThemUp, once we're inside the function, those values get slurped into a tuple called nums
  • Because addThemUp takes a bunch of separate numbers and not a tuple, we cannot pass nums directly to addThemUp
  • Instead we need to "splat" the tuple, meaning that we need to convert the tuple into a bunch of separate numbers
  • To splat a tuple, just follow it by an ellipsis, hence the line m * addThemUp(nums...)

In summary, the ellipsis has two different meanings depending on how it is used:

  • When used in the argument of a function definition, it means to "slurp" the actual arguments into a tuple
  • When used when calling a function, it means to "splat" the tuple info individual arguments.

Positional vs Keyword Arguments

With some functions that take two arguments, like addTwoNumbers, the order that you pass the values in does not alter the returned value:

In [36]:
Out[36]:
true

With other functions, like volumeOfCylinder(r, h), the order of the arguments is important

In [37]:
Out[37]:
37.69911184307752
In [38]:
Out[38]:
56.548667764616276

Arguments like this are called positional arguments - their position in a function's argument's list matters!

What happens when we have a function that has four, five, or more arguments? How do we use that function? Do we have to RTFM? Quelle horreur!

The solution of this problem is called keyword arguments or named arguments. Using kwargs allows us to pass-in the values in any order we want; as a nice bonus, it makes reading the code easier for those of us who can't take 15 minutes out of our hot young lives to RTFM.

Here's an example. Imagine we're doing computer graphics and we're writing a function to draw a circle. What type of information do we need to draw a circle?

  • x and y of the circle
  • the radius
  • the fill color
  • the edge color
  • the edge thickness

That's six arguments - if we used only positional arguments, we would have to memorize the order that these six arguments are to be passed in. Here's an implementation that uses a mixture of positional and keyword arguments. We'll give two of the keyword args a default value:

In [39]:
Out[39]:
drawCircle (generic function with 1 method)

So x, y, and r are positional arguments, and fillColor, edgeColor, edgeThickness are keyword arguments - all the positional arguments are before the semicolon, all keyword arguments come after the semicolon. Let's see how kwargs make our life easier.

In [40]:
x = 50
y = 30
r = 7
fillColor = red
edgeColor = black
edgeThickness = 1
In [41]:
x = 50
y = 30
r = 7
fillColor = red
edgeColor = green
edgeThickness = 1
In [42]:
x = 50
y = 30
r = 7
fillColor = red
edgeColor = green
edgeThickness = 1

Notice that the order of the keyword arguments is irrevelent, what matters is the name of the argument. Notice also that when calling this function, we're using just a comma before fillColor instead of a semicolon. Using a semicolon works, too:

In [43]:
x = 50
y = 30
r = 7
fillColor = red
edgeColor = green
edgeThickness = 2

Julia has a nice bit of "syntatic sugar": if a variable matches the name of a keyword argument, the assignment statement can be omitted. This is the only circumstance when the semicolon must be used. For example:

In [44]:
Out[44]:
"orange"
In [45]:
x = 50
y = 30
r = 7
fillColor = orange
edgeColor = blue
edgeThickness = 1
In [46]:
x = 50
y = 30
r = 7
fillColor = orange
edgeColor = blue
edgeThickness = 1

Again, when using this trick, the positional arguments must be separated from the keyword arguments by a semicolon!

Conclusion

In this post we saw:

  • Three ways of defining functions: conventional definitions, one-line definitions, and anonymous definitions
  • That we can assign default values to a function's arguments
  • Functions can also return multiple values
  • How to create aonymous functions and use them in an (artificial) example
  • That a function can be passed-in to another function
  • That functions can return other functions
  • How to create and use varargs functions
  • How to define functions with positional and keyword arguments.

As stated in the introduction to this chapter, the concept of functions is vast in Julia, and we just covered the basics. More information on functions will be given in many subsequent chapters. The good thing, though, is that 100% complete mastery of every nuance of Julia functions is not required in order to define your own functions and to use them.

Problems

  1. Implement the backward difference operator ∇ (typed \nabla [TAB]) and the shift operator E
  2. Write a function that converts cartesian coordinates (x, y) into polar coordinates (r, θ)

No comments:

Post a Comment