1

原文

这是讲解macro(宏)的系列文章中的第一篇。我本来计划将这个话题放在我的书《Elixir in Action》里,但还是放弃了,因为那本书的主要关注的是底层VM和OTP中重要的部分。

所以,我决定在这里讲解宏。我发现关于宏的话题十分有趣,在这一系列的文章中,我将试图解释宏是如何运作的,并以供一些编写宏的基本技巧和建议。尽管我认为编写宏并不是很难,但相较于普通Elixir代码,它确实需要更高一层的视角。因此,我认为这对于理解Elixir编译器的内部细节很有帮助。知道了事物内部是如何运作的之后,就能更容易地理解元编程代码。

这是中等难度的内容。如果你很熟悉Elixir和Erlang,但对宏还感觉困惑,那么这很适合你。如果你刚开始接触Elixir和Erlang,那么最好从其它地方开始。

元编程

也许你已经听说过Elixir中的元编程。主要的思想就是我们可以编写一些代码,它们会根据某些输入来生成代码。

归功于宏,我们可以写出像Plug里这样的结构:

get "/hello" do
  send_resp(conn, 200, "world")
end

match _ do
  send_resp(conn, 404, "oops")
end

或者是ExActor中的:

defmodule SumServer do
  use ExActor.GenServer

  defcall sum(x, y), do: reply(x+y)
end

在两个例子中,我们在编译时都会将这些自定义的宏转化成其它的代码。调用Plug的getmatch会创建一个函数,而ExActor的defcall会生成用于从客户端传递参数到服务器的两个函数和代码。

Elixir本身就非常多地用到了宏。许多结构,例如defmodule, def, if, unless, 甚至defmacro都是宏。这使得语言的核心能保持迷你,日后对语言的展开就会更简单。

还有比较冷门的,就是能利用宏批量成生函数:

defmodule Fsm do
  fsm = [
    running: {:pause, :paused},
    running: {:stop, :stopped},
    paused: {:resume, :running}
  ]

  for {state, {action, next_state}} <- fsm do
    def unquote(action)(unquote(state)), do: unquote(next_state)
  end
  def initial, do: :running
end

Fsm.initial
# :running

Fsm.initial |> Fsm.pause
# :paused

Fsm.initial |> Fsm.pause |> Fsm.pause
# ** (FunctionClauseError) no function clause matching in Fsm.pause/1

在这里,我们将一个对FSM的类型声明转换(在编译时)成了对应的多从句函数。

类似的技术被Elixir用于生成String.Unicode模块。这个模块基本上是通过读取UnicodeData.txtSpecialCasing.txt文件里表述的代码点来生成的。基于文件中的数据,各种函数(例如upcase, downcase) 被生成了。

无论是宏还是代码生成,我们都在编译的过程中对抽象语法树做了某些变换。为了理解它是如何工作的,你需要学习一点编译过程和AST的知识。

编译过程

概括地说,Elixir代码的编译有三个阶段:

图片描述

输入的源代码被解析,然后生成相应的抽象语法树(AST)。AST会以嵌套的Elixir语句的形式来表现你的代码。然后展开阶段开始。在这个阶段,各种内置的和自定义的宏被转换成了最终版本。一旦转换结束,Elixir就可以生成最后的字节码,即源程序的二进制表示。

这只是一个概述。例如,Elixir编译器会生成Erlang AST,然后依赖Erlang函数将其转换为字节码,但是我们不需要知道细节。不过,这幅图对于理解元编程代码确实有帮助。

首先我们要知道,元编程的魔法发生在展开阶段。编译器先以一个类似于你的原始Elixir代码的AST开始,然后展开为最终版本。

另外,在生成了二进制之后,元编程就停止了。你可以确定你的代码不会被重新定义,除非代码升级或是一些动态的代码载入技巧(在本文内容之外)。因为元编程总是会引入一个隐形(或不明显)的层,在Elixir中这只发生在编译时,并独立于程序的各种执行路径。

代码转换发生在编译时,因此推导最终产品会相对简单,而且元编程不会干扰例如dialyzer的静态分析工具。编译时元编程也意味着我们不会有性能损失。进入运行时后,代码中就没有元编程结构了。

创建AST片段

那么什么是Elixir AST。它是Elixir代码所对应的深嵌套格式。让我们来看一些例子。你可以使用quote特殊形式来生成代码的AST:

iex(1)> quoted = quote do 1 + 2 end
{:+, [context: Elixir, import: Kernel], [1, 2]}

quote获取任意的Elixir表达式,并返回对应的AST片段。

