Do you want to pick up from where you left of?
Take me there

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

Метапрограммирование — процесс написания кода с помощью другого кода. В Elixir это дает нам возможность расширять возможности языка для большего соответствия нуждам приложения. Мы начнем этот урок с рассмотрения внутреннего представления Elixir кода, а потом узнаем, как его модифицировать. И, в итоге, мы применим эти знания для создания собственных расширений языка.

Предупреждение: метапрограммирование это сложно и оно должно использоваться только, когда абсолютно необходимо Злоупотребление этим инструментом приведет к нечитабельности и сложностям в отладке кода.

Quote

Первый шаг к пониманию метапрограммирования — понимание, что представляют собой выражения. В Elixir внутреннее представление абстрактного синтаксического дерева (AST) состоит из кортежей. Эти кортежи состоят из трех частей: название функции, метаданные и аргументы функции.

Для того чтобы увидеть эти внутренние структуры, в Elixir есть функция quote/2. С ее помощью мы можем увидеть внутреннее представление Elixir кода:

iex> quote do: 42
42
iex> quote do: "Hello"
"Hello"
iex> quote do: :world
:world
iex> quote do: 1 + 2
{:+, [context: Elixir, import: Kernel], [1, 2]}
iex> quote do: if value, do: "True", else: "False"
{:if, [context: Elixir, import: Kernel],
 [{:value, [], Elixir}, [do: "True", else: "False"]]}

Заметили, что первые три не возвращают кортеж? Есть всего пять выражений, которые возвращают сами себя при использовании quote/2:

iex> :atom
:atom
iex> "string"
"string"
iex> 1 # Все числа
1
iex> [1, 2] # Списки
[1, 2]
iex> {"hello", :world} # кортеж из 2 элементов
{"hello", :world}

Unquote

Теперь мы можем получить внутреннюю структуру кода, но как ее изменить? Для вставки нового кода или значений мы используем unquote/1. Когда мы раскрываем выражение, оно будет выполнено и результат вставлен в AST. Для демонстрации этого давайте посмотрим на пару примеров:

iex> denominator = 2
2
iex> quote do: divide(42, denominator)
{:divide, [], [42, {:denominator, [], Elixir}]}
iex> quote do: divide(42, unquote(denominator))
{:divide, [], [42, 2]}

В первом примере переменная denominator экранирована, потому результирующий AST включает кортеж для доступа к переменной. Во втором примере в результирующем коде будет только её значение.

Макросы

Как только мы разобрались, как работают quote/2 и unquote/1, мы готовы к погружению в макросы. Важно помнить, что макросы, как и всё в метапрограммировании, должны использоваться очень осторожно.

В простейших терминах макросы — это специальные функции, созданные для возврата экранированных выражений, которые затем будут вставлены в код приложения. Представьте себе, что макрос заменяется значением, которое он вернет, а не будет вызван как функция. С макросами у нас есть все необходимое для расширения языка и динамического добавления кода в наше приложение.

Мы начнем с определения макроса с использованием defmacro/2, который в свою очередь (как и многое в Elixir), тоже является макросом. В качестве примера давайте имплементируем unless как макрос. Стоит помнить, что макрос должен вернуть экранированное значение:

defmodule OurMacro do
  defmacro unless(expr, do: block) do
    quote do
      if !unquote(expr), do: unquote(block)
    end
  end
end

Давайте подключим этот модуль и попробуем использовать этот макрос:

iex> require OurMacro
nil
iex> OurMacro.unless true, do: "Hi"
nil
iex> OurMacro.unless false, do: "Hi"
"Hi"

Так как макрос заменяет код в самом приложении, мы можем контролировать, что и когда компилируется. Отличным примером является модуль Logger. Когда логирование отключено, код не подключается, и результирующее приложение не содержит никаких отсылок к коду логирования. Этим Elixir отличается от других языков, в которых будут накладные расходы на вызовы пустых функций.

Для демонстрации этого давайте сделаем простой логгер, который может быть включен или выключен.

defmodule Logger do
  defmacro log(msg) do
    if Application.get_env(:logger, :enabled) do
      quote do
        IO.puts("Логируемое сообщение: #{unquote(msg)}")
      end
    end
  end
end

defmodule Example do
  require Logger

  def test do
    Logger.log("Это запись в журнале")
  end
end

Когда логирование включено, наша функция test сгенерирует приблизительно такой код:

def test do
  IO.puts("Logged message: #{"Это запись в журнале"}")
end

А когда логирование будет отключено - вот такой:

def test do
end

Отладка

