[elixir! #0003] [译] 理解Elixir中的宏——part.1 基础 by Saša Jurić

Godfavoredone 2019-06-20

原文

这是讲解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代码的编译有三个阶段:

[elixir! #0003] [译] 理解Elixir中的宏——part.1 基础  by Saša Jurić

输入的源代码被解析,然后生成相应的抽象语法树(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.

相关推荐