Anti-Pattern Multi-Clause Functions

In Elixir, we can use multi-clause functions to group functions together using the same name. One possible advantage of doing this is minimising the code surface area of our modules. It’s also supposed to keep our functions more focused and organised when done correctly.

Here’s a quick example:

def calculate(number) when is_binary(number) do
  # possibly parse to integer/float
end

def calculate(int) when is_integer(int) do
  # ...
end

def calculate(_), do: raise "Not a number"

In the code above, the function can be called with a single name, i.e. calculate(x), without having to expose what calculate/1 actually does with the value x in the background. This seems like a straightforward use-case, but we can further expand its usage by using pattern-matching and guard clauses, for example:

def get(%Product{} = product) do
  # ...
end

def get(%Animal{} = animal) do
  # ...
end

def get(map) when is_map(map) do
  # ...
end

def get(any) do
  # ...
end

When we start adding and mixing more pattern matchings and guard clauses, the functions start becoming less and less readable, as can be demonstrated below:

def update(%Product{count: nil, material: material})
    when material in ["metal", "glass"] do
  # ...
end

def update(%Product{count: count, material: material})
    when count > 0 and material in ["metal", "glass"] do
  # ...
end

def update(%Animal{count: 1, skin: skin})
    when skin in ["fur", "hairy"] do
  # ...
end

These are probably not the worst examples out there, but the important observation here is that sometimes when we’re trying to squeeze too many business logics into the function definitions, the code will quickly become unreadable and even harder to reason with.

One workaround that we might find some developers do to overcome this is by adding inline documentation to their functions, for example:

# update sharp product with 0 or empty count
def update(%Product{count: nil, material: material})
    when material in ["metal", "glass"] do
  # ...
end

# update blunt product
def update ...

This is not wrong and is a perfectly valid way to document our codes. However, we’ll be missing out the opportunity to use Elixir’s recommended practice for documentation which is to use the @doc annotation. Unfortunately, with multi-clause function we can only use this annotation once per function name, particularly on the first or header function.

Why is this anti-pattern?

This is rather subjective for some people but since I have suggested these codes to be anti-pattern, I am obligated to share my personal opinions on why I thought they are.

  1. Function declarations should be as descriptive as possible including the name, arguments and any additional clauses used to enhance its functionality.

  2. Elixir treats documentation as a first class citizen. Documentation should be easy to write and read - see: Writing Documentation. Being able to figure out what a function does on a single @doc annotation is easy while having to track down multiple comments all over the places is not.

So how might we approach this differently?

Elixir thrives on simplicity. Whenever we find ourselves trying to expose a complex business logic in a function, it is time to ask: is this the simplest way to write this, or can I break it down into smaller functions?

Consider the following composition:

@doc """
Update sharp product

Add additional notes here...
"""
@spec update_sharp_product(Product.t()) :: {:ok, atom()} | {:error, atom()}
def update_sharp_product(product) do
  case product.count do
    0 ->
      # ...
    _ ->
      # ...
  end
end

The first thing we should notice is that the intention of the function is immediately apparent in the function name itself. We also have the opportunity to properly document the function, along with its detailed specifications. Sure, this is not sexy and seemingly overcommunicating - but that’s really the point!

Coming back to our previous example, we still want the benefits of using multi-clause functions. Let’s try to use the same approach above by keeping things simple and well documented. As we’re about to find out, this time we will be dealing with a lot less complexities than we did previously.

@type product_or_animal :: Product.t() | Animal.t()

@doc """
Update a Product or Animal

...
"""
@spec update(product_or_animal) :: {:ok, atom()} | {:error, atom()}
def update(product_or_animal)

def update(%Product{material: material} = product) do
  cond do
    material in ["razor", "glass"] -> update_sharp_product(product)
    material in ["stick", "rubber"] -> update_blunt_product(product)
    true -> update_generic_product(product)
  end
end

def update(%Animal{skin: skin} = animal) do
  cond do
    skin in ["fur", "hairy"] -> update_furry_animal(animal)
    skin in ["wet", "moist"] -> update_slimy_animal(animal)
    true -> update_generic_animal(animal)
  end
end

Let’s analyse some of the improvements we attempted to do here:

  1. In the header function, we’re communicating early & clearly that the function only accepts either a Product or Animal struct.
  2. For each of the function, we are mainly interested in a specific field’s value in order to decide what to do next, utilising basic control flow like cond.
  3. Each met condition will be dealt separately by highly focused, named function that can be properly documented, annotated and unit tested.

Conclusion

Multi-clause function in Elixir is a powerful design pattern to keep our code surface area small, readable and organized. However, due to its reasonably flexible nature it can be easily abused by adding too many pattern matchings and guard clauses. As a general rule, all functions in elixir must possess a certain degree of documentation and specification using proper annotations, while keeping them focused and small enough to be easy to reason with. Adhering to these principals will ensure that we get the full benefits of using multi-clause functions.