Write Less, Do More (and Have Fun!) With

ELIXIR MACROS



@chris_mccord




Erlang Factory SF, 2014

Topics

  • What are macros?
  • Macro use-cases
    • Eliminating boilerplate
    • Advanced compile time code generation
    • Domain Specific Languages (DSLs)
    • Fun!
  • Writing Macros - A Guided Lesson
    • assert macro
    • Testing DSL
  • Real-world use cases and examples
    • Elixir's unicode handling
    • Phoenix Web Framework

Macro RULE #1




Don't write macros

MACRO RULE #2




USE MACROS GRATUITOUSLY

What Are macros?


  • Code that writes code
  • Elixir itself is primarily built with macros
    • if, unless, cond
    • def, defmodule
  • Full access to Elixir at compile time

What Are Macros?

Modules have full access to Elixir at compile time

defmodule Calculator do
  IO.puts "Any code in here is valid!"

  def add(a, b) do
    a + b
  end
end

IO.puts Calculator.add(1, 2)
$ elixir calculator.exs Any code in here is valid! 3

What ARE MACROS?


  • Elixir code can be represented entirely by Elixir's own data structures

iex> quote do: (1 + 2) - 5 * 10
{:-, [],
 [{:+, [], [1, 2]},
  {:*, [], [5, 10]}]}

quote


  • Returns the representation of any expression (AST)
    • AST is represented as a series of three element tuples
    • The first element is always an atom or another tuple
    • The second element represents metadata
    • The third element is the arguments for the function call

quotE

iex> quote do: div(10, 2)
{:div, [], [10, 2]} 

iex> add = fn a, b -> a + b end 

iex> quote do: add.(1, 2)
{
  {:., [], [{:add, [], Elixir}]}, 
  [],
  [1, 2]
}




ASSERT MACRO

assert MACRO


iex> quote do: 5 == 5
{:==, [...], [5, 5]}

iex> quote do: 5 > 0
{:>, [...], [5, 0]} 
defmodule Assertions do
  defmacro assert({operator, _, [lhs, rhs]}) do
    quote do
      do_assert unquote(operator),
                unquote(lhs),
                unquote(rhs)
    end
  end
  ...

  def do_assert(:==, lhs, rhs) when lhs == rhs do
    IO.write(".")
  end
  def do_assert(:==, lhs, rhs) do
    IO.puts """
    FAIL: Expected: #{lhs}
    to be equal to: #{rhs}
    """
  end

defmodule Assertions do
  ...
  def do_assert(:>, lhs, rhs) when lhs > rhs do
    IO.write(".")
  end
  def do_assert(:>, lhs, rhs) do
    IO.puts """
    FAIL:     Expected: #{lhs}
    to be greater than: #{rhs}
    """
  end
  ...
end

defmodule MyTest do
  import Assertions

  def run do
    assert 1 == 1
    assert 3 > 1
    assert 5 == 0
    assert 0 > 8
  end
end
iex> MyTest.run
..FAIL: Expected: 5
to be equal to: 0

FAIL:     Expected: 0
to be greater than: 8

use

use is a macro that provides a common API for extension

defmodule MyTest do
  use Assertions       # invokes __using__ hook

  test "adding two numbers" do
    assert 5 + 5 == 10
    assert 5 + 1 == 6
  end

  test "zero is greater than 1" do
    assert 0 > 1
  end
end

iex> MyTest.run


use

$ elixir my_test.exs
.
FAIL: "zero is greater than 1"
Expected: 0
to be greater than: 1

test "zero is greater than 1" do
  assert 0 > 1
end
# Assertions Module
defmacro __using__(_opts) do
  quote do
    import unquote(__MODULE__)
    Module.register_attribute __MODULE__, :tests, accumulate: true,
                                                  persist: false
    @before_compile unquote(__MODULE__)
  end
end
...
defmacro test(desc, do: test_block) do
  func_name = desc
              |> String.downcase
              |> String.replace(~r/[^a-zA-Z0-9]/, "_")
              |> binary_to_atom
  quote do
    @tests {unquote(desc), unquote(func_name)}
    def unquote(func_name)(), do: unquote(test_block)
  end
end
...

defmacro __before_compile__(_env) do
  quote do
    def run do
      @tests |> Enum.reverse |> Enum.each fn {desc, test_case} ->
        report desc, apply(__MODULE__, test_case, [])
      end
    end
  end
end
def report(_desc, :ok), do: IO.write "."
def report(desc, {:fail, message}) do
  IO.puts """

  FAIL: "#{desc}"
  #{message}
  """
end

defmodule MyTest do
  use Assertions 
  # @tests { "adding two numbers",
  #          :adding_two_numbers }
  test "adding two numbers" do
    assert 5 + 5 == 10
  end

  # @tests { "zero is greater than 1", 
  #          :zero_is_greater_than_1 }
  test "zero is greater than 1" do
    assert 0 > 1
  end
end
iex> MyTest.run
.
FAIL: "zero is greater than 1"
Expected: 0
to be greater than: 1

unicode SUPPORT

# Ruby
irb> "Thanks José!".upcase
"THANKS JOSé!"

irb> "noël".reverse
"l̈eon"
# Elixir
iex> String.upcase("Thanks José!")
"THANKS JOSÉ!"

iex> String.reverse("noël")
"lëon 

Elixir Unicode Support


  • Unicode handled by pattern matched functions generated at compile time
  • Functions generated entirely from `UnicodeData.txt` codepoint database
  • Supporting any additional codepoints in the future requires simply editing UnicodeData.txt and recompiling

UnicodeData.txt


