Elixir元编程-第一章 宏语言

Elixir元编程-第一章 宏语言

注:本章内容来自《Metaprogramming Elixir》一书,写的很是好,强烈推荐。内容不是原文照翻,部分文字采起意译,主要内容都基本保留,加上本身的一些理解描述。为更好理解,建议参考原文。程序员

是时候来探索元编程了。学习了Elixir的基础知识,或许你想写出更好的产品库,或者构建一门 dsl,或者优化运行性能。或许你只是想简单体会一下 Elixir 超强能力给你带来的乐趣。若是这就是你想要的,那么咱们开始吧!web

如今,我假定你已经熟悉了 Elixir;你已经体验过这门语言,或许还发布过一两个库。咱们要进入新的阶段,开始学习经过宏来编写生成代码的代码。Elixir 宏是用来改变游戏规则的。由它开启的元编程会让咱们编写强大程序时信手拈来。express

生成代码的代码,听起来有点拗口,但你不久就会看到它是如何组织起 Elixir 语言自己的基础架构。宏开启了在其余语言中彻底不可能的一扇大门。使用恰当的话,元编程能够编写清晰、简洁的程序,咱们能够塑造代码,而非教条地运用指令。编程

咱们会讲述 Elixir 所需的一切知识,而后放手去干吧。安全

让咱们开始吧。数据结构

整个世界都是你的游乐场

Elixir 中的元编程所有都是关于扩展能力的。你是否曾但愿你喜欢的语言具有某种小巧优雅的特性?若是你走运的话,几年后这种特性也许会添加到语言中。事实上这种事基本未发生过。在 Elixir 中,只要你愿意你能够任意引入新特性。好比在不少语言中你都很熟悉的 while 循环。在 Elixir 中是没有这个的,但你又想用它,好比:架构

while Process.alive?(pid) do
  send pid, {self, :ping}
  receive do
    {^pid, :pong} -> IO.puts "Got pong"
  after 2000 -> break
  end
end

下一章,咱们就来编写这个 while 循环。不止于此,使用 Elixir,咱们能够用语言定义语言,好比使用天然语法表达某些问题。下面这是一段有效的 Elixir 程序哦:框架

div do
  h1 class: "title" do
    text "Hello"
  end
  p do
    text "Metaprogramming Elixir"
  end
end
"<div><h1 class=\"title\">Hello</h1><p>Metaprogramming Elixir</p></div>"

Elixir 使这种相似编写 HTML 的 dsl 为可能。事实上,咱们只须要几章的学习就能够编写这个程序了。你如今还不须要理解这是怎么干的,咱们会学到的。如今你只须要知道,宏使得这一切成为可能。编写代码的代码。Elixir将这个理念贯彻的如此之深,远超你的想象。less

正如一个游乐场,你老是从一小块地方开始,而后以你的方式不断探索新的领域。元编程会是较难理解掌握的,对它的运用也须要考虑更高阶的问题。贯穿本书,咱们会经过大量的简单练习,初步揭开神秘面纱,最终掌握高阶的代码生成技术。在开始编写代码前,咱们先回顾下 Elixir 元编程中很是重要的两个基本原则,以及他们是如何协做的。编辑器

抽象语法树:AST

要掌握元编程,首先你须要理解 Elixir 是如何使用 AST 在内部表示 Elixir 代码的。你接触到的绝大多数的语言都会使用 AST,但基本上你也会无视它。当你的程序被编译或解释执行时,源代码会被转换成树结构,而后编译成字节码或机器码。这个过程通常都是不可见的,你也历来不会注意到它。

José Valim,Elixir语言的发明者,选择了不一样的处理方式。他用 Elixir 本身的数据结构来保存 AST 格式,并将其暴露出来,而后提供天然的语法来同其交互。使用普通 Elixir 代码就能访问 AST,这让你得到了编译器或者语言设计者才拥有的访问底层能力,你就能作一些很是强大的事情。在元编程的每一个阶段,你都在同 Elixir 的 AST 进行交互,那么就让咱们深刻探索下它究竟是什么。

Elixir 中的元编程涉及分析和修改 AST。你可使用 quote 宏来访问任意 Elixir 表达式的 AST 结构。代码生成极度依赖于 quote,贯穿本书的全部练习都离不开他。咱们研究下用它获取的一些基本表达式的 AST 结构。

输入如下代码,观察返回结果:

