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.