AoC 2024: A Powerful Elixir
Jan 2, 2025
Each year for Advent of Code I complete the puzzles in a language that I’ve never used before. This year I hastily decided on Elixir, a functional language that runs on the Erlang VM (called BEAM), at the last minute. (I used Zig in 2023, Common Lisp in 2022, Crystal in 2021, and Julia in 2020.)
The short version is that Elixir is awesome. More than any other language I’ve tried for AoC, I found Elixir really meshed with how I wanted to work, so I never had to struggle with the language to solve a problem. I would heartily recommend it to anyone.
It’s worth noting that my experience of Elixir is necessarily a bit limited: I never once launched a new process (Erlang’s term for an isolated block of code running in the same VM), passed a message, or otherwise used any of the Erlang VM’s distrbution or fault-tolerance features. But given that those features are widely regarded as excellent, I’m not worried I’m missing something.
That’s not to say that Elixir is flawless, but for my money it’s pretty close.
Functional Programming
The most obvious constraint on programming in Elixir is that it’s a functional language. All the built-in data types (tuples, lists, maps, sets, etc.) are immutable, functions are first-class values, the language has a bunch of the normal sorts of functional syntax (such a function returning the result of the last expression), and so forth.
This is generally a positive (you don’t have to worry about a function mutating its arguments, etc.), but it does have two main downsides.
First, any algorithm that is incremental ends up copying its data structures a lot, which could cause performance issues for larger problems. Surprisingly, this never really caused any difficulty in my use, which says good things about either how often this comes up in practice or BEAM’s memory allocation subsystem. I expected to run into trouble because of this on some of the maze traversal problems or such things, and it did mean I used DFS in places where I would normally use BFS because it’s much easier to write, but it was mostly a non-issue.
The second downside is that you have to use recursion in a number of places where you’d use loops in an imperative language. I don’t personally mind this, but I think it’s a big hurdle for lot of people. Recursion is one of those concepts in programming that some people just really struggle with, and having to do everything recursively might be a bit much for them.
For example, day 12 part 2 required you to count the number of sides of a complicated rectilinear polygon. My solution found all the boundary squares of the shape and then used those to count up the sides. One function took the set of boundary squares and a single square that was on the side being counted, and then removed that square and all other squares that were part of the same side from the set. In something like Python, doing that is simple:
def remove_all(boundaries, x, y, dir):
up_or_down = (dir == 'up' or dir == 'down')
i = 1
while (x + (i if up_or_down else 0), y + (i if not up_or_down else 0), dir) in boundaries:
boundaries.remove((x + (i if up_or_down else 0), y + (i if not up_or_down else 0), dir))
i += 1
i = 1
while (x - (i if up_or_down else 0), y - (i if not up_or_down else 0), dir) in boundaries:
boundaries.remove((x - (i if up_or_down else 0), y - (i if not up_or_down else 0), dir))
i += 1
In Elixir, it becomes a bit more complicated, because you have to express those
loops recursively, and you copy the boundaries
set many times.
def remove_all(boundaries, x, y, dir) do
if not MapSet.member?(boundaries, {x, y, dir}) do
boundaries
else
boundaries
|> MapSet.delete({x, y, dir})
|> remove_all(
if(dir == :up or dir == :down, do: x + 1, else: x),
if(dir == :left or dir == :right, do: y + 1, else: y),
dir
)
|> remove_all(
if(dir == :up or dir == :down, do: x - 1, else: x),
if(dir == :left or dir == :right, do: y - 1, else: y),
dir
)
end
end
Not really a huge deal, but for people that find recursion really difficult to manage, Elixir would be a poor choice.
One thing I like about Elixir (and Common Lisp as well) is that it readily
admits that some code is better written in a sequential style, and as part of
that, that side effects are a fact of life. Functions (and anything
function-like, such as case
branches) can have multiple expressions and
they’re executed in order, and functions with side effects are just regular
functions. This is the main thing I dislike about Haskell — that it treats both
of these things as a weird, special-case that need to be enclosed inside monads
to keep them from infecting the rest of the code — and so I’m pleased that
Elixir goes the more practical route.
The Pipe Operator
Starting on day 1, I fell in love with Elixir’s pipe operator |>
and never
looked back. This operator takes the left side and supplies it as the first
argument to the function call on the right side, sort of like a Unix pipe.
In a functional language, this is a wonderful tool. It’s pure syntactic sugar, but it eliminates the problem that a language like Common Lisp has of deeply nested function calls becoming unruly, making it hard to see what’s an argument to what. Compare, for example,
IO.puts(
Enum.sum(
Enum.map(
IO.stream(),
fn l ->
Enum.sum(
Enum.map(
Regex.scan(~r"mul[(](\d{1,3}),(\d{1,3})[)]", l, capture: :all_but_first),
fn [a, b] -> String.to_integer(a) * String.to_integer(b) end
)
)
end
)
)
)
to
IO.stream()
|> Enum.map(fn l ->
Regex.scan(~r"mul[(](\d{1,3}),(\d{1,3})[)]", l, capture: :all_but_first)
|> Enum.map(fn [a, b] -> String.to_integer(a) * String.to_integer(b) end)
|> Enum.sum()
end)
|> Enum.sum()
|> IO.puts()
The latter is so much easier to both read and write, and it clearly demonstrates the operations in the order that you really mean for them to happen.
JavaScript lets you write clear code like this, but that’s because it has
functions like map
and filter
defined on Array
rather than being
freestanding. That lets you write list.map(...).filter(...)
, but as a
tradeoff, you can’t use map
and filter
on arbitrary data structures.
No such tradeoff exists with the pipe operator. Its operation is clear and simple, it’s applicable to everything, and it means you can easily write universal functions that are convenient to call this way. Speaking of…
The Standard Library
Elixir’s standard library forms a cohesive whole in a way that few languages manage to achieve.
Partly, this is just because the language is comparatively young. Python, for instance, has three different command line argument parsing modules in the standard library, but Python has been around for 30 years. But Elixir’s also seems to be thought through to an unusual extent.
I don’t know to what extent this is Elixir’s doing and to what extent they’re riding Erlang’s coattails. You can access anything in the Erlang standard library from Elixir (even modules that have no Elixir definition), and many Elixir modules are clearly ports or adaptations of parts of the Erlang stdlib. But as they say, the proof of the pudding is in the eating, and the Elixir standard library tastes delicious.
First, the names are clear and consistent without being long-winded. There are
the usual suspects like Enum.map
or Enum.filter
, but also
Map.merge
,
Enum.chunk_by
, and
String.graphemes
. As
well, they’re consistent. As an example, constant-time functions to measure the
number of something are called size
, variable-time ones are called count
or
length
(so String.length
counts the Unicode characters in a string, but
Kernel.byte_size
gives
the number of bytes). And the same name across different modules will take the
same arguments in the same order; functions that don’t have different names
(like String.chunk
and
Enum.chunk_by
).
More importantly, the modules are full without being overflowing. This is
clearly subjective and different languages take different approaches, but Elixir
hits the sweet spot for me. Every time I needed a function, it was there,
usually (though not always) with the simple name I expected. But when I look at
the contents of something like the Enum
module, it’s not overwhelming. It’s
easy to get an idea of what you might do and the library’s patterns.
Some examples of functions I would probably have had to write in another
language, but are present in Elixir:
Integer.gcd
,
Map.merge
,
Enum.frequencies
, and
Enum.reduce_while
.
And the individual functions are well-designed. Every single function that takes
a container or other data structure takes it as the first argument, of course,
to make them compatible with the pipe operator. Default arguments are used
judiciously to allow simple cases to be simple while still allowing complex
cases; for instance, Enum.max
has
a default argument not just for what to use as the comparison function, but also
for what it should do when given an empty list. And they take advantage of the
language features to good effect, like
Enum.chunk_while
taking a
function that returns tuples with specific atoms to control the chunking.
Finally, the documentation is very high quality. Much like the standard library itself, it’s thorough without being longwinded or hard to read.
Pattern Matching
I’ll be honest: I don’t get the love for pattern matching. It’s useful, I use it, and it’s well-supported in the language. It’s just not something that sets my heart on fire.
That said, Elixir’s is fully featured and has one feature that was new to me: the ability to both destructure a value and retain a reference to the structured value. If you want to check some coordinates but also pass the original coordinate tuple to another function, normally you have to do either destructure it and then rebuild the tuple to pass to the second function (which is wasteful) or destructure the tuple separately from everything else (which is noisy). But in Elixir, I could write:
def shortest_path({sx, sy} = s, {tx, ty} = t, {hx, hy}) do
if (sx == hx or tx == hx) and (sy == hy or ty == hy) do
shortest_path_right_first(s, t)
else
shortest_path_left_first(s, t)
end
end
The {sx, sy} = s
syntax destructures the first argument but also gives you the
original as s
, which is exactly what I want. It’s a little thing, but it
makes it a bit more pleasant.
Syntax
Elixir’s syntax is mostly straightforward. I found it a little annoying to keep
straight what constructs use ->
and which ones use do
(eg, anonymous
functions use ->
but named functions use do
) and which ones are
newline-terminated and which ones require end
, but only a little.
Comprehensions are nice enough but clumsy syntactically, so I rarely used them.
I don’t particularly like the choice to have the result of the last expression be the return value from a function, but that’s a functional programming convention I know I have to live with.
Also, a minor quibble, but despite writing 49 programs in the language, I don’t actually know what the rules for parsing expressions are. Expressions are generally newline-terminated, but you can also split long expressions across different lines, and I don’t know what the legal ways to do that are. The rules must be sensible, because I never ran into trouble, but nothing I read at any point even touched on them.
Conclusion
Learning Elixir was a delight. With the one caveat that you need to be comfortable with functional programming (and particularly recursion), I’d recommend it for anyone. I hope I’ll have a good reason to use it again in the future.