Julia Programming

This document contains some non-authoritative notes on programming in the Julia language.

Functions

Julia libraries and applications consists of sequences of function definitions.

Single-Statement Function Assignments

Functions can be defined using single-statement expressions. This style is actually very common. For example, the core of the Mux.jl middleware library consists of the following one-liners:

mux(f) = f
mux(m, f) = x -> m(f, x)
mux(ms...) = foldr(mux, ms)

stack(m) = m
stack(m, n) = (f, x) -> m(mux(n, f), x)
stack(ms...) = foldl(stack, ms)

branch(p, t) = (f, x) -> (p(x) ? t : f)(x)
branch(p, t...) = branch(p, mux(t...))

(Source)

The expression `x -> m(f, x)` is an example of an "anonymous" function (also called a "lambda" or "arrow" function). The -> operator maps input parameters to the output expression.

Mulitple-Statement Function Assignments

More complicated functions can be defined using begin blocks. Such blocks evaluate to the evaluation of the last expression within the block (or to the expression in the first-encountered return statement).

For example, the following function approximates integrals using the familiar trapezoid rule:

trapezoid_rule(fn, a, b, n) = begin
    delta = (b - a) / n
    nodes = (a + delta * k for k in 0:n)
    weights = (delta * (k == 0 || k == n ? 0.5 : 1) for k in 0:n)
    sum(fn(node) * weight for (node, weight) in zip(nodes, weights)) # returned value
end

Functions can also be defined using the function keyword.

function trapezoid_rule(fn, a, b, n)
    # insert statements here
end

Code Generation

When the parameter types are provided, the Julia compiler is able to convert functions to machine code. Passing the machine code the CPU is how Julia code gets executed. In the context of JIT compilation, this process is done immediately before the function is called for the first time.

It is possible to inspec the generated code by using the @code_native and @code_llvm macros in the REPL (your results may be different):

julia> f(x, y) = x + y - x * y
julia> @code_native f(0.2, 0.1)
    .text
    vaddsd  %xmm1, %xmm0, %xmm2
    vmulsd  %xmm1, %xmm0, %xmm0
    vsubsd  %xmm0, %xmm2, %xmm0
    retq
    nopl    (%rax)
julia> @code_llvm f(0.2, 0.1)
define double @julia_f_12047(double, double) {
top:
   %2 = fadd double %0, %1
   %3 = fmul double %0, %1
   %4 = fsub double %2, %3
  ret double %4
}

@code_native returns the assembly code generated by the compiler (in this case, in amd64 assembly). @code_llvm returns the LLVM intermediate representation, which gets passed into the LLVM optimizing compiler to produce the afforementioned assembly.

Note that, in both macros, the submitted function is not actually called. The concrete arguments that are provided are used for type inference. If the arguments were, say, integers, then the produced assembly and LLVM IR will be different.

Concrete Types and Methods

As previously mentioned, the machine code generated by the Julia compiler depends on the function definition(s) and the concrete types of the passed-in arguments. We use the term "concrete" to exclude abstract types. Julia has two types of types: concrete and abstract.

Only concrete types can be instantiated. So only concrete types be "passed into" a function. A function together with the concrete types of the arguments is collectively called a "method". And the Julia compiler can convert methods to machine code.

Julia is a dynamic language in the sense that there are often multiple possible sets of argument types for a given function. For example, in the definition of the function f given above, the arguments x and y could represent integers, floating point numbers, square matrices, polynomials, or any other type where addition and multiplication makes sense.


Created 2020-04-12. Last updated 2020-06-22. View source.