Tips and tricks in the Julia language

August 2022

This post is some kind of a personnal collection of things learnt on-the-fly, small tips and tricks in the Julia language. They're not life-safers and won't make your code work better nor faster, but they'll probably help you write cleaner code, they're incredibly smooth, and using them fills me with an intense satisfaction. I hope this helps.

Contents

REPL tricks

Typing ] in a REPL brings you to package mode – you can activate environments or add packages.

Typing ? in a REPL brings you to help mode – type anything and you'll get documentation.

Typing ; in a REPL brings you to shell mode – you can do your regular ls and cd and grep commands.

To come back to the normal mode, juste type del.

Writing the output to the REPL

If you're in a REPL and you want to define something, say an array, the ouput will be written in the REPL. In other words, you'll see

julia> x = rand(2)
2-element Vector{Float64}:
 0.35720058578070635
 0.5348958549457257

If you don't want the output to be displayed, you only have to add ; at the end of the line. Also, you can chain many expressions in the same line if you separate them with ;.

julia> x = rand(2);y = 2*x;

Updating Julia

Julia evolves fast. Right now we're at 1.8x, but 1.9 is in alpha and scheduled soon. To upgrade my local julia version, I always use Abel Siqueira's Jill installer. Basically, you just have to

sudo bash -ci "$(curl -fsSL https://raw.githubusercontent.com/abelsiqueira/jill/main/jill.sh)"

in your terminal and that's all.

Showing off with Unicode

Yes, you can write unicode in Julia.

No, you should not abuse unicode in Julia.

Some people just do not know how to reproduce unicode input and they're stuck trying to copy-paste your fancy ⨦(🌁, 🌄) = 🌁 + √🌄 (yes, this is defining f(a,b)=a+bf(a,b) = a+\sqrt{b}). But Julia is probably the only language where I can write crystal-clear mathematical functions that are directly readable as if they were LaTeX:

φ(θ) = 0.5 * (√θ - cos(θ))

And you can overload and use all the usual symbols, from greek letters (α, β, Θ...) to operators (∫, ∑, ∈, ≤...), mathbb/mathfrak/mathcal/mathscr letters (𝕄, ℱ, 𝓝, 𝔖), and so on. To get all those, see the julia unicode manual. In the VScode extension for julia, when you want to use unicode letters (for instance \lambda for λ), you type \lambda then shift and use autocompletion.

Various kinds of syntactic sugar

Julia has nice syntactic shortcuts; they're clear, usefull, and simple to use, but some of them are not so well-known. Here's my personnal list.

condition ? yes : no

This is the same as in C. Instead of writing

if condition
    x = 0
else
    x = 1
end

One can simply use x = condition ? 0 : 1. The condition must be a boolean variable. Note that having spaces around the ? and : operators is mandatory.

Short-circuit boolean operators

Those are also present in other programming languages but for some reason I always forget which one is which one. The logical and and or are & and |. But beware! In expr1 | expr2, both expressions are tested. This is slightly inefficient: if expr1 is true, there's no need to check expr2. To avoid these spurious evaluations, we have the « short-circuit » operators && and || which do exactly what you think they do.

You can use these to replace some if statements. For instance, condition && f() checks if condition if true, and if true, performs f(). Typically, you see this in functions, for returning things based on a simple check on the argument. For example if you want to throw an error message, you can do

function f(x)
    x<=0 && return ArgumentError("x must be nonnegative")
    #rest of the code
end

Defining functions

There are at least three ways to define functions in Julia. The classical one reads

function f(x)
    return x^2
end

and note that one is not forced to use the return keyword: if the last line of the code block defining a function is simply an expression, then the function will return this expression. Thus, the former function is strictly the same as

function f(x)
    x^2
end

Now, there's the inline way:

f(x) = x^2

and finally there's the anonymous way:

x -> x^2

This last way is useful for passing functions as argument of other functions, without hassling to give them a name. Typically, if you want to apply a function to every element in an array, you can use

map(x->x^2, array)

Feeding things to structs

Despite being essentially functional, Julia allows some object-oriented-style code. A very useful tip is that if you defined a custom Struct, you might want to call it like a function –- just as in Python, you can override the self.__call__() method for your objects. Say you have a struct,

struct Thing
    a
end

Typically, this can be a machine-learning model, such as a multi-layer perceptron. The functional way to do this would be to define a feed function, such as

feed(T::Thing, x) = #code which outputs something

but it should be more intuitive if we could call T(x) directly instead. This can easily be done with

(T::Thing)(x) = #same code

Piping

