Метапрограммирование
Метапрограммирование — процесс написания кода с помощью другого кода. В 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_quoted
и показать проблему двойной распаковки, давайте рассмотрим этот пример:
Начнём с создания макроса, который просто выводит выражение дважды:
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!