Usually, we think of a program as something that manipulates data to achieve some result.
But what is data?
Can we use the programs themselves as data? š¤
In todayās article, weāll go down the rabbit hole with the assistance of Elixir, a programming language that is permeated by metaprogramming.
Iāll introduce you to metaprogramming in Elixir and show you how to create a macro for defining curried functions.
Metaprogramming is just writing programs that manipulate programs. Itās a wide term that can include compilers, interpreters, and other kinds of programs.
In this article, we will focus on metaprogramming as it is done in Elixir, which involves macros and compile-time code generation.
To understand how metaprogramming works in Elixir, it is important to understand a few things about how compilers work. During the compilation process, every computer program is transformed into an abstract syntax tree (AST) ā a tree structure that enables the computer to understand the contents of the program.
In Elixir, each node of the AST (except basic values) is a tuple of three parts: function name, metadata, function arguments.
Elixir enables us to access this internal AST representation via quote
.
iex(1)> quote do 2 + 2 - 1 end
{:-, [context: Elixir, import: Kernel],
[{:+, [context: Elixir, import: Kernel], [2, 2]}, 1]}
We can modify these ASTs with macros, which are functions from AST to AST that are executed at compile-time.
You can use macros to generate boilerplate code, create new language features, or even build domain-specific languages (DSLs).
Actually, a lot of the language constructs that we regularly use in Elixir such as def
, defmodule
, if
, and others are macros. Furthermore, many popular libraries like Phoenix, Ecto, and Absinthe use macros liberally to create convenient developer experiences.
Hereās an example Ecto query from the documentation:
query = from u in "users",
where: u.age > 18,
select: u.name
Metaprogramming in Elixir is a powerful tool. It approaches LISP (the OG metaprogramming steam roller) in expressivity but keeps things one level above in abstraction, enabling you to delve into AST only when you need to. In other words, Elixir is basically LISP but readable. š
So how do we channel this immense power? š§
While metaprogramming can be rather tricky, it is rather simple to start metaprogramming in Elixir. All you need to know are three things.
quote
|
unquote
|
defmacro
|
code -> AST | outside quote -> inside quote | AST -> AST |
quote
converts Elixir code to its internal AST representation.
You can think of the difference between regular and quoted expressions to be the difference in two different requests.
iex(1)> 2 + 2
4
iex(2)> quote do 2 + 2 end
{:+, [context: Elixir, import: Kernel], [2, 2]}
quote
makes it a breeze to write Elixir macros since we donāt have to generate or write the AST by hand.
But what if we want to have access to variables inside quote
? The solution is unquote
.
unquote
functions like string interpolation, enabling you to pull variables into quoted blocks from the surrounding context.
Hereās how it looks in Elixir:
iex(1)> two = 2
2
iex(2)> quote do 2 + 2 end
{:+, [context: Elixir, import: Kernel], [2, 2]}
iex(3)> quote do two + two end
{:+, [context: Elixir, import: Kernel],
[{:two, [], Elixir}, {:two, [], Elixir}]}
iex(4)> quote do unquote(two) + unquote(two) end
{:+, [context: Elixir, import: Kernel], [2, 2]}
If we donāt unquote two
, we will get Elixirās internal representation of some unassigned variable called two. If we unquote it, we get access to the variable inside the quote block.
Macros are functions from ASTs to ASTs.
For example, suppose we want to make a new type of expression that checks for the oddity of numbers.
We can make a macro for it in just a few lines with defmacro
, quote
, and unquote
.
defmodule My do
defmacro odd(number, do: do_clause, else: else_clause) do
quote do
if rem(unquote(number), 2) == 1, do: unquote(do_clause), else: unquote(else_clause)
end
end
end
iex(1)> require My
My
iex(2)> My.odd 5, do: "is odd", else: "is not odd"
"is odd"
iex(3)> My.odd 6, do: "is odd", else: "is not odd"
"is not odd"
"Rule 1: Donāt Write Macros" ā Chris McCord, Metaprogramming in Elixir
While metaprogramming can be an awesome tool, it should be used with caution.
Macros can make debugging much harder and increase overall complexity. They should be turned to only when it is necessary ā when you run into problems you canāt solve with regular functions or when there is a lot of plumbing behind the scenes that you need to hide.
When used correctly, they can be very rewarding, though. To see how they can improve developer life, let's look at some real-life examples from Phoenix, the main Elixir web framework.
In the following section, weāll analyze the router submodule of a freshly made Phoenix project as an example of how macros are used in Elixir.
If you look at the top of basically any Phoenix file, you will most likely see a use
macro. Our router submodule has one.
defmodule HelloWeb.Router do
use HelloWeb, :router
What this one expands to is:
require HelloWeb
HelloWeb.__using__(:router)
require
asks HelloWeb
to compile its macros so that they can be used for the module. But whatās using
? It, as you might have guessed, is another macro!
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
In our case, this macro invokes the router
function from the HelloWeb
module.
def router do
quote do
use Phoenix.Router
import Plug.Conn
import Phoenix.Controller
end
end
router
imports two modules and launches another __using__
macro.
As you can see, this hides a lot, which can be both a good and a bad thing. But it also gives us access to a magical use HelloWeb, :router
to have everything ready for quick webdev action whenever we need.
Now, look below use
.
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
end
Yup, more macros.
pipeline
and plug
define pipelines of plugs, which are functions of functions that transform the connection data structure.
While the previous macro was used for convenient one-line imports, this one helps to write pipelines in a very clear and natural language.
And, of course, the routing table is a macro as well.
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :index
end
scope
, pipe_through
, get
ā all macros.
In fact, the whole module is macros and an if statement (which is a macro) that adds an import statement and executes a macro.
I hope that this helps you see how metaprogramming is at the heart of Elixir.
Now, letās try to build our own macro.
Elixir and currying donāt really vibe together. But with some effort, you can create a curried function in Elixir.
Hereās a regular Elixir sum function:
def sum(a, b), do: a + b
Hereās a curried sum function:
def sum() do
fn x ->
fn y ->
x + y
end
end
end
Hereās how they both behave:
iex(1)> Example.sum(1,2)
3
iex(2)> plustwo = Example.sum.(2)# Function<10.76762873/1 in Example.sum/0>
iex(3)> plustwo.(2)
4
Letās say that we want to use curried functions in Elixir for some reason (for example, we want to create a monad library). Writing out every function in our code like that would be, to say the least, inconvenient.
But with the power of metaprogramming, we can introduce curried functions without a lot of boilerplate. Letās define our own defc
macro that will define curried functions for us.
First, we need to take a look at how a regular def
looks as an AST:
iex(1)> quote do def sum(a, b), do: a + b end
{:def, [context: Elixir, import: Kernel],
[
{:sum, [context: Elixir], [{:a, [], Elixir}, {:b, [], Elixir}]},
[
do: {:+, [context: Elixir, import: Kernel],
[{:a, [], Elixir}, {:b, [], Elixir}]}
]
]}
It is a macro with two arguments: the function definition (in this case, sum
is being defined) and a do: expression.
Therefore, our defc
(which should take the same data) will be a macro that takes two things:
defmodule Curry do
defmacro defc({name, ctx, arguments} = clause, do: expression) do
end
end
We want the macro to define two functions:
defc
. defmacro defc({name, ctx, arguments} = clause, do: expression) do
quote do
def unquote(clause), do: unquote(expression)
def unquote({name, ctx, []}), do: unquote(body)
end
end
Thatās more or less the macro. Now, we need to generate the main part of it, the body.
To do that, we need to go through the whole argument list and, for each argument, wrap the expression in a lambda.
defp create_fun([h | t], expression) do
rest = create_fun(t, expression)
quote do
fn unquote(h) -> unquote(rest) end
end
end
defp create_fun([], expression) do
quote do
unquote(expression)
end
end
Then, we assign the variable body
in the macro to be the result of create_fun
, applied to the arguments and the expression.
defmacro defc({name, ctx, arguments} = clause, do: expression) do
body = create_fun(arguments, expression)
quote do
def unquote(clause), do: unquote(expression)
def unquote({name, ctx, []}), do: unquote(body)
end
end
Thatās it! š„³
To try it out, letās define another module with a sum function in it.
defmodule Example do
import Curry
defc sum(a, b), do: a + b
end
iex(1)> Example.sum(2,2)
4
iex(2)> Example.sum.(2).(2)
4
iex(3)> plustwo = Example.sum.(2)# Function<5.100981091/1 in Example.sum/0>
iex(4)> plustwo.(2)
4
You can see the full code here.
In our example, the macro provides only the sum()
and sum(a,b)
functions. But from here, itās easy to extend our macro to generate partial functions for all arities.
In the case of sum
, we can make it so that the macro generates sum()
, sum(a)
, and sum(a,b)
, modifying the function definition to account for the missing arguments.
Since it is an awesome exercise to try on your own, I will not spoil the answer. š
If you want to learn more about macros in Elixir, here are a few resources I suggest to check out:
Serokell
Serokell is a software development company focused on creating customized high-performance solutions with functional programming technologies.
See other articles by Serokell
Ground Floor, Verse Building, 18 Brunswick Place, London, N1 6DZ
108 E 16th Street, New York, NY 10003
Join over 111,000 others and get access to exclusive content, job opportunities and more!