Kicking the Tires with Elixir, Part 1 - Pipes
Lately I've been playing with Elixir, a functional language that sits over Erlang and the OTP framework. It's exciting because it handles embarrassingly scalable problems with aplomb, enabling a high level of parallelism and concurrency with a great developer experience. While I think Rust shines at the system programming level, Elixir seems like a perfect candidate for web services - balancing power with ergonomics.
There are plenty of well-written posts about the language and its associated libraries. I wanted to touch on what stood out to me as a Pythonista, web developer, and nerd. I don't know how many posts will comprise the series, but I have at least a few topics in mind.
First up: pipes.
Pipe ALL the Things
Look at any Elixir code and one of the first things you'll stumble upon is
|>
- the pipe operator. It's syntactic sugar for passing the result of the
previous statement in the pipeline into the subsequent partial as the
first parameter. This feels similar to the convention in Kotlin where
functions passed in at the last position can be written outside of the
parentheses, facilitating a DSL-like expression.
Why is this good? It incentivizes you to group your code into logical blocks and remove temporary variables. Here's a contrived example where I have a function that accepts a collection of items, applies some transformation, filtering, and validation, and then processes the end result.
def add_widgets(widgets) do trimmed = trim_widget_names(widgets) # remove extraneous whitespace deduplicated = Enum.uniq_by(trimmed, fn %{name: val} -> val end) validated = Enum.filter(trimmed, fn %{name: val} -> String.length(val) > 4 end) insert(validated) end
Look at all those unnecessary variables! Did you catch the bug? On line 4, I
accidentally referenced the wrong variable: it should be deduplicated
. This
sort of error is super-sneaky. Since we're composing these functions, we can
rephrase this as:
def add_widgets(widgets) do insert(Enum.filter(Enum.uniq_by(trim_widget_names(widgets), fn %{name: val} -> val end), fn %{name: val} -> String.length(val) > 4 end)) end
Hard to read and refactor, right? Which args go with which function? Maybe some indentation can help.
def add_widgets(widgets) do insert( Enum.filter( Enum.uniq_by( trim_widget_names( widgets ), fn %{name: val} -> val end ), fn %{name: val} -> String.length(val) > 4 end ) ) end
A little more readable, but it's still hard to update - I'd need to parse levels of indentation, and inserting a function in the middle results in re-jiggering everything. This also triggers some repressed memories of callback hell from the days of JS pre-promises. If only there was a syntactic solution that addressed these concerns...
Oh, wait, there is.
def add_widgets(widgets) do widgets |> trim_widget_names() |> Enum.uniq_by(fn %{name: val} -> val end) |> Enum.filter(fn %{name: val} -> String.length(val) > 4 end) |> insert() end
This is not only readable, but easier to reason about and refactor! But, just
how sugary is this syntactic sugar? Surprisingly, not very. The |>
operator
is a macro,
which maps to Erlang's foldl/3
(that's how you reference functions in
Erlang/Elixir world - the left side of the slash is the function name, and the
right side is the "arity" - the number of arguments the function accepts).
foldl/3
accepts a list (which is what the pipe macro creates out of its left
and right hand sides), and invokes them from first to last, passing the
returned value of one in the next.
This is my favorite sort of nicety - no magic, just readability. There are some gotchas around parentheses and anonymous functions, but those are more a result of syntactic ambiguities.
More on a those in a later post. Happy hacking!