Julia for Python (and C++) programmers#

Why Julia?#

Julia is a relatively young (2012) programming language designed to be particularly effective for scientific workflows - the developers specifically call out Fortran and MATLAB as predecessors in this area.

Compared to these, Julia has much of the dynamic and interactive expressiveness of languages such as Python (including functionality that is only provided there by the third-party NumPy library), whilst leveraging just-in-time compilation and specialisation to allow performance approaching (and sometimes better than) high-performance compiled languages such as C, C++, Modern Fortran and Rust.

Some of Julia’s properties#

  • High-performance (usually within a factor of 0.5 of C; often at parity)

  • Fully Unicode supporting - including LaTeX markup support in REPL, and natural mathematical symbols for common operations (\(\sin^2\theta + cos^2\theta = 1\))

  • First-class support in Jupyter notebooks (it’s the Ju bit of the name)

  • Supports generic programming (with multiple dispatch), functional-style method chaining, and other modern paradigms

  • Dynamic typing, with efficient function specialisation (via JIT) and optional typing supported deeply in language (unlike Python)

  • Has an excellent, robust package manager making installation and reproducible environments a breeze

Julia is also just a really fun language to program in!

Example Julia Code#

function myfunc(x, y)
    if x > y
        "X is greater than Y\n"
    else
        "Y is greater than X\n"
    end
end

print(myfunc(2,3))

print(myfunc(2.0, 1))
Y is greater than X
X is greater than Y

As you can see, Julia’s syntax is somewhere between that of Python and MATLAB in style and it’s easy to follow.

One subtle thing we’re using in this example is that all expressions return a value - the “value” of the if chain is the string that is created as the only operation in that chain… and thus the “value” of the function, without an explicit return statement, is simply the value of the last expression that was executed before the function ended.

We could write this more explicitly, but less naturally, as:

function verbosefunc(x, y)
    if x > y 
        return "X is greater than Y\n"
    else 
        return "Y is greater than X\n"
    end
end
verbosefunc (generic function with 1 method)

Of course explicit return statements are necessary in some places for flow control, but it’s nice that often in Julia we can rely on implicit return value.

As we mentioned, Julia has a JIT which applies to all code written in it - the first time a unit of code is executed, the JIT will compile direct to host machine code.

We can see this better with a longer function that requires actual effort (thanks to @Moelf), using the @time macro to get the execution time:

function go_faster(a)
    trace = 0.0
    for i in axes(a, 1) # Returns the dimension of array "a" along its first axis
        trace += tanh(a[i, i])
    end
    return a .+ trace
end
go_faster (generic function with 1 method)
α = reshape(0:99, 10, 10)  #Julia will tab-complete LaTeX markup - type \alpha and press tab to write this variable name

@time go_faster(α)
  0.102960 seconds (269.89 k allocations: 18.393 MiB, 7.56% gc time, 99.98% compilation time)
10×10 Matrix{Float64}:
  9.0  19.0  29.0  39.0  49.0  59.0  69.0  79.0  89.0   99.0
 10.0  20.0  30.0  40.0  50.0  60.0  70.0  80.0  90.0  100.0
 11.0  21.0  31.0  41.0  51.0  61.0  71.0  81.0  91.0  101.0
 12.0  22.0  32.0  42.0  52.0  62.0  72.0  82.0  92.0  102.0
 13.0  23.0  33.0  43.0  53.0  63.0  73.0  83.0  93.0  103.0
 14.0  24.0  34.0  44.0  54.0  64.0  74.0  84.0  94.0  104.0
 15.0  25.0  35.0  45.0  55.0  65.0  75.0  85.0  95.0  105.0
 16.0  26.0  36.0  46.0  56.0  66.0  76.0  86.0  96.0  106.0
 17.0  27.0  37.0  47.0  57.0  67.0  77.0  87.0  97.0  107.0
 18.0  28.0  38.0  48.0  58.0  68.0  78.0  88.0  98.0  108.0

As we can see, over 99.9% of the time taken was by the JIT. However, as Julia caches the result, subsequent executions are much faster, using the previously compiled code:

β = reshape(1:100, 10, 10)  #similarly, \beta and then tab

@time go_faster(β)
  0.000008 seconds (1 allocation: 896 bytes)
10×10 Matrix{Float64}:
 10.7616  20.7616  30.7616  40.7616  …  70.7616  80.7616  90.7616  100.762
 11.7616  21.7616  31.7616  41.7616     71.7616  81.7616  91.7616  101.762
 12.7616  22.7616  32.7616  42.7616     72.7616  82.7616  92.7616  102.762
 13.7616  23.7616  33.7616  43.7616     73.7616  83.7616  93.7616  103.762
 14.7616  24.7616  34.7616  44.7616     74.7616  84.7616  94.7616  104.762
 15.7616  25.7616  35.7616  45.7616  …  75.7616  85.7616  95.7616  105.762
 16.7616  26.7616  36.7616  46.7616     76.7616  86.7616  96.7616  106.762
 17.7616  27.7616  37.7616  47.7616     77.7616  87.7616  97.7616  107.762
 18.7616  28.7616  38.7616  48.7616     78.7616  88.7616  98.7616  108.762
 19.7616  29.7616  39.7616  49.7616     79.7616  89.7616  99.7616  109.762

It’s possible to achieve similar effects using the various “JIT in Python” packages like numba, but those are additional add-ons to Python itself, and often require writing code in outwardly “unPythonic” ways.