iex> quote do: 1 + 2
{:+, [context: Elixir, import: Kernel], [1, 2]}

iex> quote do: div(10, 2)
{:div, [context: Elixir, import: Kernel], [10, 2]}

咱们能够看到 1 + 2 和 div 表达式的 AST 结构,就是用 Elixir 自身的简单的数据结构来表示。让咱们沉思片刻,你能够访问用 Elixir 数据结构保存的你写的任意代码的的表达(译注:实际上就是代码即数据,数据即代码)。quote 表达式所能带给你的东西你见所未见:可以审视你所编写代码的内部展示,并且是用你彻底知道和理解的数据结构。这让你在Elixir高阶语法层面更好的理解代码,优化性能,以及扩展功能。(译注:这里的高阶就是指普通Elixir语法,相比 AST 它确实是高阶;就比如 C 语言之于汇编)

拥有了 AST 的所有访问能力,咱们就可以在编译阶段耍一些优雅的小把戏。好比,Elixir 标准库中的 Logger 模块,能够经过从 AST 中完全删除对应表达式来优化日志功能(译注:即开发调试时运行日志,最终发布版本时自动删除全部日志,并且是从 AST 删除,对发布版来讲,该日志从未存在过)。好比说,咱们在写入一个文件时,但愿在开发阶段打印文件路径,但在产品发布阶段则彻底忽略这个动做。咱们可能写出以下代码:

def write(path, contents) do
  Logger.debug "Writing contents to file #{path}"
  File.write!(path, contents)
end

在产品发布阶段,Logger.debug 表达式会完全从程序中删除。这是由于咱们在编译时能够彻底操做 AST,从而跳过同开发阶段相关的代码。大多数语言不得不调用 debug 函数,检测运行时忽略的 log 等级,纯属浪费 CPU 时间,由于这些语言根本没法操纵 AST。

探究 Logger.debug 是如何作到这一点的,这就把咱们引领到元编程的一个重要概念面前:宏(macros)。

宏就是编写代码的代码。终其一辈子其做用就是用 Elixir 的高阶语法同 AST 交互。这也是为何 Logger.debug 看起来像普通的 Elixir 代码,但却能完成高超的优化技巧。

宏无处不在,既能够用来构建 Elixir 标准库,也能够用来构建 web 框架的核心架构。无论哪一种状况,使用的都是相同的元编程规则。你无须在复杂性,性能快慢,API 的简洁优雅上妥协。Elixr 宏能让你编写简单又高效的代码。它让你--程序员,从单纯的语言使用者,变成语言的建立者。只要你用这门语言,要不了多久,你就会使用到 José 用来构建这门语言的标准库的全部工具和威力。他开放了这门语言,容许你本身扩展。一旦你体验过这种威力,食髓知味,你就很难回头了。

你可能会想直到目前你都在尽可能避免使用宏,但其实这些宏一直都在,静静的隐藏在幕后。看下下面这段简单代码:

defmodule Notifier do
  def ping(pid) do
    if Process.alive?(pid) do
      Logger.debug "Sending ping!"
      send pid, :ping
    end
  end
end

看上去平平无奇,但咱们已经发现了四个宏。在语言内部,defmodule,def,if,甚至 Logger.debug 都是用宏实现的,Elixir 大多数的顶层结构也基本如此。你能够本身在 iex 里面查看下文档:

iex> h if

	defmacro if(condition, clauses)
	
Provides an if macro. This macro expects the first argument to be a condition
and the rest are keyword arguments.

你可能会好奇 Elixir 在本身的架构中使用宏有什么优点,大多数其余语言没有这玩意儿不也挺好的吗。宏最强大的一个功能就是你能够本身定义语言的关键字,就基于现有的宏做为构建基石就行。

要理解 Elixir 中的元编程,就要抛弃那些封闭式语言以及死板僵化的保留字那套陈腐观念。Elixir 被设计成能够随意扩展。这门语言是开放的,能够任意探索,任意定制。这也是为什么在 Elixir 实现元编程是如此的天然舒服。

知识汇总一下

咱们已经见识过了 Elixir 自身是如何由宏构建的,以及使用 quote 如何返回任意表达式的 AST 格式。如今咱们把知识汇总一下。最重要的一点要知道宏接受 AST 做为参数,而后返回值必定也是一个 AST。所谓编写宏,就是用 Elixir 的高阶语法构建 AST。