You have two functions, say f(x) = x^2 and g(x) = sin(x). If you want to compute g(f(x))g(f(x)) for some random xx, you can write g(f(x)), but you can also pipe the functions:

julia> rand() |> f |> g
4.847492976007113e-7

and you can also broadcast the functions:

julia> rand(3) .|> f .|> g
 0.4548384389788184
 0.7478851483599316
 0.20344218484963622

This works with any function; it is common practice, when using a GPU with CUDA.jl, to put an array or a model to the gpu using model = model |> gpu instead of model = gpu(model)

Finally, note that you can broadcast the pipe itself with the same syntax:

julia> rand(2) .|> (f,g)
 0.5208181108065045
 0.047596231335982044

Slurping and splatting

Slurping and splatting refer to the two (different) uses of the ... operator: see the Julia manual.

Splatting. If X = [1, 2, 3] and f(a,b,c) = a + b - c for instance, then f(X) obviously gives you an error since there is no method matching f(::Vector) or f(::Array). But then, f(X...) does the trick. What the slurping operator ... does is similar to what Python's * operator does: it simply unfolds the elements in the array and passes them as argument to the function. This is super useful.

Slurping. By contrast you can also use ... in functions definitions when you don't exactly know how many arguments there might be. Typically, a function defined by f(args...) = #code can take any number of arguments, but they'll be combined into a single argument (a tuple, actually). Look at this:

julia> f(x...) = x
f (generic function with 1 method)
julia> f(1,2,3)
(1, 2, 3)
julia> f("a")
("a",)

Ellipsis notation

Python has its famous ellipsis notation for slicing, ...; roughly speaking, it allows to access a range of indices in a list or array, without having to specify the indices. For instance, if you have an array x with 3 dimensions, of size, say, (5,6,7), writing x[0,:,:] is the same thing as writing x[0,...]. Since ... is already taken in Julia for the slurping operator, we use .. instead for the ellipsis notation. This is a part of Chris Rackauckas's package EllipsisNotation.jl which just needs to be installed with Pkg and imported with using EllipsisNotation.

Identity matrices

The identity matrix is I, period. Nothing more is needed (except using LinearAlgebra of course). You don't need to specify its size or its datatype, those will be inferred by Julia when you need it. For instance you can very well write rand(3,3) + I.

Broadcasting and the @. macro

The "dot syntax" is well known in Julia: append any operation with a . to broadcast it, ie to cast it elementwise. For instance, if you have an array X, say X = rand(10), then sin.(X) is equivalent to [sin(x) for x in X], or even to map(sin, X). Note that this dot-syntax is almost customary, since sin(X) will result in a MethodError (there is no method sin(::Vector{...}))). Virtually all operations can be dot-broadcasted: +, -, ^, *, sin, cos, tan, whatever. But sometimes you need to chain many broadcasting operations and this results in clumpy dotted code: 

X = rand(10)
Y = sin.(X.^2) .- cos.(X .* sin.(X .+ 1)).^3

Fortunately, you can distribute the dot itself using the @. macro: 

Y = @. sin(X^2) - cos(X * sin(X+1))^3

Of course, sometimes you want to dot-broadcast some operations and not others in the same expression, and in this case you need to escape some caracters with $ as explained in this post by B. Kaminski, but it results in even uglier code.

Linear solvers

The division of number y by number x is simply y / x. But the division of a vector y by a matrix X is X \ y. By « dividing a vector by a matrix », we simply mean solving the equation Aw = y in w. In general if A is not invertible there is no reason for a solution of Aw=yAw=y to exist, but there is always at least one ww minimizing the square norm Awy2|Aw - y|^2.

In fact, there is no reason for y to be a vector, it can be a matrix; in this case A \ y computes a matrix W such that AW=Y (or the min-norm solution as above).

In either cases, there are closed formulas for w, typically using the pseudo-inverse: w=(AA)1Ayw = (A^\top A)^{-1}A^\top y, which reduces to A1yA^{-1}y when AA is invertible.

This operator is extremely flexible. For example, if you're a statistician and want to perform linear regression, this is your go-to operator: 

x = randn(1000)
y = A * [15] + 0.01*randn(1000) # y = Ax + noise
y \ x # linear regression

Multiple dispatch

Multiple dispatch is when a function has several definitions according to the type of its arguments. Typically,

function f(x::Int)
    x+1
end

function f(x::String)
    string(x, "+1")
end

Here, the function ff is said to have two methods and you can see all the methods of ff and where they are defined with methods(f). You can also add some kind of constraints on the parameters: for instance, if you want a function to take as input two elements of the same type T, whatever it is, then

