Метапрограммирование в Elixir


Роман Смирнов

Системный архитектор @Convead

romul

Что такое метапрограммирование?

Создание программ, которые порождают другие программы как результат своей работы (на стадии компиляции), либо программ, которые меняют себя во время выполнения (самомодифицирующийся код).

© Wikipedia

Какие есть виды метапрограммирования?

  • Препроцессорные директивы и макросы (C, C++)
  • Рефлексия (C#, Java)
  • Динамическая модификация кода (Ruby, Python)
  • Макросы, манипулирующие AST (Lisp, Elixir)

AST - Abstract Syntax Tree

Конечное, ориентированное дерево, в котором внутренние вершины сопоставлены с операторами ЯП, а листья — с соответствующими операндами.

© Wikipedia

b*b - 4*a*c

Зачем нужны AST-макросы?

  • Автогерация boilerplate-кода
  • Расширение выразительных возможностей языка
  • Создание быстрых DSL

Синтаксис Elixir построен на макросах

  • defmodule / def / defp
  • defstruct / defrecord
  • defprotocol / defimpl
  • defoverridable / defdelegate
  • if / unless
  • with / for
  • alias / require / import
  • etc.

Ещё примеры макросов

  • Превосходная поддержка Unicode в Elixir
  • ExUnit
  • Ecto.Query
  • Router DSL в Phoenix
  • etc.

Вспомогательные макросы

  • quote преобразует выражение в AST
     
  • unquote вставляет значение внутрь quote-блока
     
  • unquote_splicing вставляет список как набор значений внутрь quote-блока

quote

AST-представление выражения в Elixir - это кортеж.

Простое выражение:

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

И чуть посложнее:

iex(2)> quote do 
...(2)>   if age >= 18 do
...(2)>     :ok
...(2)>   else
...(2)>     :error
...(2)>   end  
...(2)> end
{:if, [context: Elixir, import: Kernel],
 [{:>=, [context: Elixir, import: Kernel], [{:age, [], Elixir}, 18]},
  [do: :ok, else: :error]]}

unquote

iex(1)> a = 7
iex(2)> ast = quote do 
...(2)>   a * 6
...(2)> end
{:*, [context: Elixir, import: Kernel], [{:a, [], Elixir}, 6]}
iex(3)> Code.eval_quoted(ast)
** (CompileError) nofile:1: undefined function a/0
iex(1)> a = 7
iex(2)> ast = quote do 
...(2)>   unquote(a) * 6
...(2)> end
{:*, [context: Elixir, import: Kernel], [7, 6]}
iex(3)> Code.eval_quoted(ast)
{42, []}
iex(1)> a = 7
iex(2)> "#{a} * 6"
"7 * 6"

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
    (elixir) unicode/unicode.ex:428: String.Break.split(["a,b,c", ","])
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"], []}
defmacro list_to_tuple(list) do
  quote do
    { unquote_splicing(list) }
  end
end
list_to_tuple([1, 2, 3]) # => {1, 2, 3}

__using__

defmodule MyApp.Company do
  use MyApp.Web, :model  # invokes __using__ hook
  # ...
end
# web/web.ex
defmodule MyApp.Web
  defmacro __using__(which) when is_atom(which) do
    apply(__MODULE__, which, [])
  end

  def model do
    quote do
      use Ecto.Schema

      import Ecto
      import Ecto.Changeset
      import Ecto.Query
    end
  end
  # ...
end

Основные приёмы


  1. Code Interpolation
    вставляем фрагменты кода в другой код

  2. AST Transformation
    трансформируем одно дерево в другое

  3. Macro Substitution
    заменяем определенные ветки или листья в AST

Задача: выборочный профайлинг кода

Ожидаемый результат:

def super_function do
  # some code
  # ...
  newrelic_trace("PageController.index", :other, "super_function") do
    # потенциально медленный код
  end
end

Code Interpolation

defmacro newrelic_trace(transaction, category, segment_name, do: block) do
  quote do
    # вычисляем реальное значение блока и время его исполнения
    {elapsed, result} = :timer.tc fn -> unquote(block) end
    data = {unquote(category), unquote(segment_name)}
    NewRelic.Collector.record_value({unquote(transaction), data}, elapsed)
    # возвращаем значение исходного блока
    result
  end
end

Задача: новый синтаксис для лямбда-функций

Примеры: список квадратов и сумма квадратов

Enum.map(1..5, fn(x) -> x*x end) # => [1, 4, 9, 16, 25]
Enum.reduce(1..5, 0, fn(x, acc) -> acc+x*x end) # => 55

как-то покороче

Enum.map(1..5, &(&1*&1)) # => [1, 4, 9, 16, 25]
Enum.reduce(1..5, 0, &(&2+&1*&1)) # => 55

a что если так?

Enum.map(1..5, x ~> x*x) # => [1, 4, 9, 16, 25]
Enum.reduce(1..5, 0, {x, acc} ~> acc+x*x) # => 55

AST Transformation

defmacro args ~> body do
  args = case args do
    # {x} ~> x+1
    {:{}, _, arg} -> arg
    # x ~> x+1
    {var, _, nil} when is_atom(var) -> [args]
    # {x, y} ~> x+y
    _ -> Tuple.to_list(args)
  end
  {:fn, [], [{:->, [], [args, body]}]}
end

Задача: DSL для фрагментов HTML

Ожидаемый результат:

html_fragment do
  h1 do: "Header"
  div do
    p do: "foo"
    br
    p do: "bar"
  end
end
# => "<h1>Header</h1><div><p>foo</p><br /><p>bar</p></div>"

Общая функция

def tag(name, self_closing, do: content) do
  if self_closing do
    "<#{name} />"  
  else
    "<#{name}>#{content}</#{name}>"  
  end
end

Сам макрос

defmacro html_fragment(do: ast) do
  new_ast = Macro.prewalk(ast, &substitute_html_elements/1)
  quote do
    to_string(unquote(new_ast))
  end
end

Не хватает немного магии

defp substitute_html_elements({:__block__, _, elems}) do
  elems
end
defp substitute_html_elements({tag_name, meta, args}) do
  cond do
    tag_name in [:h1, :p, :div, :table, :tr, :td] ->
      {:tag, meta, [tag_name, false | args]}
    tag_name in [:br, :hr] ->
      {:tag, meta, [tag_name, true, [do: nil]]}
    true ->
      {tag_name, meta, args}
  end
end
defp substitute_html_elements(other) do
  other
end

Полный код примера: https://github.com/romul/html_fragments

Мы рассмотрели

  • Основы работы с макросами в Elixir
  • Трансформацию AST-представления
  • Создание собственного DSL
  • На примерах убедились, что писать макросы достаточно просто даже для нетривиальных задач

Что ещё изучить?

  • Отладка и тестирование макросов
  • Работа с атрибутами модуля
  • Гигиена макросов
  • Compile-time hooks

Warning

Большие возможности - это и большая ответственность.

Не пишите макросы там, где можно обойтись обычными функциями.

Спасибо за внимание!