10 things I love about Julia

December 28, 2021

 

Those who know me know that my love for the Julia language has grown quite a bit in the past couple years. Still, I had trouble finding a project that would allow me to work more in this nifty language until I saw Jasmine Hughes solve all of last year Advent of Code (AOC) puzzles in Julia. I know of Jasmine through the AOC RLadies leaderboard! See Jasmine’s valuable advice on “how to get the most out of it” here.

So that’s what I did this year. Locating in the west coast this time, I have the advantage of not having to stay up until midnight or to set the alarm at 4:38 am to be ready when the puzzles come out, which allowed me to end up in the top 3 of the leaderboards I joined ⭐️ ⭐️. So I thought I’d share with you 10 things I love/learned about Julia through AOC. And also some things I find not entirely straightforward.

I hope this post will inspire you to learn Julia. I won’t go in depth into why Julia is awesome For details strong typing, multiple dispatch, environment management and other clever design decisions, please check out this terrific talk by Tamas Nagy. but instead will make some connections to R and thus hopefully eliminate some of the pain points for you if you’re learning the language with an R background.

Disclaimers: I am still very much a novice in Julia and never really had any formal programming training. I did my best to use the right terminology, but please let me know if there is a better explanation/description for something. e.g., I used “array” and “collection” somewhat interchangibly. New tips/tricks/intuition are most welcomed! I’d love to update the post with your suggestions!

Acknowledgements: This post was written in an .Rmd notebook using the wonderful JuliaCall package.

1. Great packages

Julia has a number of great packages despite being a relatively young language, much of which can largely be attributed to the big overlap between its package users and developers. (No two-language problem!)

Here are some packages that I frequently call for AOC puzzles:

using Chain
using Combinatorics: permutations, combinations
using LinearAlgebra: dot, transpose
using Primes: factor
using StatsBase: countmap, rle
using Statistics
using Graphs
using DataFrames, DataFramesMeta

Julia native pipe |> supports simple “chaining” operations of one argument, but I like using the Chain package for more comprehensive support with broadcasting and splatting. For example, for day 15 part 2, I built the larger matrix by

append_input(x) = mod1.(input .+ x, 9)
input2 = @chain append_input begin
    map.([i:i+4 for i = 0:4])
    vcat.(_...)
    hcat(_...)
end

Among the other functions, I used countmap() (≈ R’s table()) in at least seven of these puzzles.

I leave DataFrames and DataFramesMeta here in case you’re comfortable with wrangling dataframes. My experience with their syntax so far has not been as fluid as I’d like, so I actually did not use them for any of this year puzzles. This reminded me that dplyr is so magical!

2. Broadcasting

While R performs “element-wise” operations automatically, such as adding a number to a numeric vector, or even adding numeric vectors of different sizes in Julia, we have to specify that we want to broadcast by adding a . after a function name or before a symbol, such as .+ or .==.

This leads to some cool notations. For example, if x is a 2-D array and we want to add 1 to all element of x:

x .+= 1

If we have two arrays as arguments for a function, we may need to use Ref to “treat the referenced values as a scalar”. For example:

getindex.(Ref([0, 4, 3, 5]), [2, 1, 2])
## 3-element Vector{Int64}:
##  4
##  0
##  4

Alternatively, with a wonkier syntax:

getindex.(([0, 4, 3, 5],), [2, 1, 2])
## 3-element Vector{Int64}:
##  4
##  0
##  4

If you’re curious about nested broadcasting, last time I searched, the best way to do it would be to define a separate function that perform the inner broadcasting, then broadcast it onto the outer iterable, or simply use list comprehension. For example:

a = [[1, 2, 3], [4, 5]]
## 2-element Vector{Vector{Int64}}:
##  [1, 2, 3]
##  [4, 5]
sq_vec(x) = x.^2
## sq_vec (generic function with 1 method)
sq_vec.(a)
## 2-element Vector{Vector{Int64}}:
##  [1, 4, 9]
##  [16, 25]

3. Splat operator

Same as R’s ellipses, Julia’s “splat” operator ... can be used in a function definition to indicate that the function accepts an arbitrary number of arguments. The Splat operator is also equivalent to using Julia’s reduce() function (≈ R’s do.call()). While we’re here, Julia’s map() ≈ R’s apply(). For example, here, max() returns the maximum of the arguments, so if we want to find the maximum of an array, we’ll need to add ... after the array in the function argument, or, for this particular case, use the built-in maximum() function.

