查看源代码

尽管 Elixir 尽力为宏提供安全的环境,但编写干净宏代码的大部分责任落在开发者身上。宏比普通的 Elixir 函数更难编写,在不需要的情况下使用它们被认为是不好的风格。负责任地编写宏。

Elixir 已经提供了使用数据结构和函数以简单易读的方式编写日常代码的机制。宏应该只在万不得已的情况下使用。记住 **显式比隐式好**。**清晰的代码比简洁的代码好。**

我们的第一个宏

Elixir 中的宏通过 defmacro/2 定义。

在本指南中,我们将使用文件而不是在 IEx 中运行代码示例。这是因为代码示例将跨越多行代码,在 IEx 中输入它们全部可能适得其反。您应该能够通过将代码示例保存到一个名为 macros.exs 的文件中,并使用 elixir macros.exsiex macros.exs 运行它来运行代码示例。

为了更好地理解宏的工作原理,让我们创建一个新模块,在这个模块中我们将实现 unless(它与 if/2 相反),作为宏和函数。

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/2。但是,正如我们在 上一指南 中了解到的,宏将接收引用的表达式,将它们注入到引文中,最后返回另一个引用的表达式。

让我们从上面的模块开始 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/2

换句话说,当被调用为

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__)。

这就是宏的意义所在。它们是关于接收引用的表达式并将它们转换为其他东西。事实上,Elixir 中的 unless/2 是作为宏实现的

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

诸如 unless/2defmacro/2def/2defprotocol/2 以及在整个 Elixir 标准库中使用的许多其他构造,都是用纯 Elixir 编写的,通常作为宏。这意味着用于构建语言的构造可以被开发者用来将语言扩展到他们正在处理的领域。

我们可以定义我们想要的任何函数和宏,包括覆盖 Elixir 提供的内置定义。唯一的例外是 Elixir 特殊形式,它们不是在 Elixir 中实现的,因此不能被覆盖。特殊形式的完整列表可以在 Kernel.SpecialForms 中找到。

宏卫生

Elixir 宏具有 "延迟解析"。这保证了在引文中定义的变量不会与在扩展该宏的上下文中定义的变量冲突。例如

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,它也不会影响 go/0 函数定义的变量 a。如果宏想要显式地影响上下文,它可以使用 var!/1

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

上面的代码将运行,但会发出警告:变量 "a" 未使用。宏正在覆盖原始值,而原始值从未使用。

变量卫生之所以有效,是因为 Elixir 用它们的 **上下文** 对变量进行注释。例如,在模块的第 3 行定义的变量 x 将表示为

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

然而,一个引用的变量将被表示为

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

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

请注意,引用变量中的 *第三个元素* 是原子 Sample,而不是 nil,它标志着该变量来自 Sample 模块。因此,Elixir 将这两个变量视为来自不同的上下文并相应地处理它们。

Elixir 也为导入和别名提供了类似的机制。这保证了宏的行为与其源模块指定的相同,而不是与宏被扩展的目标模块冲突。可以在使用 var!/2alias!/1 等宏的特定情况下绕过卫生,尽管在使用这些宏时必须谨慎,因为它们直接改变了用户环境。

有时变量名可能是动态生成的。在这种情况下,可以使用 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.unique_var/2,用于在您需要生成具有唯一名称的变量时的情况。

环境

在本章前面调用 Macro.expand_once/2 时,我们使用了特殊形式 __ENV__/0

__ENV__/0 返回一个 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.Env 环境。您可以在 Macro 中阅读有关这些函数的更多信息,并在 Macro.Env 中了解更多关于编译环境的信息。

私有宏

Elixir 还通过 defmacrop 支持 **私有宏**。与私有函数一样,这些宏只在定义它们的模块内部可用,并且仅在编译时可用。

重要的是,宏必须在使用之前定义。如果在调用宏之前没有定义宏,将在运行时引发错误,因为宏将不会被扩展,并将被转换为函数调用

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

负责任地编写宏

宏是一种强大的结构,Elixir 提供了许多机制来确保它们被负责任地使用。

  • 宏是 **卫生的**:默认情况下,在宏内部定义的变量不会影响用户代码。此外,宏上下文中可用的函数调用和别名不会泄漏到用户上下文中。

  • 宏是 **词法** 的:不可能全局注入代码或宏。要使用宏,您需要显式地 requireimport 定义该宏的模块。

  • 宏是 **显式** 的:不可能在没有显式调用宏的情况下运行宏。例如,一些语言允许开发者在幕后完全重写函数,通常通过解析转换或一些反射机制。在 Elixir 中,必须在编译时在调用者中显式调用宏。

  • 宏的语言是清晰的:许多语言为 quoteunquote 提供了语法快捷方式。在 Elixir 中,我们更喜欢明确地拼写出它们,以便清楚地界定宏定义及其引用表达式的边界。

即使有这样的保证,开发者在编写宏时负责任地发挥着重要作用。如果您确信需要使用宏,请记住宏不是您的 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
      # Keep what you need to do here to a minimum
      # and move everything else to a function
      MyModule.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。

通过本指南,我们完成了对宏的介绍。下一份指南将简要讨论 **DSLs**,展示如何混合宏和模块属性来注释和扩展模块和函数。