Elixir is often compared to Ruby. It’s true that Elixir takes inspiration from Ruby’s syntax. One of Elixir’s core tenets is “metaprogramming”, something often associated with Ruby and its ecosystem. That said, after using Elixir for a small amount of time it is immediately obvious that the semantics of the languages are very different. More subtly, Elixir focuses on explicitness where Ruby is often implicit. In this blog post, I’ll discuss Elixir’s approach to composing functionality in an explicit manner.
Building Blocks
The building blocks of Elixir applications are straightforward; modules, functions and macros. Modules are simply a namespaced bucket where functions or macros are defined.
defmodule Foo do
def add(a, b) do
a + b
end
end
We can call functions in a module by qualifying the function with the module name.
Foo.add(1, 2)
# => 3
This is true within other modules as well.
defmodule Bar do
def add(a, b, c) do
x = Foo.add(a, b)
Foo.add(x, c)
end
end
Bar.add(1, 2, 3)
# => 6
Functions within a module can make unqualified calls to other functions within that same module.
defmodule Bar do
def a do
1
end
def b do
a + 2
end
end
Bar.b
# => 3
If we’d like to use macros from module Foo within Bar, we must first
require it.
defmodule Foo do
defmacro greeter(message) do
quote do
def greet() do
unquote(message)
end
end
end
end
defmodule Bar do
require Foo
Foo.greeter("hello, world!")
end
Bar.greet
# => "hello, world!"
Reducing Verbosity
If our module Bar is making heavy use of functions/macros in Foo, it can be
tedious to always qualify those calls. import solves this problem.
defmodule Foo do
defmacro greeter(message) do
quote do
def greet() do
unquote(message)
end
end
end
def first do
1
end
def second do
2
end
end
defmodule Bar do
import Foo
greeter("hello, world!")
def sum do
first + second
end
end
Bar.greet
# => "hello, world!"
Bar.sum
# => 3
Bar.first
# => this will error
Note that while we can now make unqualified calls to functions to Foo inside
Bar, the import is explicit. It is also important to recognize that Bar
does not expose Foo’s functions externally even though we’ve imported it.
We can also import a subset of functions from Foo by providing of list of
function names and arities.
defmodule Bar do
import Foo, only: [first: 0]
def increment do
first + 1
end
end
Bar.increment
# => 2
More Flexibility
There are cases where we’d like to give module Foo the ability to inline some
code into our module Bar. This would let us do things like setup DSLs,
abstract more complicated import graphs, etc.
As an example, suppose we want to be able to write code like this:
defmodule Add do
def add(a, b) do
a + b
end
end
defmodule Multiply do
def multiply(a, b) do
a * b
end
end
defmodule Rectangle do
import Add
import Multiply
def area(a, b) do
multiply(a, b)
end
def perimeter(a, b) do
multiply(add(a, b), 2)
end
end
Rectangle.area(2, 3)
# => 6
Rectangle.perimeter(2, 3)
# => 10
This works, but it’s a pain to import both the Add and Multiply modules.
Let’s make a Math module that does the hard work for us.
defmodule Math do
defmacro import_deps do
quote do
import Add
import Multiply
end
end
end
defmodule Rectangle do
require Math
Math.import_deps
def area(a, b) do
multiply(a, b)
end
def perimeter(a, b) do
multiply(add(a, b), 2)
end
end
Great! We’ve essentially inlined the code in Math.import_deps into our
Rectangle module, but it’s all still explicit. This pattern is common enough
that Elixir introduces use. use will require the provided module and then
call the __using__ macro. Let’s rewrite the code above.
defmodule Math do
defmacro __using__(_opts) do
quote do
import Add
import Multiply
end
end
end
defmodule Rectangle do
use Math
def area(a, b) do
multiply(a, b)
end
def perimeter(a, b) do
multiply(add(a, b), 2)
end
end
It’s important to stop and recognize how powerful this pattern is. Because
__using__ is a macro, we have a lot of flexibility. We can import other
modules, call functions, call macros, etc. And yet, it’s all explicit.
Wrapping Up
Given what we now know, any time we see an unqualified function/macro call in a module, one of the following is true:
- The function/macro is defined in the same module
- The function/macro has been
imported from another module - A module we’ve
used has imported the function/macro or defined it
Of course there can be a deep hierarchy of imports and uses, but they’re all
traceable. We’re left with an explicit system for composing functionality that
is powerful, flexible and easy to reason about.