要了解这套机制如何运做,咱们先编写一个宏用来输出一个 Elixir 数学表达式在计算结果时产生的可读格式,好比 5 + 2。在大多数语言当中,咱们只能解析表达式的字符串,将其转化成程序可以识别的格式。在 Elixir 中,咱们可以直接使用宏访问表达式的内部展示形式。

咱们第一步是分析咱们的宏要接受的表达式的 AST 结构。咱们使用 iex 而后 quote 一些表达式。本身去尝试下,好好体会下 AST 的结构。

iex> quote do: 5 + 2
{:+, [context: Elixir, import: Kernel], [5, 2]}

iex)> quote do: 1 * 2 + 3
{:+, [context: Elixir, import: Kernel],
 [{:*, [context: Elixir, import: Kernel], [1, 2]}, 3]}

5 + 2 跟 1 * 2 + 3 表达式的 AST 直接就是个元组。:+:* 两个 atom 表明操做符,左右参数放在最后一个元素当中。三元组结构就是 Elixir 的高阶表达形式。

如今咱们知道表达式是如何表示的了,让咱们定义第一个宏来看看 AST 是如何配合的。咱们会定义一个 Math 模块,包含一个 say 宏,可以以天然语言形式在任意数学表达式求值时将其输出。

建立一个 math.exs 文件,添加以下代码:

macros/math.exs

defmodule Math do

  # {:+, [context: Elixir, import: Kernel], [5, 2]}
  defmacro say({:+, _, [lhs, rhs]}) do 
    quote do
      lhs = unquote(lhs)
      rhs = unquote(rhs)
      result = lhs + rhs
      IO.puts "#{lhs} plus #{rhs} is #{result}"
      result
    end
  end

  # {:*, [context: Elixir, import: Kernel], [8, 3]}
  defmacro say({:*, _, [lhs, rhs]}) do 
    quote do
      lhs = unquote(lhs)
      rhs = unquote(rhs)
      result = lhs * rhs
      IO.puts "#{lhs} times #{rhs} is #{result}"
      result
    end
  end
end

在 iex 里加载测试:

iex> c "math.exs"
[Math]

iex> require Math
nil

iex> Math.say 5 + 2
5 plus 2 is 7
7

iex> Math.say 18 * 4
18 times 4 is 72
72

分解下程序。咱们知道宏接受 AST 格式的参数,所以咱们直接使用模式匹配,来肯定该调用哪个 say。第4到15行,是宏定义,跟函数相似,能够有多个签名。知道告终果 quoted 后的格式,所以咱们能够很容易地将左右两边的值绑定到变量上,而后输出对应信息。

要完成宏功能,咱们还要经过 quote 返回一个 AST 给调用者,用来替换掉 Math.say 调用。这里咱们第一次用到 unquote。咱们后面会详述 quote 跟 unquote。如今,你只须要知道这两个宏协同工做来帮助你建立 AST,他们会帮助你跟踪代码的执行空间。

先把那些条条框框放一边,咱们如今已经深刻到了 Elixir 元编程体系的细节中。你已经见识到宏跟 AST 协同工做,如今让咱们研究它是如何运做的。但首先,咱们要讨论一些东西。

宏的规则

在开始编写更复杂的宏以前,咱们须要强调一些规则,以便更准确调整预期。宏给咱们带来神奇的力量,但能力越大,责任越大。

规则1:不要编写宏

当你同其余人谈论元编程时,可能已经早就被警告过了。尽管这是毫无道理的,但在咱们陷入狂热前,咱们仍是要牢记编写生成代码的代码须要格外当心。若是鲁莽行事,咱们很容易陷入困境。若是走得太远,宏会使得程序难以调试,难以分析。固然元编程确定有某种显著的优势的。但通常来讲,若是不必生成代码,那咱们就用标准函数定义好了。

规则2:随便用宏

有人说元编程有时是复杂而脆弱的。咱们会经过利用一小段必要代码来生成健壮,清晰的程序来驳斥这种说法。不要被 Elixir 宏系统可能带来的一点点晦涩所吓倒,而放弃对宏系统的深刻探索。学习元编程的最好方式就是开放思想,放弃成见,保持好奇心。学习时甚至能够有点小小的不负责任(意为大胆尝试)。

编写宏的时候能够秉持以上双重标准。在你的元编程之旅,你会看到如何可靠地运用你的熟练技巧,同时学会如何有效地避开常见陷阱。优秀的代码本身会说话,咱们就是要充分挖掘它。