0047;LATIN CAPITAL LETTER G;Lu;0;L;;;;;N;;;;0067;
0048;LATIN CAPITAL LETTER H;Lu;0;L;;;;;N;;;;0068;
0049;LATIN CAPITAL LETTER I;Lu;0;L;;;;;N;;;;0069;
004A;LATIN CAPITAL LETTER J;Lu;0;L;;;;;N;;;;006A;
004B;LATIN CAPITAL LETTER K;Lu;0;L;;;;;N;;;;006B;
...
1F680;ROCKET;So;0;ON;;;;;N;;;;;
1F681;HELICOPTER;So;0;ON;;;;;N;;;;;
1F682;STEAM LOCOMOTIVE;So;0;ON;;;;;N;;;;;
1F683;RAILWAY CAR;So;0;ON;;;;;N;;;;;
1F684;HIGH-SPEED TRAIN;So;0;ON;;;;;N;;;;;
1F686;TRAIN;So;0;ON;;;;;N;;;;;
1F687;METRO;So;0;ON;;;;;N;;;;;

unicode.ex


defmodule String.Unicode do

  data_path = Path.join(__DIR__, "UnicodeData.txt")

  {codes, whitespace} = Enum.reduce File.stream!(data_path)...

  def upcase(string), do: do_upcase(string) |> iolist_to_binary

  lc {codepoint, upper,_,_} inlist codes, upper != codepoint do
    defp do_upcase(unquote(codepoint) <> rest) do
      unquote(:binary.bin_to_list(upper)) ++ do_upcase(rest)
    end
  end
end

unicode.ex


defp do_upcase("é" <> rest) do
  ["É"] ++ upcase(rest)
end

defp do_upcase("ë" <> rest) do
  ["Ë"] ++ upcase(rest)
end

defp do_upcase(🚀 <> rest) do
  [🚀] ++ upcase(rest)
end 

PHoenix Web Framework

Router DSL

defmodule Router do
  use Phoenix.Router, port: 4000

  get  "/pages/:page", Ctrls.Pages, :show, as: :page
  get  "/files/*path", Ctrls.Files, :show
  post "/files",       Ctrls.Files, :upload

  resources "users", Ctrls.Users do
    resources "comments", Ctrls.Comments
  end
end

Generated match definitions
get "/pages/:page", Ctrls.Pages, :show, as: :page
def match(conn, :get, ["pages", page]) do
  conn = conn.params(Dict.merge(conn.params, [
    {"page", page}
  ]))
  apply(Ctrls.Pages, :show, [conn])
end


get "/files/*path", Ctrls.Files, :show
def match(conn, :get, ["files" | path]) do
  conn = conn.params(Dict.merge(conn.params, [
    {"path", Phoenix.Router.Path.join(path)}
  ]))
  apply(Ctrls.Files, :show, [conn])
end
...

defmodule Router do
  def match(conn, :get,    ["pages", page])
  def match(conn, :get,    ["files" | path])
  def match(conn, :post,   ["files"])
  def match(conn, :get,    ["users"])
  def match(conn, :get,    ["users", id, "edit"])
  def match(conn, :get,    ["users", id])
  def match(conn, :get,    ["users", "new"])
  def match(conn, :post,   ["users"])
  def match(conn, :put,    ["users", id])
  def match(conn, :patch,  ["users", id])
  def match(conn, :delete, ["users", id])
  def match(conn, :get,    ["users", user_id, "comments"])
  def match(conn, :get,    ["users", user_id, "comments", id, "edit"])
  def match(conn, :get,    ["users", user_id, "comments", id])
  def match(conn, :get,    ["users", user_id, "comments", "new"])
  def match(conn, :post,   ["users", user_id, "comments"])
  def match(conn, :put,    ["users", user_id, "comments", id])
  def match(conn, :patch,  ["users", user_id, "comments", id])
  def match(conn, :delete, ["users", user_id, "comments", id])
end


Router Pattern Matching

Route dispatch handled entirely by VM through pattern matching

resources "users", Ctrls.Users do
  resources "comments", Ctrls.Comments
end

GET "myapp.com/users/1/comments/2"

                      ["users", "1",     "comments","2"]
def match(conn, :get, ["users", user_id, "comments", id])
get "files/*path", Ctrls.Files, :show

GET "myapp.com/Files/Docs/photo/img.png"

                      ["files", "Docs", "photo", "img.png"]
def match(conn, :get, ["files" | path])
Named Route Helpers


get "/pages/:page", Ctrls.Pages, :show, as: :page

resources "users", Ctrls.Users do
  resources "comments", Ctrls.Comments
end

iex> Router.user_comments_path(user_id: 123)
"/users/123/comments"

iex> Router.user_comment_path(id: 1, user_id: 123)
"/users/123/comments/1"

iex> Router.page_path(page: "home")
"/pages/home"
 

FUN

defmodule Hub do
  "https://github.com/chrismccord.json?tab=repositories"
  |> HTTPotion.get
  |> apply(:body, [])
  |> JSON.decode!
  |> Enum.each fn result ->
    repo = result["repository"]
    def unquote(binary_to_atom(repo["name"]))() do
      unquote(repo)
    end
  end

  def go(repo) do
    System.cmd("open #{apply(__MODULE__, repo, [])["url"]}")
  end
end
iex> Hub.go(:phoenix)
launches "https://github.com/phoenixframework"

iex> Hub.phoenix["watchers"]
103


Macros RULES SUMMARY


  1. Don't write macros
    1. * Write macros responsibly
  2. Write macros irresponsibly
  3. Have fun




@chris_mccord

chris@chrismccord.com

freenode: #elixir-lang

github.com/phoenixframework

Elixir Macros

By chrismccord

Elixir Macros

  • 19,645