Macros em Elixir: escrevendo código que escreve código

1. Introdução às Macros: O Poder da Metaprogramação em Elixir

Macros em Elixir são ferramentas de metaprogramação que permitem gerar código durante a compilação. Diferentemente de funções comuns, que operam em tempo de execução com valores, as macros operam em tempo de compilação com a própria estrutura do código — a Árvore Sintática Abstrata (AST). Isso significa que uma macro recebe código como entrada e produz código transformado como saída.

No ecossistema Elixir, macros são onipresentes: o Ecto usa macros para definir esquemas de banco de dados (schema), o Phoenix as utiliza para roteamento (get, post), e o ExUnit depende delas para criar testes (test, assert). No entanto, macros devem ser usadas com moderação — cada macro adiciona complexidade ao código, dificulta a depuração e quebra a composição funcional tradicional. A regra de ouro é: prefira funções sempre que possível; recorra a macros apenas quando precisar de transformações em tempo de compilação.

2. AST (Árvore Sintática Abstrata): A Matéria-Prima das Macros

No Elixir, todo código é representado internamente como uma estrutura de dados de três elementos: uma tupla contendo o nome da função/macro, o módulo e os argumentos. O Elixir expõe essa representação através de quote, que captura o código como AST.

Para inspecionar o AST de uma expressão simples:

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

Expressões mais complexas geram ASTs aninhados:

iex> quote do: if true, do: :ok, else: :error
{:if, [context: Elixir, import: Kernel], [true, [do: :ok, else: :error]]}

A função Macro.to_string/1 faz o caminho inverso, convertendo AST de volta para código legível:

iex> ast = quote do: Enum.map([1,2,3], &(&1 * 2))
iex> ast |> Macro.to_string() |> IO.puts()
Enum.map([1, 2, 3], &(&1 * 2))

Essa capacidade de manipular código como dados é a base de toda metaprogramação em Elixir. Compreender o AST é essencial para escrever macros previsíveis e seguras.

3. quote e unquote: Construindo e Injetando Código

quote captura um bloco de código e retorna sua representação AST. unquote faz o oposto: permite injetar valores ou expressões dentro de um bloco quote.

Exemplo básico de quote:

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

Com unquote, podemos interpolar valores dinâmicos:

iex> x = 42
iex> quote do: 1 + unquote(x)
{:+, [context: Elixir, import: Kernel], [1, 42]}

Unquoting de expressões completas:

iex> expr = quote do: 5 * 3
iex> quote do: unquote(expr) + 10
{:+, [context: Elixir, import: Kernel],
 [{:*, [context: Elixir, import: Kernel], [5, 3]}, 10]}

Essa combinação permite construir ASTs dinâmicos de forma segura e controlada, injetando valores calculados em tempo de compilação dentro de estruturas de código.

4. defmacro: Criando Sua Primeira Macro

A estrutura básica de uma macro usa defmacro no lugar de def. A diferença fundamental: def recebe valores e retorna valores em tempo de execução; defmacro recebe AST (código não avaliado) e retorna AST transformado em tempo de compilação.

Exemplo: uma macro unless personalizada:

defmodule MyMacros do
  defmacro my_unless(condition, do: block) do
    quote do
      if !unquote(condition), do: unquote(block)
    end
  end
end

defmodule Test do
  require MyMacros

  def check(val) do
    MyMacros.my_unless val > 10 do
      IO.puts("Valor é menor ou igual a 10")
    end
  end
end

Outro exemplo prático: macro if com logging automático:

defmodule LogMacros do
  defmacro logged_if(condition, do: block) do
    quote do
      result = if unquote(condition), do: unquote(block)
      IO.puts("Condição: #{unquote(Macro.to_string(condition))} => #{result}")
      result
    end
  end
end

Note que require é necessário para usar macros de outro módulo, pois elas são expandidas em tempo de compilação.

5. Higiene de Macros: Evitando Conflitos de Nomes

Um dos maiores desafios com macros é a "captura de variáveis" — quando uma macro introduz variáveis que conflitam com variáveis do contexto de chamada. O Elixir resolve isso com higiene de macros: variáveis criadas dentro de quote são únicas e não interferem com o escopo externo.

Exemplo de problema de higiene:

