Макросы

Введение

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

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

Наш первый макрос

Макросы в Эликсире определяются через defmacro/2.

В этой главе мы будет использовать файлы вместо запуска примеров кода в IEx. Всё потому, что эти примеры будут в несколько строк кода, поэтому их ввод в IEx будет непродуктивным. Следовательно, вы должны иметь возможность проверять эти примеры, записывая их в файл macros.exs, и запускать с помощью команд elixir macros.exs или iex macros.exs.

Чтобы лучше понять, как работают макросы, давайте сперва создадим новый модуль, в котором реализуем условный оператор unless, который делает противоположный if, как макросом, так и функцией:

defmodule Unless do
  def fun_unless(clause, do: expression) do
    if(!clause, do: expression)
  end

  defmacro macro_unless(clause, do: expression) do
    quote do
      if(!unquote(clause), do: unquote(expression))
    end
  end
end

Функция получает аргументы и передаёт их в if. Однако, как мы узнали в предыдущей главе, макрос будет получать маскирующие выражения, вводить их в конструкцию quote и, в конце, возвращать другое маскирующее выражение.

Давайте начнём с iex, используя модуль выше:

$ iex macros.exs

И поиграем с теми определениями:

iex> require Unless

iex> Unless.macro_unless true, do: IO.puts "this should never be printed"
nil

iex> Unless.fun_unless true, do: IO.puts "this should never be printed"
"this should never be printed"
nil

Обратите внимание на то, что в нашей реализации макроса предложение не было возвращено, несмотря на то, что оно вывелось при реализации нашей функции. Это обусловлено тем, что аргументы вызова функции вычисляются непосредственно перед вызовом самой функции. Однако макросы не обращают внимание на свои отдельно взятые аргументы. Вместо этого, они получают аргументы в виде маскирующих выражений, которые затем преобразуются в другие маскирующие выражения. Таким образом, нам придётся переписать наш оператор unless в макросе на скрытый if.

Другими словами, при вызове:

Unless.macro_unless true, do: IO.puts "this should never be printed"

Наш макрос macro_unless получил следующее:

