Метапрограммирование — это мощный, но довольно сложный метод, который означает, что программа может анализировать или даже изменять себя во время выполнения. Многие современные языки поддерживают эту функцию, и Elixir не является исключением.
С помощью метапрограммирования вы можете создавать новые сложные макросы, динамически определять и откладывать выполнение кода, что позволяет писать более сжатый и мощный код. Это действительно сложная тема, но, надеюсь, после прочтения этой статьи вы получите общее представление о том, как начать метапрограммирование в Elixir.
В этой статье вы узнаете:
- Что такое абстрактное синтаксическое дерево и как код Elixir представлен под капотом.
- Что такое функции
quote
иunquote
- Что такое макросы и как с ними работать.
- Как ввести значения с привязкой.
- Почему макросы гигиеничны.
Прежде чем начать, позвольте мне дать вам небольшой совет. Помните, дядя Человека-паука сказал: «С великой силой приходит большая ответственность»? Это может быть применено и к метапрограммированию, потому что это очень мощная функция, которая позволяет вам крутить и сгибать код по своему желанию.
Тем не менее, вы не должны злоупотреблять этим, и вы должны придерживаться более простых решений, когда это разумно и возможно. Слишком много метапрограммирования может усложнить понимание и поддержку вашего кода, поэтому будьте осторожны с этим.
Абстрактное синтаксическое дерево и цитата
Первое, что нам нужно понять, это то, как на самом деле представлен наш код Elixir. Эти представления часто называют деревьями абстрактного синтаксиса (AST), но официальное руководство по Elixir рекомендует называть их просто кавычками .
Похоже, что выражения приходят в виде кортежей с тремя элементами. Но как мы можем доказать это? Ну, есть функция с именем quote
которая возвращает представление для некоторого заданного кода. По сути, это превращает код в неоцененную форму . Например:
1
2
3
|
quote do
1 + 2
end # => {:+, [context: Elixir, import: Kernel], [1, 2]}
|
Так что здесь происходит? Кортеж, возвращаемый функцией quote
всегда имеет следующие три элемента:
- Атом или другой кортеж с таким же представлением. В данном случае это атом
:+
, означающий, что мы выполняем сложение. Кстати, эта форма записи должна быть знакома, если вы пришли из мира Ruby. - Список ключевых слов с метаданными. В этом примере мы видим, что модуль
Kernel
был импортирован для нас автоматически. - Список аргументов или атом. В данном случае это список с аргументами
1
и2
.
Конечно, представление может быть гораздо более сложным:
1
2
3
4
5
6
7
|
quote do
Enum.each([1,2,3], &(IO.puts(&1)))
end # => {{:., [], [{:__aliases__, [alias: false], [:Enum]}, :each]}, [],
# [[1, 2, 3],
# {:&, [],
# [{{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [],
# [{:&, [], [1]}]}]}]}
|
С другой стороны, некоторые литералы возвращаются при цитировании, а именно:
- атомы
- целые
- поплавки
- списки
- строки
- кортежи (но только с двумя элементами!)
В следующем примере мы видим, что цитирование атома возвращает этот атом обратно:
1
2
3
|
quote do
:hi
end # => :hi
|
Теперь, когда мы знаем, как код представлен внутри, давайте перейдем к следующему разделу и посмотрим, что такое макросы и почему важны выражения в кавычках.
макрос
Макросы — это специальные формы, подобные функциям, но те, которые возвращают код в кавычках. Этот код затем помещается в приложение, и его выполнение откладывается. Что интересно, макросы также не оценивают передаваемые им параметры — они также представлены в виде выражений в кавычках. Макросы можно использовать для создания пользовательских сложных функций, используемых в вашем проекте.
Имейте в виду, однако, что макросы являются более сложными, чем обычные функции, и в официальном руководстве говорится, что их следует использовать только в качестве крайней меры. Другими словами, если вы можете использовать функцию, не создавайте макрос, потому что таким образом ваш код становится излишне сложным и, по сути, сложным в обслуживании. Тем не менее, у макросов есть свои варианты использования, поэтому давайте посмотрим, как их создать.
Все начинается с вызова defmacro
(на самом деле это сам макрос):
1
2
3
4
5
|
defmodule MyLib do
defmacro test(arg) do
arg |> IO.inspect
end
end
|
Этот макрос просто принимает аргумент и печатает его.
Также стоит упомянуть, что макросы могут быть приватными, как и функции. Приватные макросы могут вызываться только из модуля, в котором они были определены. Чтобы определить такой макрос, используйте defmacrop
.
Теперь давайте создадим отдельный модуль, который будет использоваться в качестве нашей игровой площадки:
1
2
3
4
5
6
7
8
9
|
defmodule Main do
require MyLib
def start!
MyLib.test({1,2,3})
end
end
Main.start!
|
Когда вы запустите этот код, {:{}, [line: 11], [1, 2, 3]}
будут распечатаны, что действительно означает, что аргумент имеет форму в кавычках (неоцененную). Прежде чем продолжить, позвольте мне сделать небольшую заметку.
требовать
Почему в мире мы создали два отдельных модуля: один для определения макроса и другой для запуска примера кода? Похоже, что мы должны сделать это таким образом, потому что макросы обрабатываются до выполнения программы. Мы также должны убедиться, что определенный макрос доступен в модуле, и это делается с помощью require
. Эта функция, в основном, гарантирует, что данный модуль скомпилирован перед текущим.
Вы можете спросить, почему мы не можем избавиться от основного модуля? Давайте попробуем сделать это:
01
02
03
04
05
06
07
08
09
10
11
12
|
defmodule MyLib do
defmacro test(arg) do
arg |> IO.inspect
end
end
MyLib.test({1,2,3})
# => ** (UndefinedFunctionError) function MyLib.test/1 is undefined or private.
# MyLib.test({1, 2, 3})
# (elixir) lib/code.ex:376: Code.require_file/2
|
К сожалению, мы получаем сообщение о том, что тест функции не найден, хотя существует макрос с тем же именем. Это происходит потому, что модуль MyLib
определен в той же области (и в том же файле), где мы пытаемся его использовать. Это может показаться немного странным, но пока просто помните, что для избежания таких ситуаций необходимо создать отдельный модуль.
Также обратите внимание, что макросы нельзя использовать глобально: сначала вы должны импортировать или требовать соответствующий модуль.
Макросы и цитируемые выражения
Итак, мы знаем, как выражения Elixir представлены внутри и какие макросы … Что теперь? Что ж, теперь мы можем использовать эти знания и посмотреть, как можно оценить цитируемый код.
Вернемся к нашим макросам. Важно знать, что последним выражением любого макроса, как ожидается, будет код в кавычках, который будет выполняться и возвращаться автоматически при вызове макроса. Мы можем переписать пример из предыдущего раздела, переместив IO.inspect
в модуль Main
:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
|
defmodule MyLib do
defmacro test(arg) do
arg
end
end
defmodule Main do
require MyLib
def start!
MyLib.test({1,2,3}) |> IO.inspect
end
end
Main.start!
|
Видишь что происходит? Кортеж, возвращенный макросом, не указан, но оценен! Вы можете попробовать добавить два целых числа:
1
|
MyLib.test(1 + 2) |> IO.inspect # => 3
|
Еще раз, код был выполнен, и 3
было возвращено. Мы можем даже попытаться использовать функцию quote
напрямую, и последняя строка все равно будет оценена:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
|
defmodule MyLib do
defmacro test(arg) do
arg |> IO.inspect
quote do
{1,2,3}
end
end
end
# …
def start!
MyLib.test(1 + 2) |> IO.inspect
# => {:+, [line: 14], [1, 2]}
# {1, 2, 3}
end
|
arg
был заключен в кавычки (обратите внимание, кстати, что мы можем даже видеть номер строки, где был вызван макрос), но выражение в кавычках с кортежем {1,2,3}
было оценено для нас, так как это последняя строка макроса.
Мы можем попытаться использовать arg
в математическом выражении:
1
2
3
4
5
|
defmacro test(arg) do
quote do
arg + 1
end
end
|
Но это вызовет ошибку о том, что arg
не существует. Почему так? Это потому, что arg
буквально вставляется в строку, которую мы цитируем. Но вместо этого мы хотели бы оценить arg
, вставить результат в строку, а затем выполнить кавычки. Для этого нам понадобится еще одна функция, которая называется unquote
.
Отмена цитирования кода
unquote
— это функция, которая внедряет результат оценки кода в код, который затем будет заключен в кавычки. Это может звучать немного странно, но на самом деле все довольно просто. Давайте настроим предыдущий пример кода:
1
2
3
4
5
|
defmacro test(arg) do
quote do
unquote(arg) + 1
end
end
|
Теперь наша программа вернет 4
, что мы и хотели! Что происходит, так это то, что код, переданный функции unquote
запускается только при выполнении кода в кавычках, а не при его первоначальном анализе.
Давайте посмотрим немного более сложный пример. Предположим, мы хотели бы создать функцию, которая запускает некоторое выражение, если данная строка является палиндромом . Мы могли бы написать что-то вроде этого:
1
2
3
|
def if_palindrome_f?(str, expr) do
if str == String.reverse(str), do: expr
end
|
Суффикс _f
здесь означает, что это функция, так как позже мы создадим похожий макрос. Однако, если мы попытаемся запустить эту функцию сейчас, текст будет напечатан, даже если строка не является палиндромом:
1
2
3
|
def start!
MyLib.if_palindrome_f?(«745», IO.puts(«yes»)) # => «yes»
end
|
Аргументы, переданные функции, оцениваются до ее фактического вызова, поэтому мы видим строку "yes"
выведенную на экран. Это действительно не то, чего мы хотим достичь, поэтому давайте попробуем использовать макрос:
01
02
03
04
05
06
07
08
09
10
11
|
defmacro if_palindrome?(str, expr) do
quote do
if(unquote(str) == String.reverse( unquote(str) )) do
unquote(expr)
end
end
end
# …
MyLib.if_palindrome?(«745», IO.puts(«yes»))
|
Здесь мы цитируем код, содержащий условие if
и используем внутри unquote
чтобы оценить значения аргументов, когда макрос вызывается. В этом примере на экран ничего не выводится, что правильно!
Внедрение значений с привязками
Использование unquote
— не единственный способ unquote
код в цитируемый блок. Мы также можем использовать функцию под названием привязка . На самом деле, это просто опция, передаваемая в функцию quote
которая принимает список ключевых слов со всеми переменными, которые следует заключать в кавычки только один раз .
Чтобы выполнить связывание, передайте bind_quoted
в функцию quote
следующим образом:
1
2
|
quote bind_quoted: [expr: expr] do
end
|
Это может пригодиться, если вы хотите, чтобы выражение, используемое в нескольких местах, оценивалось только один раз. Как показывает этот пример , мы можем создать простой макрос, который выводит строку дважды с задержкой в две секунды:
1
2
3
4
5
6
7
8
9
|
defmodule MyLib do
defmacro test(arg) do
quote bind_quoted: [arg: arg] do
arg |> IO.inspect
Process.sleep 2000
arg |> IO.inspect
end
end
end
|
Теперь, если вы вызовете его, передав системное время, две строки будут иметь одинаковый результат:
1
2
3
|
:os.system_time |> MyLib.test
# => 1547457831862272
# => 1547457831862272
|
Это не относится к unquote
, потому что аргумент будет оцениваться дважды с небольшой задержкой, поэтому результаты не совпадают:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
|
defmacro test(arg) do
quote do
unquote(arg) |> IO.inspect
Process.sleep(2000)
unquote(arg) |> IO.inspect
end
end
# …
def start!
:os.system_time |> MyLib.test
# => 1547457934011392
# => 1547457936059392
end
|
Преобразование цитируемого кода
Иногда вы можете захотеть понять, как на самом деле выглядит ваш цитируемый код, например, для его отладки. Это можно сделать с помощью функции to_string
:
01
02
03
04
05
06
07
08
09
10
|
defmacro if_palindrome?(str, expr) do
quoted = quote do
if(unquote(str) == String.reverse( unquote(str) )) do
unquote(expr)
end
end
quoted |> Macro.to_string |> IO.inspect
quoted
end
|
Напечатанная строка будет:
1
|
«if(\»745\» == String.reverse(\»745\»)) do\n IO.puts(\»yes\»)\nend»
|
Мы можем видеть, что данный аргумент str
был оценен, и результат был вставлен прямо в код. \n
здесь означает «новая строка».
Кроме того, мы можем расширить код в кавычках с помощью expand_once
и expand
:
1
2
3
4
5
6
|
def start!
quoted = quote do
MyLib.if_palindrome?(«745», IO.puts(«yes»))
end
quoted |> Macro.expand_once(__ENV__) |> IO.inspect
end
|
Который производит:
1
2
3
4
5
6
7
8
9
|
{:if, [context: MyLib, import: Kernel],
[{:==, [context: MyLib, import: Kernel],
[«745»,
{{:., [],
[{:__aliases__, [alias: false, counter: -576460752303423103], [:String]},
:reverse]}, [], [«745»]}]},
[do: {{:., [],
[{:__aliases__, [alias: false, counter: -576460752303423103], [:IO]},
:puts]}, [], [«yes»]}]]}
|
Конечно, это цитируемое представление может быть возвращено в строку:
1
|
quoted |> Macro.expand_once(__ENV__) |> Macro.to_string |> IO.inspect
|
Мы получим тот же результат, что и раньше:
1
|
«if(\»745\» == String.reverse(\»745\»)) do\n IO.puts(\»yes\»)\nend»
|
Функция expand
является более сложной, поскольку она пытается развернуть каждый макрос в данном коде:
1
|
quoted |> Macro.expand(__ENV__) |> Macro.to_string |> IO.inspect
|
Результат будет:
1
2
|
«case(\»745\» == String.reverse(\»745\»)) do\nx when x in [false, nil] ->\n nil\n _ ->\n
IO.puts(\»yes\»)\nend»
|
Мы видим этот вывод, потому if
на самом деле if
— это сам макрос, который опирается на оператор case
, поэтому он тоже расширяется.
В этих примерах __ENV__
— это специальная форма, которая возвращает информацию о среде, такую как текущий модуль, файл, строку, переменную в текущей области и импорт.
Макросы гигиеничны
Возможно, вы слышали, что макросы на самом деле гигиеничны . Это означает, что они не перезаписывают никакие переменные за пределами своей области видимости. Чтобы доказать это, давайте добавим пример переменной, попробуйте изменить ее значение в разных местах, а затем вывести ее:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
|
defmacro if_palindrome?(str, expr) do
other_var = «if_palindrome?»
quoted = quote do
other_var = «quoted»
if(unquote(str) == String.reverse( unquote(str) )) do
unquote(expr)
end
other_var |> IO.inspect
end
other_var |> IO.inspect
quoted
end
# …
def start!
other_var = «start!»
MyLib.if_palindrome?(«745», IO.puts(«yes»))
other_var |> IO.inspect
end
|
Таким образом, other_var
получил значение в start!
функция внутри макроса и внутри quote
. Вы увидите следующий вывод:
1
2
3
|
«if_palindrome?»
«quoted»
«start!»
|
Это означает, что наши переменные независимы, и мы не вводим никаких конфликтов, используя везде одно и то же имя (хотя, конечно, было бы лучше избегать такого подхода).
Если вам действительно нужно изменить внешнюю переменную из макроса, вы можете использовать var!
как это:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
|
defmacro if_palindrome?(str, expr) do
quoted = quote do
var!(other_var) = «quoted»
if(unquote(str) == String.reverse( unquote(str) )) do
unquote(expr)
end
end
quoted
end
# …
def start!
other_var = «start!»
MyLib.if_palindrome?(«745», IO.puts(«yes»))
other_var |> IO.inspect # => «quoted»
end
|
Используя var!
Мы фактически говорим, что данная переменная не должна быть гигиенической. Однако будьте очень осторожны при использовании этого подхода, так как вы можете потерять отслеживание того, что и где перезаписывается.
Вывод
В этой статье мы обсудили основы метапрограммирования на языке Elixir. Мы рассмотрели использование quote
, unquote
, макросов и привязок, рассматривая некоторые примеры и варианты использования. На данный момент вы готовы применить эти знания на практике и создавать более сжатые и мощные программы. Помните, однако, что обычно лучше иметь понятный код, чем сжатый код, поэтому не злоупотребляйте метапрограммированием в своих проектах.
Если вы хотите узнать больше о функциях, которые я описал, не стесняйтесь читать официальное руководство «Приступая к работе» о макросах , цитировать и удалять цитаты . Я действительно надеюсь, что эта статья дала вам хорошее введение в метапрограммирование в Elixir, которое на первый взгляд может показаться довольно сложным. В любом случае, не бойтесь экспериментировать с этими новыми инструментами!
Я благодарю вас за то, что вы остались со мной, и до скорой встречи.