Logo
Section 1 / 8

元编程简介

学习宏和抽象语法树如何实现元编程。

导入

元编程简介

学习宏和抽象语法树如何实现元编程。

本课程将教你如何编写“能生成代码的代码”——即使用宏(macros)。
Elixir 的 宏 支持元编程,让你轻松编写功能强大的程序。

“能生成代码的代码”听起来像是一个巧妙的技巧,但很快你就会发现,这正是 Elixir 语言自身构建的基础。

我们可以引入强大的第一类特性来扩展语言,节省时间,并以有趣且高效的方式共享功能。元编程使我们能够写出清晰、简洁的程序,将源代码视为可组合的构建模块,而非机械重复的指令序列。

Course Image

世界就是你的游乐场

在 Elixir 中,元编程的核心在于可扩展性。你是否曾希望自己的编程语言能加入某个特别实用的功能?如果幸运的话,可能要等上好几年才能实现;很多时候,这个功能根本不会被采纳。但在 Elixir 中,我们可以随时引入新的第一类特性。

让我们以常见的 while 循环为例,它在大多数语言中都很常见,但 Elixir 中没有。我们可以设想这样写一个:

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

在本课程中,我们将让这个 while 循环真正可用。不仅如此,我们还能利用 Elixir 语言构建全新的领域特定语言(DSL),用自然的语法表达各种问题。下面是一个合法的 Elixir 程序示例:

1div do 2 h1 class: "title" do 3 text "Hello" end 4 p do 5 text "Metaprogramming Elixir" 6 end 7end 8# 输出: 9# <div><h1 class="title">Hello</h1><p>Metaprogramming Elixir</p></div>

Elixir 让像 HTML 领域特定语言 (DSL) 这样的应用成为可能,而我们也会在本课程中亲手实现它。

关于这些机制如何工作,我们将在后续内容中深入讲解。现在,请记住:宏 是这一切实现的关键。Elixir 将这一理念推向了前所未有的高度。

两个核心概念

让我们快速回顾 Elixir 元编程中的两个核心概念,并理解它们如何协同工作。

  1. 抽象语法树(AST)
  2. 宏(Macros)

抽象语法树

要掌握元编程,首先要理解 Elixir 代码在内部是如何通过 抽象语法树(AST)表示的。大多数我们熟悉的编程语言都有 AST,但通常我们并不察觉它的存在。

当程序被编译或解释时,源代码会先被转换为树状结构,再转化为字节码或机器码。这一过程通常是隐藏的,我们很少去思考它。

然而,Elixir 创始人 José Valim 做了一件非常不同的事:他将 AST 以 Elixir 自身的数据结构形式暴露出来,并提供了自然的语法来与之交互。

这种让普通 Elixir 代码直接访问 AST 的设计,使我们能够执行原本只有编译器和语言设计师才能完成的强大操作。在整个元编程过程中,我们始终与 Elixir 的 AST 打交道。接下来,就让我们深入了解它的本质。

Elixir 的元编程围绕着对 AST 的操作与检查展开。我们可以使用 `quote` 宏获取任意 Elixir 表达式的 AST 表示。代码生成高度依赖 `quote`,我们在整个课程中都会频繁使用它来完成练习。

让我们用它来查看几个基础表达式的 AST 表示。

在下方终端中执行以下命令:

1quote do: 1 + 2 2quote do: div(10, 2)

注意: 你可以自行尝试更多表达式,探索它们的 AST 结构。

Course Image
Terminal

我们可以看到,1 + 2 和 div 的 AST 表示在 Elixir 中表现为简单的数据结构。请花点时间体会这一点:我们能够将自己编写的任何代码,以 Elixir 数据结构的形式进行访问。

对表达式进行“引用”(quote)赋予了语言一项独特的能力:能够在我们已熟悉的数据结构中,窥探代码的内部表示。

这让我们可以在不脱离 Elixir 高层语法的前提下,推断语义、优化性能或扩展功能。

拥有完整的 AST 访问权限,我们甚至可以在编译阶段实现一些精巧的操作。例如,Elixir 标准库中的 Logger 模块可以完全移除某些表达式,从而实现优化。

假设我们在写文件时,希望在开发环境中打印文件路径,但在生产环境中忽略该日志。我们可以这样写:

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

在生产环境中,Logger.debug 的表达式会被完全从程序中移除。这是因为我们在编译阶段可以与 AST 交互,跳过这些仅用于开发的调用。

大多数语言必须在运行时调用调试函数,并浪费 CPU 时间去检查是否应忽略日志级别,因为它们的源代码无法与底层的 AST 进行交互。

而 Logger.debug 能够实现这种优化,这引出了元编程过程中的下一个关键要素:宏。

宏

宏是“能生成代码的代码”。它们的目的在于使用 Elixir 的高层语法与 AST 交互。这正是 Logger.debug 能够实现优化技巧的原因,同时又看起来像普通的 Elixir 代码。

宏被广泛应用于构建 Elixir 标准库,也作为 Web 框架的核心基础设施。无论哪种场景,都遵循相同的元编程规则。

我们不必在复杂高性能代码与优雅低效 API 之间做选择。Elixir 的宏让我们既能写出简洁的代码,又能获得高性能。它们让程序员从语言使用者转变为语言创造者。我们不再只是语言的用户,而是拥有了与 Elixir 创造者编写标准库时同等的工具和力量。

也许你会觉得,自己到目前为止几乎没用过宏,但实际上,它们一直就在我们身边。

看看这段看似普通的代码:

1defmodule Notifier do 2 def ping(pid) do 3 if Process.alive?(pid) do 4 Logger.debug "Sending ping!" 5 send pid, :ping 6 end 7 end 8end

它看起来平平无奇,但其实我们正面对着四个宏:defmodule、def、if,甚至 Logger.debug 都是通过宏实现的——正如 Elixir 大多数顶层构造一样。

在 iex 中查阅文档,可以看到 if 宏的定义。该宏的第一个参数是条件,其余为关键字参数。

1iex> h if 2 defmacro if(condition, clauses)

你可能会疑惑:为什么 Elixir 要用宏来实现自身的构造?毕竟大多数语言没有这种结构也能正常运行。最大的优势在于:宏允许我们用现有的宏作为基石,扩展出自己的关键字。

理解 Elixir 元编程的最佳方式,是摒弃对固定关键字和封闭语言内部机制的刻板印象。Elixir 从设计之初就考虑到了可扩展性。语言向我们开放,欢迎探索与自定义功能。这正是 Elixir 元编程如此自然流畅的原因所在。

简介

将抽象语法树(AST)与宏结合起来