macro_unless(true, [do: {{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [], ["this should never be printed"]}])

И затем он вернул маскирующее выражение следующим образом:

{:if, [],
 [{:!, [], [true]},
  [do: {{:., [],
     [{:__aliases__,
       [], [:IO]},
      :puts]}, [], ["this should never be printed"]}]]}

На самом деле, мы можем проверить это, используя функцию Macro.expand_once/2:

iex> expr = quote do: Unless.macro_unless(true, do: IO.puts "this should never be printed")
iex> res  = Macro.expand_once(expr, __ENV__)

iex> IO.puts Macro.to_string(res)
if(!true) do
  IO.puts("this should never be printed")
end
:ok

Функция Macro.expand_once/2 получает маскирующее выражение и расширяет его в соответствии с текущей средой. В этом случае он расширил/вызвал макрос Unless.macro_unless/2 и вернул его результат. Затем мы перешли к преобразованию возвращаемого маскирующего выражения в строку и напечатали его (мы затронем __ENV__ чуть позже в этой главе).

Вот что такое макросы. Они про получение маскирующих выражений и преобразование их во что-то иное. Фактически, конструкция unless/2 в Эликсире реализуется как макрос:

defmacro unless(clause, do: expression) do
  quote do
    if(!unquote(clause), do: unquote(expression))
  end
end

Конструкции, вроде unless/2, defmacro/2, def/2, defprotocol/2 и многие другие, использующиеся в этом руководстве с самого начала, реализованы на чистом Эликсире, часто в качестве макроса. Это означает, что конструкторы, использующиеся для создания языка, могут использоваться разработчиками для расширения предметно-ориентированного языка, над которым они работают.

Мы может определить любые необходимые нам функции и макросы, в том числе и те, которые переопределяют встроенные определения, предоставляемые самим Эликсиром. Единственным исключением являются специальные формы Эликсира, которые не реализованы в нём и поэтому не могут быть переопределёнными априори полный список специальных форм доступен в модуле Kernel.SpecialForms.

Гигиена макросов

Макросы в Эликсире выполняются в последнюю очередь. Это гарантирует переменным, определённым внутри quote то, что они не будут конфликтовать с переменными, определёнными в контексте, где этот макрос расширяется. Например:

defmodule Hygiene do
  defmacro no_interference do
    quote do: a = 1
  end
end

defmodule HygieneTest do
  def go do
    require Hygiene
    a = 13
    Hygiene.no_interference
    a
  end
end

HygieneTest.go
# => 13

В приведенном выше примере, несмотря на то, что макрос вводит переменной значение a = 1, он никаким образом не влияет на переменную a, определённую функцией go. Если макрос хочет явно повлиять на контекст, он может воспользоваться var!:

defmodule Hygiene do
  defmacro interference do
    quote do: var!(a) = 1
  end
end

defmodule HygieneTest do
  def go do
    require Hygiene
    a = 13
    Hygiene.interference
    a
  end
end

HygieneTest.go
# => 1

Переменная гигиена работает только лишь потому, что Эликсир комментирует переменные с их контекстом. Например, переменная x, определённая на 3 строке модуля, будет представлена как:

{:x, [line: 3], nil}

Несмотря на это, маскирующая переменная представлена как:

defmodule Sample do
  def quoted do
    quote do: x
  end
end

Sample.quoted #=> {:x, [line: 3], Sample}

Обратите внимание, что третьим элементом в маскирующей переменной является атом Sample, вместо nil, который отмечает переменную как поступающую из модуля Sample. Именно поэтому Эликсир рассматривает эти две переменные как исходящие из разных контекстов и, следовательно, обрабатывает их соответственно.

Эликсир предоставляет аналогичные механизмы для импортов и псевдонимов. Это гарантирует, что макрос будет вести себя так, как указано в его исходном модуле, не противореча заданному модулю, в котором макрос расширяется. При этом, гигиена может быть обойдена в определённых ситуациях, если использовать макросы, такие как var!/2 и alias!/2, несмотря на то, что при их использовании необходимо соблюдать осторожность при явном изменении пользовательской среды.

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

defmodule Sample do
  defmacro initialize_to_char_count(variables) do
    Enum.map variables, fn(name) ->
      var = Macro.var(name, nil)
      length = name |> Atom.to_string |> String.length
      quote do
        unquote(var) = unquote(length)
      end
    end
  end

  def run do
    initialize_to_char_count [:red, :green, :yellow]
    [red, green, yellow]
  end
end

> Sample.run #=> [3, 5, 6]

Обратите внимание на второй аргумент Macro.var/2. Эта ситуация использует и определяет гигиену, что описывается в следующем разделе.

Окружающая среда

При вызове Macro.expand_once/2 ранее в этой главе, мы использовали специальную форму __ENV__.

__ENV__ возвращает экземпляр структуры Macro.Env, который содержит полезную информацию о среде компиляции, включая текущий модуль, файл и строку, все переменные, определённые в текущей области, а также импорты, требования и т. д.:

iex> __ENV__.module
nil

iex> __ENV__.file
"iex"

iex> __ENV__.requires
[IEx.Helpers, Kernel, Kernel.Typespec]

iex> require Integer
nil

iex> __ENV__.requires
[IEx.Helpers, Integer, Kernel, Kernel.Typespec]

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

Приватные макросы

Эликсир также поддерживает приватные макросы через defmacrop. В качестве приватных функций эти макросы доступны только в модуле, который их определяет, и только во время компиляции.

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

iex> defmodule Sample do
...>  def four, do: two + two
...>  defmacrop two, do: 2
...> end
** (CompileError) iex:2: function two/0 undefined

Пишите макросы ответственно

Макросы являются мощной конструкцией, и Эликсир предоставляет множество механизмов для их ответственного использования.

  • Макросы гигиеничны: по умолчанию, переменные, определённые внутри макроса, не будут влиять на код пользователя. Кроме того, вызовы функций и псевдонимы, доступные в контексте макросов, не будут протекать в контексте пользователя.

  • Макросы лексические: невозможно вводить код или макросы глобально. Чтобы использовать макрос, вам необходимо явно задать require или import модулю, который определяет макрос.

  • Макросы явны: невозможно запустить макрос без его явного вызова. Например, некоторые языки позволяют разработчикам полностью переписывать функции скрыто, часто с помощью синтаксических преобразований или с помощью некоторых механизмов рефлексии. В Эликсире макрос должен быть явно вызван в вызывающем макросе во время компиляции.

  • Язык макросов ясен: многие языки предоставляют синтаксический сахар для quote и unquote. В Эликсире же мы предпочли, чтобы они были изложены явным образом, дабы чётко разграничить определение макроса и его маскирующие выражения.

Даже при таких гарантиях, разработчик играет весомую роль при написании макросов ответственно. Если вы уверены, что вам нужно прибегнуть к макросам, всегда помните, что они не являются вашим API. Держите свои определения макросов короткими, включая их маскирующее содержание. Например, вместо написания макроса, сделайте что-нибудь похожее:

defmodule MyModule do
  defmacro my_macro(a, b, c) do
    quote do
      do_this(unquote(a))
      ...
      do_that(unquote(b))
      ...
      and_that(unquote(c))
    end
  end
end

пишите:

defmodule MyModule do
  defmacro my_macro(a, b, c) do
    quote do
      # Оставьте необходимый минимум,
      # а остальное перенесите в функции
      do_this_that_and_that(unquote(a), unquote(b), unquote(c))
    end
  end

  def do_this_that_and_that(a, b, c) do
    do_this(a)
    ...
    do_that(b)
    ...
    and_that(c)
  end
end

Это делает ваш код более понятным и простым в тестировании и обслуживании, так как вы можете напрямую вызывать и выполнять проверку do_this_that_and_that/3. Это также помогает вам разрабатывать API для разработчиков, которые не хотят полагаться на макросы.

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

© 2020 / Россия Любые мысли и вопросы пишите на elixir@wunsh.ru.