抽象语法树--揭开神秘面纱

是时候深刻探索 AST 了,咱们来学习源码展示的不一样形式。你可能急于如今就一头跳进去,立刻开始编写宏,但真正理解 AST 是后面学习元编程的重中之重。一旦你深刻理解了它的精微奥妙,你会发现 Elixir 代码远比你想象得更接近 AST。后面的内容会颠覆你对解决问题的思考方式,并驱使你的宏能力不断进步。学习了优雅的 AST 后,咱们将能够开始元编程练习了。有点耐心。你会在真正了解全部这些技术以前就建立了新的语言特性。

AST 的结构

你所编写的每个 Elixir 表达式都会分解成一个三元组格式的 AST。你会常用这种统一格式来进行模式匹配,分解参数。在前面的 Math.say 的定义中,咱们已经用到了这种技术。

defmacro say({:+, _, [lhs, rhs]}) do

既然咱们已经知道了表达式 5 + 2 会转化成 {:+, [...], [5, 2]} 元组,咱们就能够直接模式匹配 AST,获取计算的含义。让咱们 quote 一些更复杂的表达式,来看看 Elixir 程序是如何完整地用 AST 表示。

iex> quote do: (5 * 2) - 1 + 7
{:+, [context: Elixir, import: Kernel],
 [{:-, [context: Elixir, import: Kernel],
  [{:*, [context: Elixir, import: Kernel], [5, 2]}, 1]}, 7]}
  
iex> quote do
...>   defmodule MyModule do
...>     def hello, do: "World"
...>   end
...> end

{:defmodule, [context: Elixir, import: Kernel],
 [{:__aliases__, [alias: false], [:MyModule]},
  [do: {:def, [context: Elixir, import: Kernel],
   [{:hello, [context: Elixir], Elixir}, [do: "World"]]}]]}

你能够看到每个 quoted 的表达式造成了一个堆栈结构的元组。第一个例子同 Math.say 宏的基本结构是相似的,不过是有更多的元组嵌套在一块儿组成树状结构用来表达一个完整的表达式。第二个例子展现了一个完整的 Elixir 模块是若是用一个简单的 AST 结构来表示的。

其实一直以来,你所编写 Elixir 代码都是用这种简单一致的结构来展示的。理解这种结构,只须要了解几条简单规则就好了。全部的 Elixir 代码都表示为一系列的三元组,其格式以下:

  • 第一个元素是一个 atom,表示函数调用,或者是另外一个元组,表示 AST 中嵌套的节点。
  • 第二个元素是表达式的元数据。
  • 第三个元素是一个参数列表,用于函数调用。

咱们用这个规则来分解下上面例子中 (5 * 2) - 1 + 7这个表达式的 AST:

iex(1)> quote do: (5 * 2) - 1 + 7
{:+, [context: Elixir, import: Kernel],
 [{:-, [context: Elixir, import: Kernel],
  [{:*, [context: Elixir, import: Kernel], [5, 2]}, 1]}, 7]}

咱们看到 AST 格式就是一棵函数和其参数构成的树。咱们对输出结构美化下,把这棵树看得更清楚些:

让咱们从 AST 的终点向下遍历,AST 的 root 节点是 + 操做符,参数是数字 7 和另外一个嵌入节点。咱们看到嵌入节点包含 (5*2)表达式,它的计算结果又用于 - 1 这条分支。你应该还记得 5 * 2在 Elixir 中不过是 Kernel.*(5,2)调用的语法糖。这样咱们的表达式更容易解码。原子 :*,就是个函数调用,元数据告诉咱们它是从 Kernel import 过来的。后面的元素 [5,2] 就是 Kernel.*/2函数的参数列表。所有的程序都是这样经过一个简单 Elixir 元组构成的树来表示的。

高阶语法 vs. 低阶 AST

要理解 Elixir 语法跟 AST 背后的设计哲学,最好的办法莫过于拿来同其余语言比较一下,看看 AST 处于什么位置。在某些语言当中,好比颇有个性的 Lisp,它直接用 AST 编写,用括号组织表达式。若是你看的仔细,会发现 Elixir 某种程度上也是这种格式。

Lisp: (+ (* 2 3) 1)

Elixir(这里去掉了元数据)

quote do: 2 * 3 + 1
{:+, _, [{:*, _, [2, 3]}, 1]}