defmodule BadMacro do
  defmacro set_x(val) do
    quote do
      x = unquote(val)  # Esta variável x é higiênica
    end
  end
end

x = 10
BadMacro.set_x(20)
IO.puts(x)  # Ainda imprime 10, não 20

Para quebrar a higiene intencionalmente, use var!:

defmodule Unhygienic do
  defmacro set_x!(val) do
    quote do
      var!(x) = unquote(val)
    end
  end
end

x = 10
Unhygienic.set_x!(20)
IO.puts(x)  # Agora imprime 20

Para valores complexos (listas, mapas, tuplas), use Macro.escape/1:

defmodule SafeMacro do
  defmacro store_data(data) do
    escaped_data = Macro.escape(data)
    quote do: unquote(escaped_data)
  end
end

6. Macros Avançadas: Macro.prewalk e Manipulação de AST

Para transformações complexas do AST, o Elixir oferece Macro.prewalk/2 e Macro.postwalk/2, que percorrem recursivamente a árvore.

Exemplo: macro @decorator para logging automático de chamadas:

defmodule Decorator do
  defmacro def_logged({:when, _, [func_head, guard]}) do
    quote do
      def unquote(func_head) when unquote(guard) do
        IO.puts("Chamando: #{unquote(Macro.to_string(func_head))}")
        result = unquote(func_head)
        IO.puts("Resultado: #{result}")
        result
      end
    end
  end

  defmacro def_logged(func_head) do
    quote do
      def unquote(func_head) do
        IO.puts("Chamando: #{unquote(Macro.to_string(func_head))}")
        result = unquote(func_head)
        IO.puts("Resultado: #{result}")
        result
      end
    end
  end
end

defmodule Calculator do
  require Decorator

  Decorator.def_logged add(a, b), do: a + b
  Decorator.def_logged multiply(a, b), do: a * b
end

Macro.prewalk permite modificar cada nó antes de processar seus filhos:

defmodule Transform do
  defmacro add_logging(expr) do
    Macro.prewalk(expr, fn
      {:+, meta, args} ->
        quote do
          result = unquote({:+, meta, args})
          IO.puts("Soma resultou em: #{result}")
          result
        end
      node -> node
    end)
  end
end

7. Casos de Uso Reais: Macros em Frameworks e Bibliotecas

Ecto usa macros extensivamente para definição de esquemas:

defmodule User do
  use Ecto.Schema

  schema "users" do
    field :name, :string
    field :email, :string
    field :age, :integer, default: 0
    timestamps()
  end
end

Cada field e timestamps são macros que geram funções de acesso, validações e metadados do schema.

Phoenix utiliza macros para roteamento declarativo:

defmodule MyAppWeb.Router do
  use Phoenix.Router

  get "/users", UserController, :index
  post "/users", UserController, :create
  resources "/posts", PostController
end

Cada get, post e resources expande em múltiplas funções de pipeline e match de rotas.

ExUnit define toda sua DSL de testes via macros:

defmodule MyTest do
  use ExUnit.Case

  test "truth" do
    assert 1 + 1 == 2
  end
end

test e assert são macros que geram funções de teste com captura de exceções e formatação de falhas.

8. Limitações e Boas Práticas: Quando Não Usar Macros

Macros têm limitações importantes:

  1. Não compõem com pipes: 1 |> my_macro não funciona, pois macros operam em AST, não em valores.
  2. Depuração complexa: erros apontam para código gerado, não para a chamada original.
  3. Quebram previsibilidade: o comportamento pode variar dependendo do contexto de compilação.

Checklist para decidir se uma macro é necessária:

  1. A transformação precisa acontecer em tempo de compilação?
  2. Você está criando uma DSL que precisa de sintaxe especial?
  3. A alternativa com funções seria significativamente mais verbosa ou menos segura?
  4. O código gerado é determinístico e previsível?
  5. Você pode testar a macro isoladamente?

Alternativas preferíveis:
- Funções de ordem superior (map, reduce, filter)
- Protocolos (polimorfismo)
- Módulos comuns com funções bem definidas
- Comportamentos (behaviours)

Em resumo: macros são extremamente poderosas, mas seu uso deve ser reservado para situações onde a metaprogramação em tempo de compilação traz benefícios claros — como criação de DSLs, otimizações ou redução de boilerplate — e não como substituto para boa arquitetura de software.

Referências