The Versatility of Function Declarations in Elixir

March 13, 2019

# ruby
def hello
  puts "Hello world!"

# elixir
def hello do
  IO.puts("Hello world!")

Over the past few months I’ve been lucky enough to use Elixir at work. I wrote some hobby projects in Ruby in the past, so my first surprise was in finding out how differently Elixir behaves despite the syntactic similarities between the two.

The sameness pretty much ends there. Ruby is an interpreted object-oriented language. Elixir on the other hand is a compiled, functional language which runs on the Erlang VM. In Elixir, functions are first-class citizens defined by two properties: name & arity.

Name is self-explanatory, but you may not have come across arity: The arity of a function is the number of arguments it takes.

# hello/0
def hello do
  IO.puts("Hello world!")

# hello/1
def hello(name), do: IO.puts("Hello #{name}!")

hello() # Hello world!
hello("George") # Hello George!

While these both share the same name, for all intents and purposes they are different functions. Defining multiple function clauses like this can get verbose, but we can utilise the alternate def hello(name), do: syntax to declare functions in a single line and keep things terse.

Impressed person

Using multiple function clauses can remove the need for what would typically be branching logic inside a single function definition. The utility in this becomes really evident when you also leverage Elixir’s pattern matching.

Let’s refactor hello/1 to address a few edge cases:

# hello/1
def hello(nil), do: IO.puts("..?")
def hello("Jerry"), do: IO.puts("Hello, Newman.")
def hello("Newman"), do: IO.puts("Hello, Jerry.")
def hello(name), do: IO.puts("Hello #{name}!")

hello(nil) # ..?
hello("George") # Hello George!
hello("Jerry") # Hello, Newman.

With just 4 lines of code we’ve handled a nil case, and satisfied at least one Seinfeld fan. But surely, life is more complex.

How could we make this robust enough to handle a real world scenario?

defmodule PartyGuest do
  @sworn_enemies ["Newman"]
  defguardp is_crowd(guests) when is_list(guests) and length(guests) > 10
  defdelegate float_around, to: PartyHost

  def hello(nil), do: IO.puts("..?")
  def hello(guests) when is_crowd(guests), do: IO.inspect("👋")
  def hello(guests) when is_list(guests), do: Enum.map(guests, &hello(&1))
  def hello(guest) when in @sworn_enemies, do: IO.puts("Hello, ${guest}.")
  def hello(guest), do: IO.puts("Hello #{guest}!")

Now, if we give hello/1 a list of guests, it will greet all of them nicely one by one. Unless there’s a lot of them… in which case a wave will do. Also, we can relax - it’s the PartyHost module’s responsibility to float_around all night. We’ve even added an extensible list of enemies to handle should the situation arise.

So, it only took 10 lines of code to get a pretty accurate simulation of me at a party.


What’s going on here?

  1. @sworn_enemies: This is a module attribute. We can use these to encapsulate some domain specific knowledge under a namespace for readability.
  2. defguard: Here we’re defining a custom guard. Guards give us a way to augment pattern matching with more complex checks. You probably noticed I actually used defguardp: Besides defmodule and defdelegate, all of Elixir’s def variations can be made private to a module by appending the letter ‘p’ to the keyword.
  3. defdelegate: The defdelegate keyword allows us to call on another module’s implementation of a given function. This can help prevent repetition in our code, as well as keep implementation details encapsulated within their appropriate domains.
  4. &hello(&1): This is an anonymous function. The ampersand denotes the function invocation itself, while &N refers to the N-th parameter. We could’ve also used the longform syntax: fn (guest) -> hello(guest) end.

Important to Note:

The order we declare our functions is important. If I was to define the hello(guest) function clause above the rest, none of the others would ever be called. This is because in this variant, guest acts as a catch-all reference that will match on any data type, and the functions are evaluated top-down. You can read more about how pattern matching in Elixir works here.

This kind of thing can be taken even further by things like pattern matching on structs, but we’ll call that out of scope for this article. Hopefully these examples have given you a pretty clear idea of how powerful and expressive Elixir’s function declarations can be.

Tyler Barker

Personal blog by MastodonTyler Barker. I'm a Software Engineer from Australia, currently writing Elixir + JavaScript at Xplor.