若是你比较 Elixir AST 跟 Lisp 的源码,将括号都换成圆括号,就会发现他们的结构基本上都是同样的。Elixir 干的漂亮的地方在于从高阶的源码转换到低阶的 AST 只须要一个简单的 quote 调用。而对于 Lisp,你确实拥有了可编程的 AST 的所有威力,可代价是不够天然不够灵活的语法。José 革命性的创新就在于将语法同 AST 分离。在 Elixir 中,你能够同时拥有这两样最好的东西:可编程的 AST,以及可经过高阶语法进行访问。

AST 字面量

当你开始探索 Elixir 源码是如何用 AST 表达时,有时会发现 quoted 的表达式看上去使人困惑,彷佛也不大规范。要破解这个困惑,你须要知道 Elixir 中的一些字面量在 AST 中和高阶源码中的表现形式是同样的。这包括 atom,整数,浮点数,list,字符串,还有任意的包含 former types 的二元组。例如,下面这些字面量在 quoted 时直接返回自身:

iex> quote do: :atom
:atom
iex> quote do: 123
123
iex> quote do: 3.14
3.14
iex> quote do: [1, 2, 3]
[1, 2, 3]
iex> quote do: "string"
"string"
iex> quote do: {:ok, 1}
{:ok, 1}
iex> quote do: {:ok, [1, 2, 3]}
{:ok, [1, 2, 3]}

若是咱们将上述例子传递给一个宏,那么宏接受的也只会是参数的字面量形式,而不是抽象表达形式。若是 quote 其余的数据类型,咱们就会看到,获得的是抽象形式:

iex> quote do: %{a: 1, b: 2}
{:%{}, [], [a: 1, b: 2]}

iex> quote do: Enum
{:__aliases__, [alias: false], [:Enum]}

上述 quoted 的演示告诉咱们 Elixir 的数据类型在 AST 里面有两种不一样的表现形式。一些值会直接传递,而一些复杂的数据类型会转换成 quoted 表达式。编写宏时牢记这些字面量规则是颇有好处的,咱们也就不会为参数究竟是不是抽象格式而困惑了。

如今咱们已经为理解 AST 结构打好了基础,是时候开始进行代码生成练习了,也能够验证下新知识。下一步,咱们会探索如何利用 Elixir 宏系统来转换 AST。

宏:Elixir 的基本构建部件(Building Blocks)

该干干脏活了,咱们看看宏究竟是什么。我向你许诺过能够定制语言特性,如今咱们就从重建一个 Elixir 特性开始吧。经过这个练习,咱们会揭示宏的基本特性,同时看到 AST 是如何融入其中的。

重建 Elixir 的 unless 宏

咱们如今假设 Elixir 语言根本没有内建 unless 结构。在大多数语言当中,咱们不得不退而求其次,使用 if !表达式来替代它,并且只能无奈地接受。

对咱们很幸运,Elixir 不是大多数语言。让咱们定义本身的 unless 宏,利用已有的 if 做为咱们实现的基础部件。宏必须定义在模块内部,咱们定义一个 ControlFlow 模块。打开编辑器,建立 unless.exs 文件:

macros/unless.exs

defmodule ControlFlow do
  defmacro unless(expression, do: block) do 
    quote do
      if !unquote(expression), do: unquote(block)
    end
  end
end

在同一目录下打开 iex,测试一下:

iex> c "unless.exs"
[ControlFlow]

iex> require ControlFlow
nil

iex> ControlFlow.unless 2 == 5, do: "block entered"
"block entered"

iex> ControlFlow.unless 5 == 5 do
...>   "block entered"
...> end
nil

咱们必需要在模块未被 imported 时,在调用以前 require ControlFlow。由于宏接受 AST 形式的参数,咱们能够接受任何有效的 Elixir 表达式做为 unless 的第一个参数。第二个参数,咱们直接经过模式匹配获取 do/end 块,将 AST 绑定到一个变量上。必定要记住,一个宏其生命期的职责就是获取一个 AST 形式,而后返回一个 AST 形式,所以咱们立刻用 quote 返回了一个 AST。在 quote 内部,咱们作了一个单行的代码生成,将 unless 关键字转换成了 if !表达式:

quote do
  if !unquote(expression), do: unquote(block)
end

这种转换咱们称之为宏展开(macro expansion)。unless 最终返回的 AST 将会于编译时,在调用者的上下文(context)中展开。在 unless 使用的任何地方,产生的代码将会包含一个 if !表达式。这里咱们还使用了前面在 Math.say 中用到的 unquote 宏。

