Appearance
Meta-programming anti-patterns
Source: Elixir guide, Meta-programming anti-patterns.
Anti-patterns about macros and code generation. Five of them.
Compile-time dependencies
Problem: any macro use adds a compile-time dependency on the module defining the macro. Worse, when a macro is used in a module body, its arguments can become compile-time dependencies too, so changing one file recompiles many.
elixir
defmodule MyApp do
use Plug.Builder
plug MyApp.Authentication
endplug stores the module in @plugs, and MyApp.Authentication is only called at runtime. But because plug ran at compile time, it is now a compile-time dependency of MyApp.
Refactoring: expand the literal as if it were inside the function where it's used:
elixir
defmacro plug(mod) do
mod = Macro.expand_literals(mod, %{__CALLER__ | function: {:call, 2}})
quote do
@plugs unquote(mod)
end
endNow it's a runtime dependency. Only do this if the macro doesn't call functions, read structs or inspect the module at compile time. Use mix xref trace path/to/file.ex to see a file's compile-time, runtime and export dependencies.
Large code generation
Problem: a macro that expands into a lot of code makes compilation slower and the artifacts larger, because it's expanded and compiled at every call, and a router can have hundreds of get/2.
Refactoring: keep the quote tiny and delegate the work to a function:
elixir
defmacro get(route, handler) do
quote do
Routes.__define__(__MODULE__, unquote(route), unquote(handler))
end
end
def __define__(module, route, handler) do
# checks, raises, Module.put_attribute(...)
endUnnecessary macros
Problem: a macro where a function would do makes code harder to read and reason about, and harder to evolve.
elixir
defmacro sum(v1, v2) do
quote do: unquote(v1) + unquote(v2)
endRefactoring: a plain function, with no require needed:
elixir
def sum(v1, v2), do: v1 + v2This is the guidance from the Macros chapter: macros are a last resort.
use instead of import
Problem: import and alias are lexical and only let a module call another. use lets a module inject any code, including propagated imports, so you must know the library's internals to know what your module now contains.
elixir
defmodule Library do
defmacro __using__(_opts) do
quote do
import Library
import ModuleA # propagated
end
end
end
defmodule ClientApp do
use Library
def foo, do: "local" # error: imported ModuleA.foo/0 conflicts with local function
endRefactoring: avoid __using__/1 when alias or import would do. ClientApp just does import Library.
When you truly need more, use is the common extension point. Document its effects in @moduledoc like a nutrition label, listing only changes to the public API. For example: When you use GenServer, the GenServer module will set @behaviour GenServer and define a child_spec/1 function, so your module can be used as a child in a supervision tree.
Untracked compile-time dependencies
Problem: the opposite: a compile-time dependency the compiler can't see, so it doesn't recompile when it should. It happens when module names are built dynamically.
elixir
mods = [OtherModule.Foo, OtherModule.Bar] # fine: literals are tracked
for mod <- mods, do: mod.example()
for part <- [:Foo, :Bar] do # bad: the compiler only sees OtherModule
Module.concat(OtherModule, part).example()
endWriting :"Elixir.OtherModule.Foo" atoms directly has the same problem, since Elixir never sees the aliases.
Refactoring: use full module names. If you must build them, do it in a macro at compile time so the compiler still sees them:
elixir
defmacro call_examples(parts) do
for part <- parts do
quote do
OtherModule.unquote(part).example()
end
end
endmix xref trace helps check that dependencies are tracked.