Elixir中的元编程-宏

  1. 前言
  2. 我们第一个宏
  3. 宏的卫生
  4. 环境
  5. 私有宏
  6. 负责任地写宏

前言

即使Elixir尽最大努力为宏提供一个安全的环境,但是用宏写干净的代码的主要责任落在开发者身上。宏比普通Elixir函数更难写,并且当它们不是必需的时候,使用它们被认为是一种坏的风格。所以要负责人地写宏。

Elixir已经通过使用它的数据结构和函数以一种简单和可读的方式提供了一种机制来写你每天的代码。宏只能作为最后的手段使用。记住,明确的优于暗示的。清晰代码优于简洁代码。

我们第一个宏

在Elixir里,宏通过 defmacro/2 定义。

在本章,我们将使用文件来替代在IEx里运行代码样例。这是因为代码样例将跨多行而在IEx里输入它们将适得其反。你应该把代码样例保存在macros.exs文件里,用 elixir macros.exs 或 iex macros.exs 运行。

为了更好地理解宏是如何工作的,让我们创建一个新模块分别用宏和函数来实现 unless ,也即 if 的相反功能:

1
2
3
4
5
6
7
8
9
10
11
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 。但是,如我们在前一章所学,宏将接收引用表达式然后注入到引用,最后返回另一个引用表达式。

让我们用上面的模块来启动iex:

1
$ iex macros.exs

然后试一试那些定义:

1
2
3
4
5
6
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 。

也就是说,当如下调用:

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

则我们的 macro_unless ,接收到如下所示内容:

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

然后它返回一个引用表达式:

1
2
3
4
5
6
{:if, [],
[{:!, [], [true]},
[do: {{:., [],
[{:__aliases__,
[], [:IO]},
:puts]}, [], ["this should never be printed"]}]]}

我们可以用 Macro.expand_once/2 确认这个例子:

1
2
3
4
5
6
7
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 就是用宏来实现的:

1
2
3
4
5
defmacro unless(clause, do: expression) do
quote do
if(!unquote(clause), do: unquote(expression))
end
end

向 unless/2, defmacro/2, def/2, defprotocol/2 这样的结构,和在本教程中被使用的许多其他结构都是用纯Elixir实现的,经常是实现为一个宏。这意味着,用于构建语言的结构可以被开发人员用来将语言扩展到他们正在工作的领域。

我们可以定义一些函数和我们想要的宏,包括那些覆盖由Elixir提供的内置的定义。唯一的例外是Elixir的特殊形式,不用Elixir实现的,因此无法重写,特殊形式的所有列表在Kernel.SpecialForms 里

宏的卫生

Elixir宏有晚解决。它保证引用内定义的变量不会和宏展开的上下文里定义的变量冲突。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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函数里定义的变量 a。如果宏想明确地影响上下文,它可以用 var! :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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

变量的卫生能运作只能是因为Elixir用上下文标注变量。例如,在一个模块的第三行定义的变量x将被表示成:

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

但是,一个引用变量被表示成:

1
2
3
4
5
6
7
defmodule Sample do
def quoted do
quote do: x
end
end

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

注意,在引用变量里的第三个元素是原子 Sample ,而不是 nil ,这说明这个变量来自 Sample 模块。因此,Elixir认为这两个变量来自不同的模块和上下文,因此相应地分别处理它们。

Elixir为导入和别名也提供相似的机制。这保证了宏如它所在模块指定那样表现,而不会与它将被展开的所在模块发生冲突。Hygiene 可以在通过用像 var!/2 和 alias!/2 这样的宏特定的环境下被绕过,但是使用它们的时候必须要小心,因为它们直接修改用户环境。

有时候变量名字可以动态创建。在这种情况下,macro.var/2可以用来定义新的变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 结构的一个实例,它含有有关编译环境的有用信息,包括当前模块、文件和行,所有在当前范围定义的变量,以及导入和需求等等:

1
2
3
4
5
6
7
8
9
10
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 文档里学到更多有关编译环境的知识。

私有宏

Elixir用 defmacrop 也支持私有宏。像私有函数,这样的宏只能在定义它们的模块里有效,也只能在编译时有效。

宏在使用之前定义是很重要的。在调用之前不定义宏会在运行时引发错误,因为宏不会被扩展,将被转换为函数调用:

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

负责任地写宏

宏是一个功能强大的结构并且Elixir提供了许多机制来确保他们被负责任地使用。

  • 宏是卫生的:默认情况下,宏中定义的变量不会影响用户代码。此外,函数调用和在宏观背景下可用的别名是不会泄漏到用户上下文。
  • 宏是有词法范围的:在全局中注入代码或宏是不可能的。为了使用宏,你需要明确地需要或导入定义宏的模块。
  • 宏是明确的:不明确调用宏是不可能运行宏的。例如,一些语言允许开发人员完全重写后台的功能,通常是通过解析变换或通过一些反射机制。在Elixir里,必须在编译时在调用者里明确地调用宏。
  • 宏语言是清晰的:许多语言提供引用和非引用的语法快捷方式。在Elixir,为了清楚地划定一个宏的定义和引用表达式的界限,我们喜欢有明确的说明。

即使有了这样的保证,开发者在负责编写宏时也扮演了重要角色。如果你有信心需要采用宏,请记住宏不是你的API。保持你的宏定义短,包括它们的引用内容。例如,不要像下面这样写宏:

1
2
3
4
5
6
7
8
9
10
11
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

而是像下面这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
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,显示我们如何可以混合宏和模块属性来诠释和扩展模块和函数。

原文链接: http://elixir-lang.org/getting-started/meta/macros.html