flooey.org

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.