在这里,AST片段表达了简单的加法操作(1+2)。它通常被称为一个 quoted 表达式。

大多是时候你不需要明白quoted结构中的细节,但是让我们来观察一下这个简单的例子。在这里,我们的AST片段可以分为三个部分:

  • 一个原子表示所要调用的操作(:+

  • 表达式的环境(例如 imports和aliases)。通常你不需要理解这个数据

  • 参数

简而言之,在Elixir中,quoted表达式是用来描述代码的。编译器会用它来生成最后的字节码。

我们也可以对quoted表达式求值,尽管很少用到:

iex(2)> Code.eval_quoted(quoted)
{3, []}

返回的元组中包含了表达式的结果,以及一个列表,其中包含了表达式中产生的变量绑定。

然而,在AST求值之前(通常由编译器来做),quoted表达式还没有接受语义验证。例如,当我们这样写:

iex(3)> a + b
** (RuntimeError) undefined function: a/0

我们会得到错误,因为这里没有叫做a的变量(或函数)。

如果我们quote了表达式:

iex(3)> quote do a + b end
{:+, [context: Elixir, import: Kernel], [{:a, [], Elixir}, {:b, [], Elixir}]}

这里就不会报错。我们得到了a+b的quoted表示,这意味着我们生成了对表达式a+b的描述,而不用管这些变量是否存在。最终代码还没有发出,所以不会报错。

如果我们将其加入到某个ab为合法变量的AST中,则代码可以正确运行。

让我们来试一下。首先,quote求和表达式:

iex(4)> sum_expr = quote do a + b end

然后创建一个quote了的绑定表达式:

iex(5)> bind_expr = quote do
          a=1
          b=2
        end

记住,它们只是quoted表达式。它们只是在描述代码,并没有执行。这时,变量ab并不存在于当前shell会话中。

为了让这些片段一起工作,我们必须连接它们:

iex(6)> final_expr = quote do
          unquote(bind_expr)
          unquote(sum_expr)
        end

在这里,我们生成了一个新的quoted表达式,由bind_expr的内容和sum_expr的内容组成。事实上,我们生成了一个包含着两个表达式的新的AST片段。不要担心unquote,我会在稍后解释它。

与此同时,我们可以执行这个最后的AST片段:

iex(7)> Code.eval_quoted(final_expr)
{3, [{{:a, Elixir}, 1}, {{:b, Elixir}, 2}]}

结果又一次由表达式的结果(3)和绑定列表组成,可以看出我们的表达式将两个变量ab绑定到了值12

这就是Elixir中元编程方法的核心。当进行元编程时,我们本质上是在构建各种AST片段,以生成一些代表着我们想要得到的代码的AST。在这过程中,我们通常对输入的AST片段(我们所结合的)的确切内容和结构不感兴趣。相反,我们使用quote来生成并结合输入片段,以生成一些修饰好了的代码。

Unquoting

unquote在这里出现了。注意,无论quote块里有什么,它都会变成AST片段。这意味着我们不可以简单地将外部的变量注入到我们的quote里。例如,这样是不能达到效果的:

quote do
  bind_expr
  sum_expr
end

在这里,quote只是简单地生成了对于bind_exprsum_expr变量的quoted标记,它们必须存在于这个AST可以被理解的环境里。然而,这不是我们想要的。我们想要的是直接注入bind_exprsum_expr的内容到我们所生成的AST片段中对应的地方。

这就是unquote(…)的目的——括号里的表达式会被立刻执行,然后插入到调用了unquote的地方。这意味着unquote的结果必须是合法的AST片段。

我们也可将unquote类比于字符串插值(#{})。对字符串你可以这样做:

"... #{some_expression} ... "

类似地,quote时你可以这样做:

quote do
  ...
  unquote(some_expression)
  ...
end

两种情形下,你都要执行一个在当前环境中合法的表达式,并将结果注入到你正在构建的表达式中(或字符串,AST片段)。

这很重要,因为unquote并不是quote的逆操作。quote将一段代码转换成quoted表达式,unquote并没有做逆向操作。如果你想将一个quoted表达式转换成一个字符串,你可以使用Macro.to_string/1

例子:跟踪表达式

让我们来实践一下。我们将编写一个帮助我们排除故障的宏。这个宏可以这样用:

iex(1)> Tracer.trace(1 + 2)
Result of 1 + 2: 3
3

Tracer.trace接受了一个表达式,并打印出了它的结果。然后返回的是表达式的结果。

需要认识到这是一个宏,它的输入(1+2)可以被转换成更复杂的形式——打印表达式的结果并返回它。这个变换会发生在展开期,而结果的字节码会包含一些修饰过的输入代码。

在查看它的实现之前,想象一下或许会很有帮助。当我们调用Tracer.trace(1+2),结果的字节码会对应这些:

mangled_result = 1+2
Tracer.print("1+2", mangled_result)
mangled_result

名称mangled_result表明Elixir编译器会损毁所有在宏里引用的临时变量。这也被称为宏清洗,我们会在本系列之后的内容中讨论它(不在本文)。

因此,这个宏的定义可以是这样的:

defmodule Tracer do
  defmacro trace(expression_ast) do
    string_representation = Macro.to_string(expression_ast)

    quote do
      result = unquote(expression_ast)
      Tracer.print(unquote(string_representation), result)
      result
    end
  end

  def print(string_representation, result) do
    IO.puts "Result of #{string_representation}: #{inspect result}"
  end
end

让我们来逐步分析这段代码。

首先,我们用defmacro定义宏。宏本质上是特殊形式的函数。它的名字会被损毁,并且只能在展开期调用它(尽管理论上你仍然可以在运行时调用)。

我们的宏接收到了一个quoted表达式。这一点非常重要——无论你发送了什么参数给一个宏,它们都已经是quoted的。所以,当我们调用Tracer.trace(1+2),我们的宏(它是一个函数)不会接收到3。相反,expression_ast的内容会是quote(do: 1+2)的结果。

在第三行,我们使用Macro.to_string/1来求出我们所收到的AST片段的字符串形式。这是你在运行时不能够对一个普通函数做的事之一。虽然我们能在运行时调用Macro.to_string/1,但问题在于我们没办法再访问AST了,因此不能够知道某些表达式的字符串形式了。

一旦我们拥有了字符串形式,我们就可以生成并返回结果AST了,这一步是在quote do … end 结构中完成的。它的结果是用来替代原始的Tracer.trace(…)调用的quoted表达式。

让我们进一步观察这一部分:

quote do
  result = unquote(expression_ast)
  Tracer.print(unquote(string_representation), result)
  result
end

如果你明白unquote的作用,那么就很简单了。实际上,我们是在把expression_ast(quoted 1+2)代入到我们生成的片段中,将表达式的结果放入result变量。然后我们使用某种格式来打印它们(借助Macro.to_string/1),最后返回结果。

展开一个AST

在shell里可以很容易地观察它。启动iex,然后复制粘贴Tracer模块的定义:

iex(1)> defmodule Tracer do
          ...
        end

然后,你必须requireTracer模块:

iex(2)> require Tracer

接下来,quote一个对trace宏的调用:

iex(3)> quoted = quote do Tracer.trace(1+2) end
{{:., [], [{:__aliases__, [alias: false], [:Tracer]}, :trace]}, [],
 [{:+, [context: Elixir, import: Kernel], [1, 2]}]}

这个输出有点吓人,但你通常不用理解它。如果仔细观察,你可以看到这个结构里有提到Tracertrace,证明了这个AST片段对应着我们的原始代码,它还没有被展开。

现在,该开始展开这个AST了,使用Macro.expand/2

iex(4)> expanded = Macro.expand(quoted, __ENV__)
{:__block__, [],
 [{:=, [],
   [{:result, [counter: 5], Tracer},
    {:+, [context: Elixir, import: Kernel], [1, 2]}]},
  {{:., [], [{:__aliases__, [alias: false, counter: 5], [:Tracer]}, :print]},
   [], ["1 + 2", {:result, [counter: 5], Tracer}]},
  {:result, [counter: 5], Tracer}]}

这是我们的代码完全展开后的版本,你可以看到其中提到了result(由宏引入的临时变量),以及对Tracer.print/2的调用。你甚至可以将这个表达式转换成字符串:

iex(5)> Macro.to_string(expanded) |> IO.puts
(
  result = 1 + 2
  Tracer.print("1 + 2", result)
  result
)

这些说明了你对宏的调用已经展开成了别的东西。这就是宏工作的原理。尽管我们只是在shell中尝试,但使用mixelixirc构建项目时也是一样的。

我想这些内容对于第一章来说已经够了。你已经对编译过程和AST有所了解,也看过了一个简单的宏的例子。后续,我们将更深入地讨论宏的一些机制。

Copyright 2014, Saša Jurić. This article is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.
The article was first published on the old version of the Erlangelist site.
The source of the article can be found here.


Ljzn
399 声望102 粉丝

网络安全;函数式编程;数字货币;人工智能