Don’t Do This: Object Oriented Inheritance in Elixir with Macros
Elixir's macro system lets developers do incredible things — including some you probably shouldn’t
A recent post in the Elixir forum of Reddit asked how Elixir accomplishes code reuse without inheritance. Coming from an object-oriented background, the poster was used to solving this problem by inheriting behaviors with classes, but wasn’t sure how to approach it in a functional language like Elixir.
The general answer is that Elixir favors a composition approach, constructing complex behaviors by importing functionality from other modules. But the post got me thinking: even though it might be a terrible thing to do, is it possible to implement classic object-oriented style inheritance in Elixir?
It turns out, yes: Elixir’s macro system is powerful enough to implement object-oriented style inheritance with modules. In this post, we’ll learn about the Elixir macro system and how to (ab)use it to implemenent inheritence. We’ll also talk a bit about why it’s probably not a good idea to do so.
Macros & Metaprogramming
Metaprogramming, broadly speaking, is the ability to manipulate code as data. This means that a programming language can read, modify and generate its own code, and it’s just as powerful and dangerous as it sounds.
Elixir enables metaprogramming via its macro system. The macro system lets us write and execute code that generates code, and this opens the door to all kinds of language extensions and mechanisms for code reuse. Let’s see a simple example, where we have some “mixin” functions we want to inject into a module:
defmodule Mixin do
defmacro __using__(_) do
quote do
def mixin_func1(value), do: IO.inspect(value)
def mixin_func2(value), do: IO.inspect(value * 2)
end
end
end
# add the "use" macro to inject functions into MyModule
defmodule MyModule do
use Mixin
end
MyModule.mixin_func2(5)
> 10
Instead of simply executing code, macros can return code that can be executed by the caller–including code that is generated dynamically. This is done using a quote do ... end
block to quote the code and return its data representation rather than executing it. In this simple use case, the __using__
macro lets us effectively inject our function definition code into MyModule
with use Mixin
.
For a more real-world example, consider this piece of code generated in a default project in the Phoenix Framework (with some modifications). In this example, controller modules like MyAppWeb.UserController
need to have access to certain functionality. Instead of subclassing some base controller class, we use a macro to inject the desired code:
# our project's macros
defmodule MyAppWeb do
def controller do
quote do
use Phoenix.Controller, namespace: MyAppWeb
import Phoenix.LiveView.Controller
import Plug.Conn
import MyAppWeb.Gettext
alias MyAppWeb.Router.Helpers, as: Routes
end
end
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end
# our controller implementation
defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
end
From a code reuse standpoint, metaprogramming lets us accomplish the same sorts of things we do with inheritance — as well as other forms of code reuse that don’t neatly fit the inheritance model. Note that this example not only imports some functionality, it also calls another use.
But macros go far beyond simple code reuse: in addition to injecting code with use, macros can be used to construct control structures or domain specific language APIs (DSLs) . Here’s an API example from the popular Ecto database mapper library:
import Ecto.Query, only: [from: 2]
query = from u in "users",
where: u.age > 18,
select: u.name
Repo.all(query)
Ecto uses a macro definition for from
, instead of a function, so that API users can write expressions like u.age > 18
which can be transformed into database queries rather than being evaluated directly. In a function call, the expression is evaluated as usual and the function receives the value. In a macro, the expression is provided as code and can be read, manipulated or executed as the macro author chooses. In this case, the expression is transformed into a SQL database query fragment rather than being executed in Elixir.
To dig deeper on this, let’s take a look at an example from the Elixir documentation. In the following example, a macro is used to generate a new control structure called Unless
. The example shows one implementation using a function and one using a macro, to demonstrate the difference:
defmodule Unless do
def fun_unless(clause, do: expression) do
if(!clause, do: expression)
end
defmacro macro_unless(clause, do: expression) do
quote do
if(!unquote(clause), do: unquote(expression))
end
end
end
In the function example, the expression
is evaluated immediately as part of the normal program execution. But by using a macro, the expression is injected into a control structure and only executed when the clause
is false–which is the behavior we expect from a flow control structure!
iex> require Unless
iex> Unless.macro_unless true, do: IO.puts "this should never be printed"
nil
iex> Unless.fun_unless true, do: IO.puts "this should never be printed"
"this should never be printed"
nil
The Unless
macro uses a quote do … end
block like our previous example, but also uses unquote
to inject the passed in expressions into the quoted code block.
While metaprogramming is extremely powerful, it can be “dangerous” in the sense that it can modify expected code behaviors and make code extremely difficult to understand if used incorrectly. As the Elixir documentation notes:
Macros should only be used as a last resort. Remember that explicit is better than implicit. Clear code is better than concise code
Writing macros is somewhat uncommon for day-to-day Elixir development, with the exception of some common and well-established use cases like the __using__
example above.
Building a Macro for Object-Oriented Style Inheritance
Now that we’ve seen how macros can be used in Elixir to extend module behavior, let’s go back to our original question: can we use Elixir macros to implement OO-style inheritance? We can, but it will require a much more complicated macro!
First, let’s state the goals of our inheritance macro:
- We want to implement an “Inherit” macro which will take a parent module, and inject all of the functions from the parent module into the current module.
- It should work with multilevel inheritance so that if C inherits from B, and B inherits from A, then C should be able to call A functions as well.
- Like most OO languages, functions should be overridable: we should allow inherited functions to be overridden and reimplemented
- Finally we should support multiple inheritance: the ability to inherit from separate base classes and get functions from both.
If we implement our inherit macro correctly, we should be able to write code like the following:
defmodule Base do
def f1(a), do: a * 1
def f2(a), do: a * 2
end
defmodule Base2 do
def f3(a), do: a * 3
end
defmodule Derived do
use Inherit, Base
end
defmodule MyModule do
use Inherit, Derived
use Inherit, Base2
def f2(a), do: a * 10
end
# Call an inherited function
MyModule.f1(a) |> IO.inspect()
> 5
# Call an overridden function
MyModule.f2(a) |> IO.inspect()
> 50
# Call a function inherited with multiple-inheritance
MyModule.f3(5) |> IO.inspect()
> 15
After some trial and error to solve a couple of tricky problems described below, I was able to come up with a macro which passed all the tests:
defmodule Inherit do
defmacro __using__(quoted_module) do
module = Macro.expand(quoted_module, __ENV__)
module.__info__(:functions) |> Enum.map(fn {name, arity} ->
# Generate an argument list of length `arity`
args = arity== 0 && [] || 1..arity |> Enum.map(&Macro.var(:"arg#{&1}", nil))
quote do
defdelegate unquote(name)(unquote_splicing(args)), to: unquote(module)
defoverridable [{unquote(name), unquote(arity)}]
end
end)
end
end
What we’re doing in this macro is
- implement a macro which takes in a base module to inherit from with
defmacro
— note that we need to get the unquoted module with Macro.expand - iterate over all of the functions the module exposes using
module.__info__(:functions)
- use
defdelegate
to define a function in which points at the base implementation — this was the trickiest part and is described in more detail below. - use
defoverridable
to allow the function to be override in the derived class if desired
As mentioned above, the tricky part of this macro is constructing the defdelegate
call, specifically generating a delegate definition with the correct number of arguments. For example, if we need to define a method do_stuff
with 3 arugments, we need to generate code that looks like this:
defdelegate do_stuff(a1, a2, a3), to: unquote(module)
It’s simple enough to can construct a list of argument variables, but how do we put it into the function definition? As it turns out, a simple unquote
won’t solve our problem here — that would essentially be a function definition with a single list argument. Instead, we use a function called unquote_splicing
: it unquotes a list, expanding the elements in place. Let’s take a quick look at a simple unquote
vs. unquote_splicing
example:
iex(6)> quote do [1, unquote(list), 2] end
[1, [2, 3], 2]
iex(7)> quote do [1, unquote_splicing(list), 2] end
[1, 2, 3, 2]
With this trick in place to expand the arguments, we implement the function definition block as follows:
quote do
defdelegate unquote(name)(unquote_splicing(args)), to: unquote(module)
defoverridable [{unquote(name), unquote(arity)}]
end
And voila! In just a couple of lines, we've implemented OO-style inheritence in Elixir.
Conclusion: Seriously, Don’t Do This
Implementing object oriented style inheritance in Elixir is a great little exercise to learn a bit more about the macro system, but it’s probably not a good idea to use it in an Elixir application. While inheritance is a common tool in other languages, it’s not a common Elixir idiom and is likely to cause confusion (and probably well-deserved scorn) if used in an Elixir codebase.
That said, the power of the macro system is that we can implement these kinds of specialized structures when necessary. Though I can’t imagine using a macro like this for every day application development, it’s certainly possible that functionality like this could be used for a specialized library or DSL, similar to Ecto’s specialized use of macros.
To learn more about metaprogramming in Elixir, I recommend the book Metaprogramming Elixir: Write Less Code, Get More Done (and Have Fun!) by Phoenix Framework author Chris McCord.