Skip to main content

第 1 章:引言(Introduction)

原文:Robert Nystrom, Crafting Interpreters, Chapter 1。原书以 CC BY-NC-SA 4.0 协议发布;本译文用于学习与研究。

童话不只是因为告诉我们龙存在而真实,更因为它告诉我们:龙可以被战胜。

G. K. Chesterton,经由 Neil Gaiman《Coraline》转引

我非常期待与你一起开始这段旅程。本书讲解如何为编程语言实现解释器,也讲解怎样设计一门值得实现的语言。这正是我刚接触编程语言时希望拥有的书,也是我在脑中酝酿了近十年的书。

接下来的篇章会一步一步构建两个功能完整语言的解释器。我假定这是你第一次深入编程语言,因此会覆盖构建完整、可用且快速的语言实现所需的每个概念和每一行代码。

为了在一本书中装下两套完整实现,又不把它写成一块门挡,本文比其他同类书更少侧重理论。构建系统的每个部分时,我会介绍其背后的历史与概念,并尽量让你熟悉相关术语;这样即使哪天置身于一场满是 PL(programming language,编程语言)研究者的鸡尾酒会,也能融入谈话。

不过,我们主要还是把脑力用在让语言真正运行起来。并非理论不重要:在处理语言时,能够精确且形式化地推理语法和语义是一项关键能力。但我个人通过动手学得最好。抽象概念堆成段落时,我很难真正消化;而一旦写过代码、运行过它、再调试过它,我就真正理解了。

这也是我为你设定的目标。我希望你离开本书时,对一门真实语言如何运作有扎实直觉。将来阅读更理论化的书籍时,那些概念也会牢牢附着在这些可触摸的实践基础上。

1.1 为什么要学这些?(Why Learn This Stuff?)

每一本编译器书的引言似乎都有这一节。我不知道为什么编程语言会招来如此强烈的存在主义怀疑。鸟类学书籍大概不会费心证明自己为何存在,它们默认读者喜欢鸟,然后直接开始教学。

但编程语言确实有些不同。我们之中任何人创造一门被广泛采用的通用语言,机会都很小。世界上广泛使用语言的设计者,即使不掀开大众面包车的车顶帐篷,也装得下一辆车。若学习语言的唯一理由就是加入这个精英群体,确实难以自圆其说。幸好理由不止这一个。

1.1.1 小语言无处不在(Little Languages Are Everywhere)

每出现一门成功的通用语言,就会有上千门成功的细分语言。它们以前叫作“小语言”,但术语经济通胀后有了“领域特定语言”(domain-specific language,DSL)这个名字。这些是为特定任务量身打造的混合语:应用脚本语言、模板引擎、标记格式和配置文件都属于此类。

原书插图:一些小语言示例。

几乎每个大型软件项目都需要若干这样的语言。能够复用已有方案时,最好不要从头造一个。把文档、调试器、编辑器支持、语法高亮和其他配套设施都算进去,自己完成它会是一项艰巨工程。

不过,你仍很可能遇到没有现成库满足需求,只能匆忙写一个解析器或其他工具的场景。即便复用了既有实现,最终也难免需要调试、维护并深入它的内部。

1.1.2 语言是极好的训练(Languages Are Great Exercise)

长跑运动员有时会绑着负重练习,或在空气稀薄的高海拔训练。之后卸下负担时,轻快的肢体和富氧空气带来的相对轻松感,会让他们跑得更快、更远。

实现一门语言是对编程技能的真正考验。代码复杂且对性能敏感;你必须掌握递归、动态数组、树、图和哈希表。日常编程中你或许至少会用到哈希表,但真的理解它吗?等我们从零亲手实现一个后,我保证你会理解。

我会告诉你,解释器没有想象中那么可怕;但要把它实现得好,仍然是一项挑战。迎接这个挑战后,你会成为更强的程序员,也会更清楚地知道如何在日常工作中使用数据结构和算法。

1.1.3 还有一个理由(One More Reason)

