The Elixir docs describe protocols as “a mechanism to achieve polymorphism in Elixir.” I find that definition a little vague and generally feel that working through examples is a better way for me to learn a new concept. The canonical examples of protocols are Inspect and Enumerable which are covered well in the docs but I haven’t had much occasion to use. I’ve recently been working on a library that generates SVGs and have found protocols to be a natural fit for converting the data structures representing SVG elements in Elixir into the XML nodes that comprise an SVG.

For example, I represent a line between two points with an %Svg.Line{} struct and need to generate a <line> XML node with x1, y1, x2 and y2 attributes. Initially, I wrote an Svg.render/1 function that used multiple function heads with pattern matching to convert an %Svg.Line{} into a chunk of XML, like this (ignoring boilerplate for starting and ending the XML document) 1:

defmodule Svg
  # ...
  # Data structure declaration and function definitions here
  # ...

  def render(%Svg{elements: elts}), do: Enum.map(elts, &render_element/1)

  defp render_element(%Svg.Line{x1: x1, y1: y1, x2: x2, y2: y2}) do
    ['<line x1="',
      Integer.to_charlist(x1),
      '" y1="',
      Integer.to_charlist(y1),
      '" x2="',
      Integer.to_charlist(x2),
      '" y2="',
      Integer.to_charlist(y2),
      '" style="stroke:rgb(255,0,0);stroke-width:2" />']
  end
end

The various function heads quickly started to overwhelm the Svg module and I extracted them out into a separate Svg.Render module. Although the overall approach worked, it still bothered me that I was defining data structures in individual modules but relying on a single function in a separate module to know how to convert each data structure to its proper SVG representations. To address this inconsistency, I added an Svg.Render protocol with a single render/2 function:

defprotocol Svg.Render do
  @doc "Renders an SVG element to XML"
  def render(element, opts \\ [])
end

To implement this very basic protocol, a module simply needs to define a render/2 function that accepts an element and returns an IO list representation of an XML node. The implementation for rendering a top level Svg structure doesn’t change much 2:

defimpl Svg.Render, for: Svg do
  def render(svg = %Svg{}, _opts) do
    svg |> Svg.elements |> Enum.map(&Svg.Render.render/1)
  end
end

This implementation can now live in lib/svg.ex and the implementation for, e.g., a line can live in lib/svg/line.ex:

defmodule Svg.Line do
  # ... Struct and function definitions here ...
end

defimpl Svg.Render, for: Svg.Line do
  def render(%Svg.Line{x1: x1, y1: y1, x2: x2, y2: y2}, _opts) do
    ['<line x1="',
    Integer.to_charlist(x1),
    '" y1="',
    Integer.to_charlist(y1),
    '" x2="',
    Integer.to_charlist(x2),
    '" y2="',
    Integer.to_charlist(y2),
    '" style="stroke:rgb(255,0,0);stroke-width:2" />']
  end
end

Note that, somewhat suprisingly, the protocol implementation is defined outside of the module definition and the target module is specified via the for: keyword.

Introducting an Svg.Point structure to encapsulate x-y coordinates and an Svg.Style structure to allow for user defined styling shows how we can rely on the implementation of the Svg.Render protocol provided by other modules:

# In lib/svg/line.ex
defimpl Svg.Render, for: Svg.Line do
  def render(%Svg.Line{point1: pt1, point2: pt2, style: style}, _opts) do
    ['<line', Svg.Render.render(pt1, suffix: '1'),
    Svg.Render.render(pt2, suffix: '2'),  Svg.Render.render(style), '/>']
  end
end

# In lib/svg/point.ex
defimpl Svg.Render, for: Svg.Point do
  def render(%Svg.Point{x: x, y: y}, opts) when is_number(x) and is_number(y) do
    suffix = Keyword.get(opts, :suffix, '')
    prefix = Keyword.get(opts, :prefix, '')

    [' ', prefix, 'x', suffix, '=',
     Svg.Render.render(x, quoted: true),
     ' ', prefix, 'y', suffix, '=',
     Svg.Render.render(y, quoted: true),
     ' ']
  end
end

Here we see a little of the “mechanism to achieve polymorphism in Elixir” that the docs describe. The implementation of Svg.Render for Svg.Line doesn’t need to know how to render a point, as it did originally, it only needs to know that it can pass a point to Svg.Render.render and the proper chunk of XML will be returned.

We are not limited to modules that we own when implementing protocols, either (and this is why the implementation defintion happens outside of the module). For instance, the implementation of Svg.Render.render/2 for Svg.Point above relies on rendering an (optionally quoted) Integer:

defimpl Svg.Render, for: Integer do
  def render(integer, quoted: true), do: ['"', Svg.Render.render(integer), '"']
  def render(integer, _opts), do: Integer.to_charlist(integer)
end

I’ve chosen to put the implementations of Svg.Render.render/2 for modules that I don’t own in lib/svg/render.ex alongside the definition of the protocol itself, which is as close to a natural home for those functions as I could think of.

This use of protocols feels slightly atypical, because the sole implementer of the protocol is my own code. The end result is largely the same as the single render_element/1 function using pattern matching in function heads that I started with, only now the various implementations are spread across the codebase. However, the use of protocols makes the library extensible by hypothetical future users. Imagine an application that wishes to draw many hexagons in an SVG. One option is for that application to define an add_hexagon method that would then make the requisite calls to my SVG library to draw connecting lines at the correct locations. Alternatively, the user could define a Hexagon data structure and implement the Svg.Render protocol for their Hexagon module. Using that approach, each Hexagon simply needs to be added to the SVG in the same manner an %Svg.Line{} or %Svg.Circle{} would be and the library would convert it to XML when required.

I’ve been very happy with the use of protocols in my SVG library; they have really helped to separate and encapsulate the rendering of various data structures. And after having worked through this actual use case, the definition of protocols offered by the Elixir documentation makes much more sense to me.

  1. This implementation uses IO lists to avoid repeated string interpolation and concatenation. For more on that topic, read Nathan Long’s “Elixir and IO Lists” blog post and watch the “String Theory” talk by Nathan and James Edward Gray II from Elixir & Phoenix Conf 2016. 

  2. The arity notation in the call to map here tripped me up when re-reading the code: although Svg.Render.render has an arity of 2, Elixir automatically generates a function with arity of 1 because a default argument is specified in the function definition.