Отлично, теперь мы знаем, как использовать quote/2, unquote/1 и писать макросы. Но что, если у вас есть огромный кусок AST, и вы хотите в нём разобраться? В таком случае вы можете использовать Macro.to_string/2. Рассмотрим следующий пример:

iex> Macro.to_string(quote(do: foo.bar(1, 2, 3)))
"foo.bar(1, 2, 3)"

А когда вы хотите посмотреть на код, сгенерированный макросами, можете сочетать их с Macro.expand/2 и Macro.expand_once/2. Эти функции расширяют макросы в соответствующий им код. Первая функция может расширять макрос несколько раз, а вторая — только один. К примеру, давайте изменим пример с unless из предыдущей секции:

defmodule OurMacro do
  defmacro unless(expr, do: block) do
    quote do
      if !unquote(expr), do: unquote(block)
    end
  end
end

require OurMacro

quoted =
  quote do
    OurMacro.unless(true, do: "Hi")
  end
iex> quoted |> Macro.expand_once(__ENV__) |> Macro.to_string |> IO.puts
if(!true) do
  "Hi"
end

Если запустить тот же код с Macro.expand/2, результат интригует:

iex> quoted |> Macro.expand(__ENV__) |> Macro.to_string |> IO.puts
case(!true) do
  x when x in [false, nil] ->
    nil
  _ ->
    "Hi"
end

Помните, ранее мы упоминали if? В Elixir это макрос, а мы расширили его в соответствующее выражение case.

Закрытые макросы

Хоть это и нечастый случай, Elixir поддерживает закрытые макросы. Закрытый макрос определяется вызовом defmacrop и сможет быть вызван только из того модуля, в котором он был определен. Закрытые макросы должны быть определены раньше, чем будут вызваны кодом.

Гигиена при использовании макросов

Взаимодействие макросов с контекстом вызова называется гигиеной макросов. По умолчанию макрос в Elixir гигиеничен и не будет конфликтовать с контекстом:

defmodule Example do
  defmacro hygienic do
    quote do: val = -1
  end
end

iex> require Example
nil
iex> val = 42
42
iex> Example.hygienic
-1
iex> val
42

Но что, если мы хотим изменять значение val? Для того чтобы обозначить переменную негигиеничной, мы можем использовать var!/2. Давайте изменим пример с использованием этого трюка:

defmodule Example do
  defmacro hygienic do
    quote do: val = -1
  end

  defmacro unhygienic do
    quote do: var!(val) = -1
  end
end

И посмотрим, как он теперь взаимодействует с контекстом:

iex> require Example
nil
iex> val = 42
42
iex> Example.hygienic
-1
iex> val
42
iex> Example.unhygienic
-1
iex> val
-1

Включая var!/2в макрос, мы изменили значение val, не передавая его внутрь макроса. Использование таких макросов должно быть минимальным. Используя var!/2, мы увеличиваем вероятность конфликта имен.

Контекст

Мы уже рассмотрели полезность unquote/1, но есть еще один нюанс при включении значений в наш код: контекст. С контекстом переменных мы можем включить один код несколько раз внутри макроса и убедиться, что он выполнится только один раз, препятствуя повторным выполнениям. Для использования этой возможности мы должны передать названия таких переменных в опцию bind_quoted функции quote/2 в виде ключевого списка.

Для того чтобы увидеть пользу от bind_quote и показать проблему двойной распаковки, давайте рассмотрим этот пример: Начнём с создания макроса, который просто выводит выражение дважды:

defmodule Example do
  defmacro double_puts(expr) do
    quote do
      IO.puts(unquote(expr))
      IO.puts(unquote(expr))
    end
  end
end

Попробуем наш новый макрос, передав ему функцию текущего системного времени. Мы ожидаем, что она выведет значение дважды:

iex> Example.double_puts(:os.system_time)
1450475941851668000
1450475941851733000

Время отличается! Что произошло? Использование unquote/1 на одном и том же выражении несколько раз приводит к выполнению этого кода несколько раз, что может обернуться неприятными последствиями. Давайте обновим наш код с использованием bind_quoted и посмотрим, что получится:

defmodule Example do
  defmacro double_puts(expr) do
    quote bind_quoted: [expr: expr] do
      IO.puts(expr)
      IO.puts(expr)
    end
  end
end

iex> require Example
nil
iex> Example.double_puts(:os.system_time)
1450476083466500000
1450476083466500000

С bind_quoted мы получаем ожидаемый результат: одно и то же время выведено дважды.

Теперь, когда мы познакомились с quote/2, unquote/1, и defmacro/2, у нас есть все необходимые инструменты для расширения языка Elixir под наши нужды.

Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!