Daniel Perez

CTO@ClaudeTech

danhper

http://bit.ly/metaprogramming-ex

Today's topic

Metaprogramming in Elixir


Target audience

  • At least basic knowledge of Elixir

Metaprogramming in Elixir

  1. Metaprogramming and macros
  2. AST and code representation
  3. Writing macros
  4. Writing DSLs

What is metaprogramming?

computer programs with the ability to treat programs as their data.

What is metaprogramming?

In today context:

A program which can modify itself

How do we metaprogram?

  • Preprocesor macros (C, C++)
  • Reflection (Java, Go)
  • Dynamic program modification (Ruby, Python)
  • Macros modifying AST (Lisp, Elixir)

A short Ruby example

class Foo
  %w(foo bar baz).each do |v|
    define_method(v) do
      puts "Hi, I am #{v}"
    end
  end
end

What are macros?

a rule that specifies how a certain input sequence should be mapped to a replacement output sequence

Preprocessor macros

Transform the program before the compiler runs

Example in C

#ifdef DEBUG_BUILD
  #define DEBUG(x) fprintf(stderr, x)
#else
  #define DEBUG(x) do {} while (0)
#endif

A long long time ago in LispLand

Code is data, data is code!

Homoiconicity they say...

(+ 1 2 3)

is

  • a list containing + 1 2 3
  • an expression adding 1 2 3

Back to the future

And Elixir?

It's like Lisp...

but Jose hid the parentheses

Enter the AST

Abstract Syntax Tree

Compiler frontend

AST in Elixir

An expression is a tuple

Simple expression

iex(1)> quote do: 1 + 2
{:+, [context: Elixir, import: Kernel], [1, 2]}

A little more complex expression

iex(2)> quote do 
...(2)>   if a > 20, do: "major", else: "minor"  
...(2)> end
{:if, [context: Elixir, import: Kernel],
 [{:>, [context: Elixir, import: Kernel], [{:a, [], Elixir}, 20]},
  [do: "major", else: "minor"]]}

Writing macros