max(4, 5, 3)
max([4, 5, 3]...)
reduce(max, [4, 5, 3])
maximum([4, 5, 3])

Quick note: maximum() can also take a function as its first argument.

Similarly, the following commands are equivalent:

*("hello", " ", "world")
*(["hello", " ", "world"]...)
join(["hello", " ", "world"])

4. Unicode support

I love that Julia let me use subscripts and superscripts in my variable names (with \_ and \^), and Unicode math symbols (with, say, \notin<tab>) even as operations: A few folks on Zulip used the octopus emoji 🐙 for AOC 2021 day 11 💥, which made me happy.

4 ∉ [4, 5, 3]
## false

or

8 ÷ 3
## 2

5. Macros

Some of the macros I frequently used are @show, @chain, @time, @assert, and for AOC especially, BenchmarkTools’s @btime.

If you want, you can even define your own macros!

6. Scope

Some developers may not like how Julia deals with variable scope, but I actually enjoy being explicit about where I want my variables defined. In general, it’s best practice to indicate whether a variable name is global or local within your loops. For example:

s = 0
for i = 1:10 
    global s += 1
end

As of Julia 1.5, if we don’t have global here, our code would still run in REPL, but if we try to include a .jl script with these lines, Julia will give a warning and also error out.

See more about scoping here.

7. Find & filter

Julia’s findall() is essentially R’s which() with a slightly longer syntax. And if you want the elements instead of positions, you can use filter().

# In R
x <- c(2, 4, 5) 
which(x > 2) ## 2 3
x[x > 2]     ## 4 5
# In Julia
x = [2, 4, 5]

findall(xᵢ -> xᵢ > 2, x) ## 2 3
findall(>(2), x)         ## 2 3

filter(xᵢ -> xᵢ > 2, x)  ## 4 5
filter(>(2), x)          ## 4 5
x[x .> 2]                ## 4 5 (the R way)

# x[begin:end .∈ Ref(findall(>(2), x))]
# not what I would do unless I have a list of indices I want to include/exclude

One potential gotcha to keep in mind is that, if the condition is not satisfied with any element of the collection, findfirst() returns nothing while findall() returns an empty collection. A related and useful function here is indexin().

findall() can also be used on strings with the argument overlap crucial for some AOC puzzles.

8. Simultaneous assignments

nᵢ, nⱼ = size(input)

or

[f(x, y) for (x, y) in my_tuples]

9. Memoization is easy!

using Memoize
@memoize function my_recursive_func
...
end

10. Other neat things

These are some of the short functions in utils.jl in my julia-aoc-2021 repo:

chr2hex(x) = string(parse(Int, x, base = 16), base = 2, pad = 4)
make_mat(input) = make_int(hcat(collect.(input)...))
make_int(s) = parse.(Int, s)
bin2dec(x) = parse(Int, x, base = 2)
sort_vals(counts) = sort(collect(counts), by = x -> x[2])

Additional, useful functions/notes for AOC:

  • mod1() FTW!
  • pop!(), push!(), popfirst!(), pushfirst!(), filter!(), delete!(), deleteat!()
  • collect() turns object into a collection, ≈ Python’s list().
  • Base.product() ≈ R’s expand_grid()
  • zip() ≈ Python’s zip()
  • Multiple dispatch (≈ R’s S3 and S4 methods)
  • try ... catch; ... end can be slow. Check with if else if known
  • end can be used to index into an array, e.g. x[1:end-1] (≈ R’s head(x, -1))
  • fill(a (x, y, z)) fills an array of dimension x, y, z with a
  • merge() to merge dictionaries
  • vcat(x, y) or [x; y] ≈ R’s c(x, y)
  • findmin(skipmissing(x)) to find minimum while ignoring missing values
  • Integer array is faster than character array is faster than string arrays
  • While most objects in R are immutable, this is not the case in Julia. Be sure to use copy() or deepcopy() when you need to create a copy of, say, a mutable struct.
  • Know your recursion
  • There will always be a Dijkstra problem. Brush up on your implementation of the algorithm or learn to use one of the packages that do. In R, igraph is a great package for this. Julia’s Graphs package has an extensive list of shortest path algorithms.

What’s next

I’d like to keep learning Julia, of course. Most immediately, I’d like to know

  • how to create and use struct
  • CartesianIndices
  • when exactly should I use convert(), Int(), string(), etc.
  • SparseArrays

What about you? What’s your favorite macro? What do you love about Julia? What else do you want to learn? Let me know!

I hope to see you at Advent of Code next year! 🌻


2 9 things I can't tech without | All posts