Fork me on GitHub

元编程

元编程是指用代码来写代码的过程。在 Elixir 中,这说明我们可以扩展该语言,动态地修改该语言。我们会先看看 Elixir 底层是怎么实现的,然后讲怎么修改它,最后会使用刚学过的只是来扩展它。

忠告:元编程非常难处理,只有在绝对需要的时候才去使用它。过度使用元编程会导致代码很复杂,不容易理解和调试。

目录

Quote

学习元编程的第一步是理解表达式是怎么表示的。Elixir 的 AST(抽象语法树)是列表构成的。这些列表包含了三个部分:函数名称,metadata,还有函数的参数。

Elixir 提供了 quote/2 函数,可以让我们看到这些内部结构,也就是说 quote/2 能把 Elixir 代码转换成对顶的底层表示形式。

iex> quote do: 42
42
iex> quote do: "Hello"
"Hello"
iex> quote do: :world
:world
iex> quote do: 1 + 2
{:+, [context: Elixir, import: Kernel], [1, 2]}
iex> quote do: if value, do: "True", else: "False"
{:if, [context: Elixir, import: Kernel],
 [{:value, [], Elixir}, [do: "True", else: "False"]]}

注意到前面三个并没有返回列表?有五个原语使用 quote 的返回值是它们自身:

iex> :atom
:atom
iex> "string"
"string"
iex> 1 # All numbers
1
iex> [1, 2] # Lists
[1, 2]
iex> {"hello", :world} # 2 element tuples
{"hello", :world}

Unquote

知道了如何获取代码的内部表示,那怎么修改它呢?我们依靠 unquote/1 来插入新的代码和值。当我们 unquote 一个表达式的时候,会把它运行的结果插入到 AST。我们来看个例子理解一下 unquote/2:

iex> denominator = 2
2
iex> quote do: divide(42, denominator)
{:divide, [], [42, {:denominator, [], Elixir}]}
iex> quote do: divide(42, unquote(denominator))
{:divide, [], [42, 2]}

在上面的例子中,变量 denominator 在 quote 的时候会在 AST 上出现一个列表,使用 unquote/1 会把对应的位置变成 denominator 的值。

理解了 quote/2unquote/1,我们就开始学习宏。要注意的:宏和元编程一样,必须谨慎使用。

简单来说,宏就是一个特别的函数:它返回的 quoted 的表达式会被插入到应用的代码中。可以想象宏被 quoted 后的表达式替代,而不是去像函数那样被调用。有了宏,我们就能够扩展 Elixir 和动态地为应用添加代码了。

定义宏需要用 defmacro/2,这在 Elixir 中也是一个宏(花点时间慢慢领会)。在这个例子中,我们会实现 unless 宏,别忘了宏需要返回 quoted 后的表达式:

defmodule OurMacro do
  defmacro unless(expr, do: block) do
    quote do
      if !unquote(expr), do: unquote(block)
    end
  end
end

导入模块,测试一下我们刚定义的宏:

iex> require OurMacro
nil
iex> OurMacro.unless true, do: "Hi"
nil
iex> OurMacro.unless false, do: "Hi"
"Hi"

因为宏会替换应用中的代码,所以我们能控制会把代码替换成什么,也能控制在什么情况下进行替换。Logger 模块就是很好的例子,如果 logging 被禁止了,就不会有代码被插入,最终的代码也不会有调用 logging 的逻辑。和其他语言的区别在于:其他语言在 logging 被禁止的时候,还会用 logging 有关的代码存在,即使里面的实现是空的。

我们写一个简单的 logger 来说明这一点:我们的 logger 可以通过环境变量来开启和禁止:

defmodule Logger do
  defmacro log(msg) do
    if Application.get_env(:logger, :enabled) do
      quote do
        IO.puts("Logged message: #{unquote(msg)}")
      end
    end
  end
end

defmodule Example do
  require Logger

  def test do
    Logger.log("This is a log message")
  end
end

当 logging 开启的时候,我们的 test 函数最后的代码会是下面的样子:

def test do
  IO.puts("Logged message: #{"This is a log message"}")
end

如果我们禁止了 logging,最终的代码就变成了:

def test do
end

私有宏

尽管不常使用,Elixir 还支持私有宏。私有宏通过 defmacrop 定义,只能在定义的模块中被调用。私有宏必须在调用它的代码之前定义。

宏清洁

宏清洁是指:宏和调用的上下文交互的过程。默认情况下,Elixir 中的宏是洁净的:也就是说,它不会和调用者的上下文冲突:

defmodule Example do
  defmacro hygienic do
    quote do: val = -1
  end
end

iex> require Example
nil
iex> val = 42
42
iex> Example.hygienic
-1
iex> val
42

但是如果我们希望在宏里面修改 val 变量呢?使用 var!/2 可以让某个变量的操作变成对上下文变量的操作。在我们的例子中,添加另外一个使用 var!/2 的宏:

defmodule Example do
  defmacro hygienic do
    quote do: val = -1
  end

  defmacro unhygienic do
    quote do: var!(val) = -1
  end
end

然后比较一下它们是怎么和代码的上下文交互的:

iex> require Example
nil
iex> val = 42
42
iex> Example.hygienic
-1
iex> val
42
iex> Example.unhygienic
-1
iex> val
-1

使用 var!/2,我们可以直接操作 val 变量而不需要它作为参数传递给宏。宏的这种使用方式应该尽少使用,引入 var!/2 会增加变量冲突的风险。

绑定

我们已经介绍过 unquote/1,但是还有另外一种方法可以在代码中插入值——绑定。使用变量绑定,我们能够在宏中多次使用变量,并且保证它们只会被计算一次,从而避免意外的重新计算。要使用绑定变量,我们必须传一个关键字列表 bind_quoted 作为 quote/2 的选项。

为了理解 bind_quote 的好处和重新计算的问题,我们来看个例子。创建一下把某个表达式的值打印两边的宏:

defmodule Example do
  defmacro double_puts(expr) do
    quote do
      IO.puts unquote(expr)
      IO.puts unquote(expr)
    end
  end
end

我们把当前的系统时间传递过去,来测试一下刚定义的宏,期望是:它把当前时间打印两遍:

iex> Example.double_puts(:os.system_time)
1450475941851668000
1450475941851733000

两次的时间居然不一样!什么鬼?对同一个表达式多次使用 unquote/1 会重新计算表达式的值,这会导致意想不到的错误。我们来更新一下例子,看看使用 bind_quoted 的结果:

defmodule Example do
  defmacro double_puts(expr) do
    quote bind_quoted: [expr: expr] do
      IO.puts expr
      IO.puts expr
    end
  end
end

iex> require Example
nil
iex> Example.double_puts(:os.system_time)
1450476083466500000
1450476083466500000

使用 bind_quoted 和我们预期的结果是一致的:当前的时间被打印两次。

现在我们已经学完了 quote/2unquote/1defmacro/2,这些工具已经足够我们按照需要扩展 Elixir 了。


分享本页面