How to override Kernel macros
The macro mechanism in Elixir is not only an interesting metaprogramming feature - in fact, it is at the language's very core. And the more awesome fact is that, using macros, you can override the algorithm of defining functions itself!
This TIL is a build-up for Michal Buszkiewicz's ElixirConf EU 2021 talk on debugging in Elixir - see you in Warsaw on Sep 10, 2021!
Elixir is a language built upon macros, that are used to transform code into different code. Some of the language's keywords such as case
, cond
, etc. are actually implemented at the compiler level and are shimmed with the Kernel.SpecialForms
module, whereas others such as raise
, if
, unless
etc. are implemented as macros that do their job interacting with Erlang-provided modules as well as those from Kernel.SpecialForms
.
Hell, even def
is defined as a macro and to make things even weirder, so is defmacro
itself (explained simply enough here).
One thing that you have to keep in mind before you go further is that Kernel
and Kernel.SpecialForms
is imported automatically into each of your custom modules. You can, however, opt to use the except
option to skip certain macros:
defmodule NoIf do
import Kernel, except: [if: 2]
def foo() do
# trying to use if/2 will result in an error
end
end
That's just an example. How can we use it for more practical purposes? We can, for instance, override def
to tamper with every function that we implement inside the module - for whatever purposes (logging function calls, other debugging purposes, etc.).
defmodule CustomDef do
defmacro def(call, expr \\ nil) do
IO.inspect([call: call, expr: expr], label: "Defining")
quote do
Kernel.def(unquote(call), unquote(expr))
end
end
end
defmodule NoDef do
import Kernel, except: [def: 2, def: 1, defp: 2, defp: 1]
import CustomDef
def foo(arg) do
{:ok, :foo, arg * 2}
end
end
IO.inspect(NoDef.foo(2), label: "Inside NoDef.foo/0")
IO.inspect(NoDef.foo(3), label: "Inside NoDef.foo/0")
The output is:
Defining: [
call: {:foo, [line: 15], [{:arg, [line: 15], nil}]},
expr: [
do: {:{}, [line: 16],
[:ok, :foo, {:*, [line: 16], [{:arg, [line: 16], nil}, 2]}]}
]
]
Inside NoDef.foo/0: {:ok, :foo, 4}
Inside NoDef.foo/0: {:ok, :foo, 6}
For demonstration purposes, I wanted to show that we were able to put an output message at the stage of defining the function, and - as you can see that expr
contains the function's AST - you can alter it so that, for instance, every function you create in your module contains a logger call at the beginning.
For easier usage in any of your modules, you could create a __using__
macro so you can plug it in with the use
keyword.
How to check if a set contains exact values with Jest in JS?