function f(a::T, b::T) where {T}
    return "whatever"
end

Inlining

Inlining refers to the practice of replacing a function call by its body. Instead of calling the function (which wastes a small amount of overhead), the compiler directly uses the code used to define the function. Under certain circumstances, this increases the speed of your program because 1) you lose the overhead time of the function call and 2) the compiler can further optimize inlined expressions which could not have been optimized otherwise.

Inlining optimization is in general a subtle technique; sometimes it can even fail, for example when you inline huge portions of code. The best practice is to time and benchmark your code to see what works better, see this post by Shuhei Kadowaki.

In Julia, if you want to tell the compiler to inline a function, you can do this using the @inline macro. Apparently, Julia automatically inlines small functions, so you should use this macro for slightly bigger functions, so if you want to forbid Julia to inline them you can use @noinline.

Logging macros

Most people debug their code by gently sprinkling println statements all around their code. In Julia, some very useful macros allow you to do this in a more classy style: they are @info, @warn, @error, @show, @debug. You use them by providing variables or key = values pairs. For example,

julia> x = rand()
0.5541313526244116

julia> @info "This is an info message" x y = x^2
┌ Info: This is an info message
│   x = 0.5541313526244116
└   y = 0.30706155596136

The macros @warn, @error, ... are similar. At first sight, one might think that these macros are just more specific versions of print statements, but in reality they are internally endowed with metadata which interact with the Logging package.

When you call one of these macros, a log event happens. The metadata associated to a log event are: the source module from which the event comes from, the file, the line, an ID and some extra info. Julia's default logger then chooses to display these metadata based on various things. For example, an @info event will display the dictionnary you gave as argument, but not the file or the module, while an @error message will also display the line:

julia> @info "This is an info message"
[ Info: This is an info message

julia> @error "Something just broke"
┌ Error: Something just broke
└ @ Main REPL[4]:1

This example was written in a REPL, hence the line number 1.

This is good and usually sufficient for one's need, but we only tickled the full power of the Logging functionalities in Julia. More can be found on the JuliaLogging excellent webpage.

Saving stuff to files

You did some experiments, got a nice result under the shape of - say - an array, and you want to keep it somewhere for later. You can of course use the good old write function, but at the moment it's way better to use the BSON.jl package. This package encodes nearly everything using the Binary JSON format and is really easy to use thanks to the utility functions @load, @save.

Suppose that you have an array of floats to save, and a string:

arr = rand(10)
phrase = "Vote for Pedro"

Then you can both save them in the file "output.bson" with

using BSON:@save, @load
@save "output.bson" arr, phrase

The file is created or rewritten. Later, when you want to load those variables, you only have to do

using BSON:@load
@load "output.bson" arr, phrase

and a variable phrase is created with the value you stored last time. Indeed, you don't need to load all the variables you saved. If you only need to work on the string phrase you can just do

@load "output.bson" phrase

Keyword arguments

It can be a good programming practice to wrap your function arguments inside an Args structure. For instance, instead of having

function f(size, depth, epsilon, tolerance, number_iterations)
# do stuff using these args

one could simply define a type for the arguments and the using it in the signature of the function: 

mutable Struct Args
    size
    depth
    epsilon
    # etc
end 

function f(args::Args)
# do stuff with args.size, args.depth, etc

A very nice way of doing this is to use the Base.@kwdef macro, which requires you to use the keywords when instanciating the structure. Taken from the doc itself:

julia> Base.@kwdef struct Foo
             a::Int = 1         # specified default
             b::String          # required keyword
         end


julia> Foo(b="hi")
Foo(1, "hi")

Type stability

A function should always return values of the same type.

This great example is taken from the official doc. Suppose you have a function like pos(x) = x < 0 ? 0 : x. You did not specify any types, so Julia needs to infer the types by itself. But here, if x is positive, the output is x (say, a Float32) and if x is a negative float, the output is 0, that is...

julia> x = -3.0
julia> typeof(pos(x))
Int64

The output is an Int. The function pos is not type stable – shame! The solution, here, is pos(x) = x < 0 ? zero(0) : x. In general, if you have an object y and you want to convert it to the type of x, this operation can be performed (if able) by oftype(x,y). There are helper functions, like one(x) which returns a unit of the same type of x.

Coding style

Most of Julia's packages adopt the BlueStyle guideline in addition to the general guidelines. In general, you can check that your files have a correct style by using JuliaFormatter.jl: you just go at the root of your repo and launch format("."). All your files will be formatted. In VSCode, you can simply right-click on your file in the text editor and select Format Document.