unquote

unquote 宏容许将值就地注入到 AST 中。你能够把 quote/unquote 想象成字符串中的插值。若是你建立了一个字符串,而后要将一个变量的值注入到字符串中,你会对其作插值的操做。构建 AST 也是相似的。咱们用 quote 生成一个 AST(存入变量-译注),而后用 unquote 将(变量值-译注)值注入到一个外部的上下文。这样就容许外部的绑定变量,表达式或者是 block,可以直接注入到咱们的 if ! 变体中。

咱们来测试一下。咱们使用 Code.eval_quote 来直接运行一个 AST 而后返回结果。在 iex 中输入下面这一系列表达式,而后分析每一个变量在求值时有何不一样:

iex> number = 5
5

iex> ast = quote do 
...>   number * 10
...> end 
{:*, [context: Elixir, import: Kernel], [{:number, [], Elixir}, 10]} 

iex> Code.eval_quoted ast
** (CompileError) nofile:1: undefined function number/0 

iex> ast = quote do 
...>   unquote(number) * 10
...> end 
{:*, [context: Elixir, import: Kernel], [5, 10]}

iex> Code.eval_quoted ast
{50, []}

在第7行咱们看到第一次 quoted 的结果并无被注入到返回的 AST 中。相反,产生了一个本地 number 引用的 AST,所以运行时抛出一个 undefined 错误。咱们在第13行使用 unquote 正确地将 number 值注入到 quoted 上下文中,修复了这个问题。对最终的 AST 求值也返回了正确结果。

使用 unquote,咱们的百宝箱里又多了一件元编程的工具。有了 quote 跟 unquote 的成对使用,构建 AST 时,咱们就不须要再笨手笨脚的手工处理 AST 了。

宏展开

让我深刻 Elixir 内部,去探寻在编译时宏到底发生了什么。当编译器碰见一个宏,就会递归地展开它,直到代码再也不包含任何宏。下面这幅图描述了一个简单的 ControlFlow.unless 表达式的高阶处理流程。

这幅图片显示了编译器在遇到 AST 宏时的处理策略,就是将它展开。若是展开的代码依然包含宏,那就所有展开。这种展开递归地进行直到全部的宏都已经所有展开成他们最终的生成代码形式。如今咱们想象一下编译器遇到下面这个代码块时:

ControlFlow.unless 2 == 5 do
  "block entered"
end

How Elixir Expands Macros

咱们知道 ControlFlow.unless 宏会生成一个 if ! 表达式,所以编译器会将代码展开成下面的样子:

if !(2 == 5) do
  "block entered"
end

如今编译器又看到了一个 if 宏,而后继续展开代码。可能你还不知道,但是 Elixir 的 if 是在内部经过 case 表达式实现的一个宏。所以最终展开的代码变成了一个基本的 case 代码块:

case !(2 == 5) do
  x when x in [false, nil] ->
    nil
  _ ->
    "block entered"
end

如今代码再也不包含任何可展开的宏了,编译器完成它的工做而后继续编译其它代码去了。case 宏属于一个最小规模宏集合的一员,它位于 Kernel.SpecialForms 中。这些宏属于 Elixir 的基础构建部分(building blocks),绝对不可以覆盖篡改。它们也是宏扩展的尽头。

让咱们打开 iex 跟随前面的流程,看下 AST 是如何一步步展开的。咱们使用 Macro.expand_once 在每一步捕获结果后展开一次。注意要在与 unless.exs 文件相同目录中打开 iex,输入下面表达式:

iex> c "macros/unless.exs"
[ControlFlow]

iex> require ControlFlow
nil

iex> ast = quote do
...>   ControlFlow.unless 2 == 5, do: "block entered"
...> end
{{:., [], [{:__aliases__, [alias: false], [:ControlFlow]}, :unless]}, [],
 [{:==, [context: Elixir, import: Kernel], [2, 5]}, [do: "block entered"]]}

iex> expanded_once = Macro.expand_once(ast,__ENV__)
{:if, [context: ControlFlow, import: Kernel],
 [
   {:!, [context: ControlFlow, import: Kernel],
    [{:==, [context: Elixir, import: Kernel], [2, 5]}]},
   [do: "block entered"]
 ]}