最后一个理由让我不太好意思承认,因为它离我的内心太近。从孩童时学会编程起,我就觉得语言带着某种魔力。第一次一个键一个键地敲入 BASIC 程序时,我无法想象 BASIC 本身是如何做出来的。

后来,大学朋友谈到编译器课程时脸上混合的敬畏与恐惧,足以让我相信语言黑客是另一类人类:仿佛获准接触秘术的巫师。

这个形象很迷人,却也有阴暗面。我并不觉得自己像巫师,于是以为自己缺少加入那个圈子所必需的天赋。自从在学校笔记本上涂写虚构关键字起,我就一直对语言着迷;但我花了数十年才鼓起勇气真正学习它们。那种“神奇”、那种排他感,把我自己排除在外。

这种形象的从业者也毫不犹豫地强化它:两本语言领域的奠基性著作,封面上分别有一条和一位巫师

后来我终于开始拼凑自己的小解释器,很快发现:当然根本没有魔法。它就是代码,研究语言的人也只是普通人。

语言领域的确有一些在别处不常遇到的技术,某些部分也有一点难度,但并不比你已克服的其他障碍更难。若你曾被语言吓住,而本书能帮你克服这种恐惧,希望我能让你比之前多一点勇气。

而且,谁知道呢,也许你会做出下一门伟大的语言。总得有人来做。

1.2 本书如何组织(How the Book Is Organized)

本书分为三部分。你正在阅读第一部分,它由几个章节构成,旨在帮助你熟悉方向、学习语言黑客使用的术语,并介绍我们将实现的语言 Lox。

后面两部分各自构建一个完整的 Lox 解释器。在这些部分中,每章遵循相同结构:选取一项语言特性,讲解其背后的概念,再带你完成实现。

我经过不少试错,才把两个解释器拆成以章节为单位的部分:它们建立在前面章节之上,却不依赖后续章节。从第一章起,你就会有一个可以运行和试验的程序;每经过一章,它的功能都会更完整,最终成为一门完整语言。

除了大量精彩的英文散文外,每章还有几种其他组成部分。

1.2.1 代码(The Code)

我们的目标是亲手“打造”解释器,所以本书包含真正的代码。实现所需的每一行代码都会给出,每个片段也会说明该插入不断增长的实现中的哪个位置。

很多其他语言书籍和语言实现会使用 Lex、Yacc 一类工具,即所谓的“编译器编译器”(compiler-compiler):它们从较高层的描述自动生成实现所需的部分源文件。这类工具各有利弊,两派对此也都有强烈观点,甚至可以说是信仰。

原书插图:一头牦牛。

本书不会使用它们。我想确保没有让魔法和困惑藏身的黑暗角落,因此所有内容都亲手编写。你会发现这没有听起来那么糟,也意味着你确实会理解每一行代码,以及两个解释器如何工作。

Yacc 接受文法文件并生成编译器源文件,像是“输出编译器的编译器”,所以才有 compiler-compiler 之名。它不是第一款此类工具,名称是 Yet Another Compiler-Compiler 的缩写;后来的 Bison 则取了 Yacc 发音近似“yak”的双关。

一头牦牛。

若觉得这种自指和双关有趣,你会很适合这里;否则,语言极客的幽默也许需要慢慢习惯。

书与“真实世界”的约束不同,所以这里的编码风格未必总是编写可维护生产软件的最佳方式。例如,我有时会省略 private 或声明全局变量;这样做只是为了让代码更容易阅读。书页不像 IDE 那样宽,每个字符都很宝贵。

代码中的注释也不多,因为每几行代码周围都有数段完整文字解释它们。若你也为自己的程序配套写一本书,当然可以少写注释;否则,还是应当比我多用一些 //

本书会包含实现所需的每一行代码,也会说明每一行的含义,但不会描述编译和运行解释器所需的工程配置。我假定你能写一个 makefile,或能在自己选择的 IDE 中建好项目。此类说明很快就会过时;我希望本书像陈年白兰地一样经得起时间,而不是像自酿劣酒一样迅速变质。

1.2.2 代码片段(Snippets)

