#MonthOfJulia Day 4: Functions
Julia performs Just-in-Time (JIT) compilation using a Low Level Virtual Machine (LLVM) to create machine-specific assembly code. The first time a function is called, Julia compiles the function’s source code and the results are cached and used for any subsequent calls to the same function. However, there are some additional wrinkles to this story.
Julia caters for generic functions which will operate on variables with a variety of data types. But it gains a lot of speed by compiling specialised versions of a function which are optimised to work on particular data types. As a result the code generated by Julia achieves performance which is comparable to that of lower level languages like C and FORTRAN, as illustrated by the benchmark results below.
There are multiple routes to defining a function in Julia, ranging from verbose to succinct. The last statement in a function definition becomes the return value, so it is not necessary to have an explicit
return statement (although for clarity it is often a good idea to be explicit!).
The functions defined above are generic in the sense that they do not pertain to arguments of any particular data type: they should work for all (reasonable) arguments.
Functions can return multiple values as a tuple.
Julia has an interesting convention for functions with side effects: function names which end in a ! modify their first argument. For example,
sort() will return a sorted version of its input, while
sort!() will reorder the elements of the input in place. Maintaining this syntax is important since all function arguments are passed by reference and it is thus perfectly permissible that the values of arguments be modified within a function.
We can get a glimpse of at what happens under the hood during the compilation process.
code_llvm() returns the bytecode generated by the LLVM for a generic function when its applied to an argument of a given type. For example, below we see the bytecode for the function
sqrt() applied to a 64 bit floating point argument.
Digging deeper we can scrutinise the native assembly instructions using
Not being a Computer Scientist, this information is of limited use to me. But it will serve to illustrate the next point. As one might expect, the assembly code generated for a particular function depends on type of the arguments being fed into that function. If, for example, we look at the assembly code for
sqrt() applied to a 64 bit integer argument then we see that there are a number of differences.
So the JIT compiler is effectively generating versions of the generic function
sqrt() which are specialised for different argument types. This is good for performance because the code being executed is always optimised for the appropriate argument types.
Type Specification and Multiple Dispatch
Julia implements multiple dispatch, which means that the code executed for a specific function depends dynamically on the data type of the arguments. In the case of generic functions, each time that a function is called with a new data type specialised code is generated. But it’s also possible to define different versions of a function which are selected on the basis of the argument data type. These would then be applied in lieu of the corresponding generic function. There is an obvious parallel with function overloading.
The Julia documentation articulates these ideas better than I can:
To facilitate using many different implementations of the same concept smoothly, functions need not be defined all at once, but can rather be defined piecewise by providing specific behaviors for certain combinations of argument types and counts. A definition of one possible behavior for a function is called a method. Thus far, we have presented only examples of functions defined with a single method, applicable to all types of arguments. However, the signatures of method definitions can be annotated to indicate the types of arguments in addition to their number, and more than a single method definition may be provided. When a function is applied to a particular tuple of arguments, the most specific method applicable to those arguments is applied. Thus, the overall behavior of a function is a patchwork of the behaviors of its various method definitions. If the patchwork is well designed, even though the implementations of the methods may be quite different, the outward behavior of the function will appear seamless and consistent.
Specific data types are indicated by the
:: type annotation operator. So, for example, we can create a version of
square() which does something unique (and inane) for integer arguments.
You can get a list of the methods associated with a function using
methods(). Below we have both the specialised (
Int64) version as well as the fully generic version of
Type annotations can also be used within the local scope of a function body (as well as
I’ve really only scratched the surface here. There’s a lot more to say about default, positional and keyword arguments, operators, parametric types and a host of other topics. You can find further details of today’s Julia ramblings on github and there’s even more in the documentation for functions and methods. Finally, check out some thoughts on writing good Julia functions from Chris von Csefalvay.