iex> expanded_fuly = Macro.expand_once(expanded_once,__ENV__)
{:case, [optimize_boolean: true],
 [
   {:!, [context: ControlFlow, import: Kernel],
    [{:==, [context: Elixir, import: Kernel], [2, 5]}]},
   [
     do: [
       {:->, [],
        [
          [
            {:when, [],
             [
               {:x, [counter: -576460752303423452], Kernel},
               {{:., [], [Kernel, :in]}, [],
                [{:x, [counter: -576460752303423452], Kernel}, [false, nil]]}
             ]}
          ],
          nil
        ]},
       {:->, [], [[{:_, [], Kernel}], "block entered"]}
     ]
   ]
 ]}
iex>

第7行,咱们 quote 了一个简单的 unless 宏调用。接下来,咱们第13行使用 Macro.expand_once 来展开宏一次。咱们能够看到 expanded_once AST 被转换成了 if ! 表达式,正如咱们在 unless 中定义的。最终,在第18行咱们彻底将宏展开。expanded_fully AST 显示 Elixir 中的 if 宏最终彻底被分解为最基础的 case 表达式。

这个练习只为展现 Elixir 宏系统构建的本质。咱们三次进入代码构造,而后依赖简单的 AST 转换生成了最终结果。Elixir 中的宏一以贯之。这些宏让这门语言可以构建自身,咱们本身的库也彻底能够利用。

代码的多层次展开听上去不大安全,但不必担忧。Elixir 有办法保证宏运行时的安全。咱们看下是如何作到的。

代码注入和调用者上下文

宏不光是为调用者生成代码,还要注入他。咱们将代码注入的地方称之为上下文(context)。一个 context 就是调用者的 bindings,imports,还有 aliases 能看到的做用域。对于宏的调用者,context 很是宝贵。它可以保持你眼中世界的样貌,并且是不可变的,你可不会但愿你的变量,imports,aliases 在你不知道的状况下偷偷改变吧。

Elixir 的宏在保持 context 安全性跟必要时容许直接访问二者间保持了优秀的平衡。让咱们看看如何安全地注入代码,以及有何手段能够访问调用者的 context。

注入代码

由于宏所有都是关于注入代码的,所以你必须得理解宏运行时的两个 context,不然代码极可能在错误的地方运行。一个 context 是宏定义的地方,另一个是调用者调用宏的地方。让咱们实战一下,定义一个 definfo 宏,这个宏会以友好格式输出模块信息,用于显示代码执行时所在的 context。建立 callers_context.exs 文件,输入代码:

macros/callers_context.exs

defmodule Mod do
  defmacro definfo do
    IO.puts "In macro's context (#{__MODULE__})." 

    quote do
      IO.puts "In caller's context (#{__MODULE__})." 

      def friendly_info do
        IO.puts """
        My name is #{__MODULE__}
        My functions are #{inspect __info__(:functions)}
        """
      end
    end
  end
end

defmodule MyModule do
  require Mod
  Mod.definfo
end

进入 iex,加载文件:

iex> c "callers_context.exs"
In macro's context (Elixir.Mod).
In caller's context (Elixir.MyModule).
[MyModule, Mod]

iex> MyModule.friendly_info
My name is Elixir.MyModule
My functions are [friendly_info: 0]

:ok

咱们能够从标准输出看到,当模块编译时咱们分别进入了宏和调用者的 context。第3行在宏展开前,咱们进入了 definfo 的 context。而后第6行在 MyModule 调用者内部生成了展开的 AST,在这里 IO.puts 被直接注入到模块内部,同时还单独定义了一个 friendly_info 函数。

若是你搞不清楚你的代码当前运行在什么 context 下,那就说明你的代码过于复杂了。要避免混乱的惟一办法就是保持宏定义尽量的简短直白。

保护调用者 Context 的卫生

(卫生宏:这个名称真难听,当初是谁第一个翻译的) Elixir 的宏有个原则要保持卫生。卫生的含义就是你在宏里面定义的变量,imports,aliases 等等根本不会泄露到调用者的空间中。在展开代码时咱们必须格外注意宏的卫生,由于有时候咱们万不得已仍是要采用一些不那么干净的手法来直接访问调用者的空间。

当我第一次了解到卫生这个词,感受听上去很是的尴尬和困惑--这真是用来描述代码的词吗。但若干介绍以后,这个关乎干净,无污染的执行环境的想法就彻底可以理解了。这个安全机制不但可以阻止灾难性的名字空间冲突,还能迫使咱们进入调用者的 context 时必须交代的清楚明白。