由于书中逐行给出了实现所需代码,代码片段非常精确。又因为我尽量让程序即使缺少主要功能时也保持可运行,有时会先加入临时代码,稍后再由新的片段替换。

一个完整片段的中心是需要加入的新代码。它上下可能带有几行淡出的代码,用来说明它在既有代码中的位置;还会有一条简短说明,指出片段应放在哪个文件和什么位置。若说明写着“替换 _ 行”,表示淡出行之间已有一些代码,你需要删除并用新片段替换它们。

例如下面片段的中心是新增代码,淡出行只用于定位;说明中的“替换 1 行”表示需替换淡出行之间已有的一行:

default:
if (isDigit(c)) {
number();
} else {
Lox.error(line, "Unexpected character.");
}
break;

位置:lox/Scanner.javascanToken(),替换 1 行。

1.2.3 旁注(Asides)

旁注收录人物小传、历史背景、相关主题的引用,以及值得进一步探索的方向。理解后续内容不需要知道旁注中的信息,因此想跳过也可以。我不会评判你,只是可能会有一点难过。

当然,至少有些旁注确实如此;多数只是笨拙笑话和业余绘图。

1.2.4 挑战(Challenges)

每章末尾都有几道练习。它们不像教材习题集那样复习已讲过的内容,而是帮助你学到比本章更多的东西。它们迫使你离开引导路径自行探索:研究其他语言、弄清如何实现某项特性,或以其他方式走出舒适区。

攻克挑战后,你会带着更宽广的理解离开,也可能添上几处小擦伤。若你想待在观光巴士舒适的范围内,也可以跳过。书是你的。

请注意:挑战常会要求你修改正在构建的解释器。应在代码副本中完成这些修改,因为后续章节假定你的解释器保持原始、未被挑战的状态。

1.2.5 设计笔记(Design Notes)

多数“编程语言”书籍严格来说是编程语言实现书,很少讨论如何设计正在实现的语言。实现很有趣,因为它定义得十分精确;我们程序员似乎天生偏爱黑白分明、由一和零构成的事物。

我个人认为,世界不需要无止境地再实现 FORTRAN 77。总会有一天,你会开始设计一门新语言。进入这场游戏后,问题中柔软的人文一面就变得至关重要:哪些特性容易学习,如何平衡创新与熟悉感,哪种语法对谁更易读。

这些因素会深刻影响新语言的成败。我希望你的语言成功,因此有些章节最后会有一则“设计笔记”,针对编程语言中人性层面的一个角落写一篇短文。我不是专家,也不知道是否真有人是,所以请带着很大的保留阅读它们。这样它们会成为更有味道的思考素材,而这正是我的主要目的。

1.3 第一个解释器(The First Interpreter)

第一个解释器 jlox 将用 Java 编写,重点是概念。我们会用最简单、最清晰的代码正确实现语言语义。这能让我们熟悉基础技术,也能精确理解语言应当如何行为。

Java 很适合这个任务。它层次足够高,不会让我们被琐碎的实现细节压垮;同时也足够明确。与脚本语言不同,底层隐藏的复杂机制通常较少,并且静态类型会让你看清正在使用的数据结构。

本书使用 Java 与 C,但读者已经将代码移植到许多其他语言。若这两门语言不合口味,可从那些实现开始。

我还特意选择 Java,因为它是一门面向对象语言。这一范式在 20 世纪 90 年代席卷编程世界,如今是数百万程序员的主导思维方式。你很可能早已习惯用类和方法组织代码,所以我们会让你待在熟悉的区域。

虽然学术语言研究者有时看低面向对象语言,但现实是,即使语言实现工作也广泛使用它们。GCC、LLVM 和大多数 JavaScript 虚拟机都用 C++ 编写。面向对象语言无处不在,而且一门语言所用的工具和编译器,往往也用同一门语言编写。

编译器读取一种语言的文件,翻译它们,并输出另一种语言的文件。你可以用任何语言实现编译器,包括它所编译的同一门语言,这个过程称为“自举”(self-hosting)。有了另一门语言写成的编译器后,可以先用它编译自己的编译器一次;随后,已编译的版本就能编译自己的后续版本,原始的外部编译器版本便可弃用。这称为“引导”(bootstrapping),名字来自“拽着自己的鞋带把自己提起来”的形象。

