Refactoring Patterns in Elixir: Replace Conditional with Polymorphism Via Protocols Part 2

Part 2: Replace Conditionals with Polymorphism Via Protocols

Use Elixir protocols to bring polymorphism to your data structures

This is the second in a series of posts we are doing on refactoring patterns in Elixir, a series that stemmed from working through Martin Fowler’s book Refactoring. In the first part of this series, we looked at using pattern matching in function heads as a tool to use for refactoring complex conditionals.

Pattern matching is ubiquitous in Elixir and Erlang, and it is a great tool to use for cleaning up complex conditionals in most common cases. To recap what we did in the last post, let’s take a look at this code sample ported over to Elixir from the JavaScript example in Martin Fowler’s book, and then see how we were able to refactor it using pattern matching:

The Bird module before refactoring:

defmodule Bird do


  defstruct type: nil, number_of_coconuts: 0, voltage: 0


  def plumage(bird) do
    case bird.type do
      "EuropeanSwallow" ->
        "average"
      "AfricanSwallow" ->
        if bird.number_of_coconuts > 2 do
          "tired"
        else
          "average"
        end
      "NorwegianParrot" ->
        if bird.voltage > 100 do
          "scorched"
        else
          "beautiful"
        end
      _ ->
        "average"
    end
  end
end

The Bird module after refactoring:

defmodule Bird do
  defstruct type: nil, number_of_coconuts: 0, voltage: 0


  def plumage(%__MODULE__{type: "AfricanSwallow", number_of_coconuts: num}) when num > 2, do: "tired"


  def plumage(%__MODULE__{type: "NorwegianParrot", voltage: voltage}) when voltage > 100, do: "scorched"


  def plumage(%__MODULE__{type: "NorwegianParrot"}), do: "beautiful"


  def plumage(%__MODULE__{}), do: "average"
end

Just looking at the before and after versions of the above code snippet, we can see that pattern matching in function heads gives us a huge benefit and an easy win in the readability department. Our code is now easier to understand and reason about: with only four cases for the plumage/1 function, we can easily find and determine what will be returned from the function for a given Bird.

We did, however, note one caveat to the refactoring tool used above: assuming we had a larger variety of Birds to handle, with each variety requiring some unique logic to determine its plumage, we would naturally arrive at a situation where the shear amount of function heads that we would need to create would become overwhelming and lead to harder and harder to follow logic. Not only that, but since calls to these functions would fall through from top to bottom in the order they are declared, we would also have to think long and hard about what order to place the function heads in to arrive at the intended behavior. Eventually, we would reach a breaking point where maintaining this function would start to become intractable.

One solution for dealing this is actually quite simple: if we are dealing with a plethora of different kinds of Bird that all have different conditions that determine what their plumage is, then perhaps we should break away from the generic idea of a Bird and create data structures that are more specific and tailored to the kind of bird(s) we are actually working with. For example, given the code snippet above, maybe we want to have EuropeanSwallow, AfricanSwallow, and NorwegianParrot represented as data structures instead of just a Bird (or in addition to a Bird that would represent a general case).

Elixir actually gives us an elegant way of handling this kind of problem – protocols which you can read more about here. Essentially, a protocol allows us to declare one function and delegate out the implementation of that based on the type of the data passed in to the function. Keeping with our Bird example, we might have a protocol Bird.plumage/1 and delegate out specialized implementations of the function based on our EuropeanSwallow, AfricanSwallow, and NorwegianParrot data structures. Note that we can also have a fallback implementation for protocols so that we can handle generalized cases if that is what we need. Let’s take a look at what our protocol definition looks like:

defprotocol Bird do
  @fallback_to_any true
  def plumage(bird)
end

There are a couple things of note here. First, defining a protocol is more or less the same as defining a module (and, in fact, this will generate a module for us), and second, we define only function heads inside of the protocol without providing an implementation. The @fallback_to_any true declaration tells Elixir to reference an implementation of the function it marks for the Any data structure. We will look at an example of that below, but for now just note that you can use this to provide a generalized fallback implementation.

So once we have a protocol set up, how do we define our own custom implementations of the function(s) declared in the protocol? Let’s take a look at what our implementation would look like for the NorwegianParrot, and while we are at it, we will also create a module for NorwegianParrot and define a struct for it:

defmodule NorweiganParrot do
  defstruct voltage: 0


  defimpl Bird, for: NorweiganParrot do
    def plumage(%NorweiganParrot{voltage: voltage}) when voltage > 100,
      do: "scorched"


    def plumage(_), do: "beautiful"
  end
end

And for the EuropeanSwallow and AfricanSwallow, we would have something like:

defmodule EuropeanSwallow do
  defstruct number_of_coconuts: 0


  defimpl Bird, for: EuropeanSwallow do
    def plumage(%EuropeanSwallow{}), do: "average"
  end
end

and

defmodule AfricanSwallow do
  defstruct number_of_coconuts: 0


  defimpl Bird, for: AfricanSwallow do
    def plumage(%AfricanSwallow{number_of_coconuts: num}) when num > 2,
      do: "tired"


    def plumage(_), do: "average"
  end

We are defining the specialized implementation for the three types of bird we are concerned with here. To do this, we use defimpl #{ProtocolName}, for: #{MyDataType} to let Elixir know that this is the implementation of the Bird protocol for the type we are passing as the value to for:. Just like in normal Elixir modules, we can use pattern matching and guard clauses when declaring functions, and we can also have multiple function heads for the same declaration. This is all just good ole classic Elixir.

The great thing about this is it gives us a much more focused, granular level of control over the way a function behaves based on the type of data it receives. We no longer have to worry about how a NorwegianParrot‘s plumage is calculated when we are working on our implementation of the AfricanSwallow.

As I mentioned above, we can also define a fallback implementation that will catch any parameters that are passed to Bird.plumage/1 that don’t match the three specialized implementations we worked out above. You can do that by defining an implementation of the protocol for the Any data type like this:

defimpl Bird, for: Any do
  def plumage(_), do: "average"
end

With all of this in place, we can replace the initial code snippet from above with our protocol, giving us polymorphism on the parameters that get passed to Bird.plumage instead of relying on pattern matching in a single module and we can avoid potentially having to handle a large number of different cases in the same file. We now have more control over the behavior of the function and can write tests that can prove out different edge cases around the specialized implementations that we care about.

To finish this out, let’s look at what it might look like to call this code:

1> Bird.plumage(%AfricanSwallow{number_of_coconuts: 7}) ### "tired"
2> Bird.plumage("some general bird") ### "average"
3> Bird.plumage(%NorwegianParrot{voltage: 7000}) ### "scorched"

That wraps up our second installment in our Refactoring Elixir Series. I hope you enjoyed, and happy Elixiring to you!

Subscribe to the Gaslight Newsletter

Like what you see? Subscribe to Gaslight’s monthly email newsletter for coding tips, tech insights, events, news and more delivered right to your inbox from the Gaslight Team!

Subscribe Now