Overview
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):
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.
Categories: #Elixir Tags: #Elixir Protocols #Polymorphism #Enumerable #SVG Render #XML Parsing