原书插图:自举的幽默示意。

最后,Java 非常流行,很可能你已经会用它,因此开始本书时少需要学习一些内容。即使不熟悉 Java,也不必惊慌。我尽量只使用其很小的子集;除了使用 Java 7 的菱形操作符让代码稍简洁外,几乎没有“高级”特性。只要你会另一门面向对象语言,例如 C# 或 C++,也能顺利读下来。

第 II 部分结束时,我们会得到一个简单、可读的实现。它不算很快,但正确。不过这是借助 Java 虚拟机已有运行时设施才做到的;我们还想学习 Java 自身如何实现这些东西。

1.4 第二个解释器(The Second Interpreter)

因此下一部分会从头再来一次,不过这次使用 C。C 是理解实现究竟如何工作的完美语言:一直深入到内存中的字节与 CPU 中流动的指令。

使用 C 的重要原因,是为了展示 C 特别擅长的内容;但这意味着你需要相当熟悉它。你不必是 Dennis Ritchie 转世,但也不应害怕指针。

若尚未达到这个程度,先找一本 C 入门书认真学习,完成后再回来。作为回报,你会成为更强的 C 程序员。这很有用,因为大量语言实现都用 C 编写,例如 Lua、CPython 和 Ruby 的 MRI。

在 C 版解释器 clox 中,Java 免费提供的一切都必须自己实现。我们会写自己的动态数组和哈希表,决定对象在内存中的表示方式,并构建垃圾收集器来回收它们。

作者把 clox 读作“sea-locks”,但读成“clocks”,甚至像希腊人那样发 x 音的“cloch”也完全可以。

Java 实现关注正确性。掌握它之后,我们还要追求速度。C 解释器会包含一个编译器,将 Lox 翻译成高效的字节码表示(别担心,很快会解释它的含义),然后执行这些字节码。这也是 Lua、Python、Ruby、PHP 等许多成功语言实现采用的技术。

我们还会尝试基准测试与优化。最终会拥有一套健壮、准确且快速的语言解释器,能够与其他专业级实现竞争。一本书加上几千行代码就能做到,不赖吧。

挑战(Challenges)

  1. 作者为编写和发布本书搭建的小系统中,至少使用了六种领域特定语言。它们分别是什么?
  2. 编写并运行一个 Java 的“Hello, world!”程序。准备好运行它所需的 makefile 或 IDE 项目;若有调试器,请熟悉它,并在程序运行时逐步执行。
  3. 对 C 做同样的事。为了练习指针,定义一个由堆分配字符串构成的双向链表,编写插入、查找与删除元素的函数,并测试它们。

设计笔记:名字里有什么?(What's in a Name?)

为本书实现的语言取名,是写作本书时最困难的挑战之一。我翻过好几页候选名才找到合适的名字。等你开始构建自己的语言第一天,就会发现命名有多么狡猾地困难。一个好名字要满足几项标准:

  1. 尚未被使用。 无意间踩到别人的名字,可能引发各种法律与社会层面的麻烦。
  2. 容易发音。 若事情顺利,会有很多人说出和写下语言名称。超过两三个音节或一小把字母的名称,会让他们烦不胜烦。
  3. 足够独特,方便搜索。 人们会搜索语言名称来学习它,因此希望这个词足够少见,让大多数结果指向你的文档。如今搜索引擎塞入大量 AI 后,这个问题没那么严重了;但把语言命名为 for,仍不会给用户带来任何便利。
  4. 在多种文化中没有负面含义。 这很难完全防范,却值得考虑。Nimrod 的设计者最终把语言改名为“Nim”,因为太多人记得 Bugs Bunny 把“Nimrod”当作侮辱词使用,尽管 Bugs 的用法本是反讽。

候选名称通过这道关卡后,就保留它。不要执着于寻找一个能捕捉语言本质的称号。其他成功语言的名字已经告诉我们:名字本身并不那么重要,你只需要一个足够独特的标记。