咱们已经见识过了代码注入如何工做,但咱们尚未在两个不一样 contexts 间定义或是访问过变量。让我探索几个例子看看宏卫生如何运做。咱们将再次使用 Code.eval_quoted 来执行一段 AST。在 iex 中输入以下代码:

iex> ast = quote do
...>   if meaning_to_life == 42 do
...>     "it's true"
...>   else
...>     "it remains to be seen"
...>   end
...> end

{:if, [context: Elixir, import: Kernel],
 [
   {:=, [], [{:meaning_to_life, [], Elixir}, 42]},
   [do: "it's true", else: "it remains to be seen"]
 ]}

iex> Code.eval_quoted ast, meaning_to_life: 42
** (CompileError) nofile:1: undefined function meaning_to_life/0

meaning_to_life 这个变量在咱们表达式的视野中彻底找不到,即使咱们将绑定传给 Code.eval_quoted 也不行。Elixir 的安全策略是你必须直白地声明,容许宏在调用者的 context 中定义绑定。这种设计会强制你思考破坏宏卫生是否必要。

破坏卫生

咱们能够用 var! 宏来直接声明在 quoted 表达式中须要破坏宏卫生。让咱们重写以前 iex 中的例子,使用 var! 来进入到调用者的 context:

iex> ast = quote do
...>   if var!(meaning_to_life) == 42 do
...>     "it's true"
...>   else
...>     "it remains to be seen"
...>   end
...> end

{:if, [context: Elixir, import: Kernel],
 [
   {:==, [context: Elixir, import: Kernel],
    [
      {:var!, [context: Elixir, import: Kernel],
       [{:meaning_to_life, [], Elixir}]},
      42
    ]},
   [do: "it's true", else: "it remains to be seen"]
 ]}

iex> Code.eval_quoted ast, meaning_to_life: 42
{"it's true", [meaning_to_life: 42]}

iex> Code.eval_quoted ast, meaning_to_life: 100
{"it remains to be seen", [meaning_to_life: 100]}

让我建立一个模块,在其中篡改在调用者中定义的变量,看看宏的表现。在 iex 中输入以下:

macros/setter1.exs

iex> defmodule Setter do
...>   defmacro bind_name(string) do
...>     quote do
...>       name = unquote(string)
...>     end
...>   end
...> end
{:module, Setter, ...

iex> require Setter
nil

iex> name = "Chris"
"Chris"

iex> Setter.bind_name("Max")
"Max"

iex> name
"Chris"

咱们能够看到因为卫生机制保护着调用者的做用域,name 变量并无被篡改。咱们再试一次,使用 var! 容许咱们的宏生成一段 AST,在展开时能够直接访问调用者的绑定: macros/setter2.exs

iex> defmodule Setter do
...>   defmacro bind_name(string) do
...>     quote do
...>       var!(name) = unquote(string)
...>     end
...>   end
...> end
{:module, Setter, ...

iex> require Setter
nil
iex> name = "Chris"
"Chris"
iex> Setter.bind_name("Max")
"Max"
iex> name
"Max"

经过使用 var!,咱们破坏了宏卫生将 name 从新绑定到一个新的值。破坏宏卫生通常用于一事一议的个案处理。固然一些高阶的手法也须要破坏宏卫生,但咱们通常应该尽可能避免,由于它可能隐藏实现细节,同时添加一些不为调用者所知的隐含行为。之后的练习咱们会有选择的破坏卫生,但那是绝对必要的。

使用宏时,咱们必定要清楚地知道宏运行在哪一个 context,同时要保持宏卫生。咱们体验过直接声明破坏卫生,用于探索宏在整个生命周期所进入的不一样 context。咱们要秉持这些信念来指导咱们后续的开发实践。

进一步探索

咱们已经揭开了抽象语法树的神秘面纱,它是支撑全部 Elixir 代码的基础。经过 quote 一个表达式,操纵 AST,定义宏,你的元编程之旅一路进阶。在后续的章节,咱们会建立更为高级的宏,用来定制语言结构,咱们还会编写一个迷你测试框架,能够推断 Elixir 表达式的含义。

至于你,须要将前面讲的知识点展开。这有一些想法你能够尝试一下:

  • 不依赖 Kernel.if 定义一个 unless 宏,使用其余的 Elixir 流程控制结构。
  • 定义一个宏用来返回手写代码的原始 AST,固然不许使用 quote 代码生成。
相关文章
相关标签/搜索