Статьи

Основы метапрограммирования эликсира

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

С помощью метапрограммирования вы можете создавать новые сложные макросы, динамически определять и откладывать выполнение кода, что позволяет писать более сжатый и мощный код. Это действительно сложная тема, но, надеюсь, после прочтения этой статьи вы получите общее представление о том, как начать метапрограммирование в Elixir.

В этой статье вы узнаете:

  • Что такое абстрактное синтаксическое дерево и как код Elixir представлен под капотом.
  • Что такое функции quote и unquote
  • Что такое макросы и как с ними работать.
  • Как ввести значения с привязкой.
  • Почему макросы гигиеничны.

Прежде чем начать, позвольте мне дать вам небольшой совет. Помните, дядя Человека-паука сказал: «С великой силой приходит большая ответственность»? Это может быть применено и к метапрограммированию, потому что это очень мощная функция, которая позволяет вам крутить и сгибать код по своему желанию.

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

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

Похоже, что выражения приходят в виде кортежей с тремя элементами. Но как мы можем доказать это? Ну, есть функция с именем quote которая возвращает представление для некоторого заданного кода. По сути, это превращает код в неоцененную форму . Например:

1
2
3
quote do
  1 + 2
end # => {:+, [context: Elixir, import: Kernel], [1, 2]}

Так что здесь происходит? Кортеж, возвращаемый функцией quote всегда имеет следующие три элемента:

  1. Атом или другой кортеж с таким же представлением. В данном случае это атом :+ , означающий, что мы выполняем сложение. Кстати, эта форма записи должна быть знакома, если вы пришли из мира Ruby.
  2. Список ключевых слов с метаданными. В этом примере мы видим, что модуль Kernel был импортирован для нас автоматически.
  3. Список аргументов или атом. В данном случае это список с аргументами 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, которое на первый взгляд может показаться довольно сложным. В любом случае, не бойтесь экспериментировать с этими новыми инструментами!

Я благодарю вас за то, что вы остались со мной, и до скорой встречи.