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

  • 20,992