Quoting and unquoting

  • quote transforms an expression into its AST representation
    ` in Lisp
  • unquote allows to inject a value in the AST
    , in Lisp
  • unquote_splicing allows to inject an array in the AST
    ,@ in Lisp

Using unquote

iex(1)> a = 5
iex(2)> ast = quote do 
...(2)>   a + 5
...(2)> end
iex(3)> Code.eval_quoted(ast)
** (CompileError) nofile:1: undefined function a/0

Using unquote

iex(1)> a = 5
iex(2)> ast = quote do 
...(2)>   unquote(a) + 5
...(2)> end
iex(3)> Code.eval_quoted(ast)
{10, []}

Using unquote_splicing

iex(1)> args = ["a,b,c", ","]
iex(2)> ast = quote do
...(2)>   String.split(unquote(args))
...(2)> end
iex(3)> Code.eval_quoted(ast)
** (FunctionClauseError) no function clause matching in String.Break.split/1

Using unquote_splicing

iex(1)> args = ["a,b,c", ","]
iex(2)> ast = quote do
...(2)>   String.split(unquote_splicing(args))
...(2)> end
iex(3)> Code.eval_quoted(ast)
{["a", "b", "c"], []}

Writing macros

Reimplementing if

defmacro if(condition, do: do_clause, else: else_clause) do
  quote do
    case unquote(condition) do
      x when x in [nil, false] -> unquote(else_clause)
      _ -> unquote(do_clause)
    end
  end
end

Reimplementing defdelegate

defdelegate fun(a, b), to: Mod

should expand to

def fun(a, b) do
  Mod.fun(a, b)
end

Reimplementing defdelegate

Implementing defdelegate

defmacro defdelegate(function, to: module) do
  {name, _, vars} = function  # {:fun, _, [{:a, _, _}, {:b, _, _}]}
  quote do
    def unquote(name)(unquote_splicing(vars)) do
      unquote(module).unquote(name)(unquote_splicing(vars))
    end
  end
end

Macro expansion

  • Macro are expanded at compile time
  • Compiler expands until it reaches a special form
  • Expansion can be checked with Macro.expand
  • Expanded form can be formatted with Macro.to_string

Macro hygiene

  • Macros are hygienic by default: they do not leak
  • Macros can be non-hygienic by using var!

A look at Plug router

Where is conn?

get "/hello" do
  send_resp(conn, 200, "world")
end

Plug router like macro

This will not work

defmacro get(route, do: block) do
  quote do
    def handle_request(conn, :get, unquote(route)) do
      unquote(block)
    end
  end
end
 
# when trying to use conn
** (CompileError) iex:8: undefined function conn/0

Plug router like macro

defmacro get(route, do: block) do
  quote do
    def handle_request(var!(conn), :get, unquote(route)) do
      unquote(block)
    end
  end
end
 
# somewhere else
get "/foo" do
  IO.inspect(conn)
end

Writing a DSL

What's a DSL?

  • Domain Specific Language
  • More or less a "mini-language"
  • Usually a set of macros/functions

Examples

  • Mix config
  • Phoenix router
  • ExUnit
  • ExCLI  (Today's sample)

Writing a DSL

defmodule MyApp.SampleCLI do
  use ExCLI.DSL

  name "mycli"
  description "My CLI"
  long_description "This is my long description"

  option :verbose, help: "Increase the verbosity level", aliases: [:v], count: true

  command :hello do
    description "Greets the user"
    long_description """
    Gives a nice a warm greeting to whoever would listen
    """

    argument :name
    option :from, help: "the sender of hello"

    run context do
      if context.verbose >= 1 do
        IO.puts("Running hello command.")
      end
      if from = context[:from] do
        IO.write("#{from} says: ")
      end
      IO.puts("Hello #{context.name}!")
    end
  end
end

We want to

  • Write the DSL macros
  • Store the data defined in macros
  • Retrieve and use the data

Storing data

  • Module attributes (compile time)
  • External process (run time)

Using use

  • use MyModule, opts is equivalent to
    • require Module
    • Module.__using__(opts)
  • use is (often) used to inject functionality

Defining __using__

defmacro __using__(_opts) do
  quote do
    import unquote(__MODULE__)
    @app %{commands: []}
    @command nil
    @before_compile unquote(__MODULE__)
  end
end

before_compile

defmacro __before_compile__(_env) do
  quote do
    def __app__ do
      @app
    end
  end
end

Simple setter

defmacro name(name) do
  quote do
    @app Map.put(@app, :name, unquote(name))
  end
end

Changing scope

defmacro command(name, do: block) do
  quote do
    @command %{name: unquote(name)}
    unquote(block)
    @app Map.put(@app, :commands, [@command | @app.commands])
    @command nil
  end
end

Changing scope

defmacro argument(name) do
  quote do
    if @command do
      @command Map.put(@command, :arguments, [unquote(name), @command.arguments])
    else
      raise "argument should be called from inside a command"
    end
  end
end

Definining functions

defmacro run(context, do: block) do
  quote bind_quoted: [context: Macro.escape(context), block: Macro.escape(block)] do
    def __run__(unquote(@command.name), var!(unquote(context))) do
      unquote(block)
    end
  end
end

Trying it out

defmodule MyCLI do
  use MyDSL

  name "my_cli"

  command :my_command do
    argument :hello

    run context do
      IO.inspect(context)
    end
  end
end

Wrapping up

Today we saw

  • Metaprogramming
  • Macros
  • How to write a DSL

We did not see

  • Using other processes to store data
  • How to test macros
  • Code generation

Wrapping up

A few guidelines

  • Do not use macros just to use macros
  • Do not create DSLs just to create DSLs
  • Test your macros properly
  • Have fun!