1. 前言
童话故事是不会骗人的:不仅是因为它告诉我们存在龙,而是它告诉我们可以击败龙。
—— 尼尔·盖曼, 《寻梦环游记》
我真的十分兴奋我们能一起踏上这段旅程。这是一本为编程语言实现解释器的书。它也是一本关于如何设计一门语言而有价值的书。我刚开始接触编程语言的时候就希望我可以写出这本书,这本书在我脑子里已经酝酿将近十年了。
在本书中,我们将一步一步地用两个不同的解释器来实现一个功能齐全的语言。我假设这是您第一次接触编程语言,因此我将介绍涉及语言的每个概念和代码片段,来快速构建一个完整且可用的语言。
为了在一本书中塞进两个完整的实现而不变成一个门槛,所以本书在理论上比其他书更简单。在构建系统的每个模块时,我将介绍它背后的历史和概念。我会尽力让您熟悉这些行话,即便您在充满PL(编程语言)研究人员的鸡尾酒会中,也能快速融入其中。
但我们主要还是要花费精力实现这门语言并运转起来。这并不是说理论不重要。在学习一门语言时,能够对语法和语义进行精确而公式化的推理1是一项至关重要的技能。对我来说,要深入阅读那些充满抽象概念的段落并真正理解它们太难了。但是如果我根据理论编写了代码,运行并调试完成它,那么我就明白了这些概念,这对个人来说通过实践来达到最好的学习效果。
这就是我对您的期望。我想让你们直观地理解一门真正的语言是如何诞生与发展的。我希望当你以后阅读其他理论性更强的书籍时,这些概念会牢牢地扎根在你的脑海中,让你有直观的认识。
1.1 为什么要学习这些?
每本编译器书籍的导言似乎都有这一部分,我不知道是什么对编程语言存在性让人产生质疑。我不认为鸟类学书籍会为它们的存在辩护。它们假定读者喜欢鸟类,然后开始教学。
但是编程语言有一点不同。我不否认,对我们中的任何一个人来说,能够创建一种广泛成功的通用编程语言的可能性都很小。其实能设计出世界上广泛使用语言的设计师们,一辆大众巴士就能装得下。如果只报着想成为这种语言设计大师来学习语言,这一点显然不合理,事实上我们大多数人学习编译器也并不是只冲着这一点来。
1.1.1 小型语言无处不在
对于每一种成功的通用语言,伴随着上千种成功的小众语言。我们过去称它们为“小语言”,但在术语泛滥的今天称它们为“领域特定语言(即DSL)”。这些是为特定任务量身定做的混杂语言2,如应用程序脚本语言、模板引擎、标记格式和配置文件。
几乎每个大型软件项目都需要一些这样的工具。如果可以的话,最好重用现有的工具,而不是自己动手再造一个。一旦考虑到文档、调试器、编辑器支持、语法高亮显示和所有其他可能的特性,完全自己实现就成了一项艰巨的任务。
但是,当现有的库不能满足您的需要时,您有一个好的机会是自己去创建一个解析器或其他工具。即使当您重用一些现有的实现时,也不可避免地需要调试和维护,并在深入其内部进行研究。
1.1.2 语言是很好的锻炼
长跑运动员有时会在脚踝上绑上重物,或者在空气稀薄的高海拔地区进行训练。当他们卸下自己的负担以后,灵活的肢体和富氧的空气带来了更好的舒适度,使他们可以跑得更远,更快。
实现一门语言是对编程技能的真正考验。代码很复杂,而且性能很关键。您必须掌握递归、动态数组、树、图和哈希表。您在日常编程中至少使用过哈希表,但到底您对它们的理解程度有多高呢?嗯,等我们从头完成我们的作品之后,我相信您会理解的。
虽然解释器并不像您想的那样令人生畏,但实现一个好的解释器仍然是一个挑战。学会了它,您就会成为一个更强大的程序员,并且在日常工作中也能更加灵活地使用数据结构和算法。
1.1.3 另一个原因
最后一点,我内心一直有个疑惑,那就是自从我小时候学会编程以来,我就觉得语言有种神奇的魔力。当我第一次一个键一个键地输入BASIC程序时,没想明白BASIC语言本身是如何制作出来的。
后来,当我的大学朋友们谈论他们的编译器课程时,他们脸上那种既敬畏又恐惧的表情足以让我相信,语言黑客是另一种人,是可以操控某种神秘艺术特权的巫师。
巫师既迷人又也有他黑暗的一面,我自认缺乏加入秘社所需的先天品质,所以我认为自己不像个巫师。自从我在学校笔记本上拼写关键词以来,我一直就对语言着迷,我花了数十年的时间鼓起勇气尝试真正地学习它们。但那种“神奇”的品质,那种排他性的感觉,将我挡在门外。
当我最终开始拼凑我自己的小解释器时,我很快意识到,根本就没有魔法。它只是代码,而那些语言大师也只是普通人。
有一些技巧您在语言之外不会经常遇到,而且有些部分有点难。但不会比您克服的其他障碍更困难。我希望,如果您对语言感到害怕,而这本书能帮助您克服这种恐惧,也许我会让您比以前更勇敢一点。
最后,说不准,你也许会创造出下一个伟大的语言,毕竟总要有人做。
1.2 本书的组织方式
这本书分为三个部分。您现在正在读的是第一部分。这部分用了几章来让您进入状态,教您一些语言黑客使用的行话,并向您介绍我们将要实现的语言Lox。
其他两个部分则分别构建一个完整的Lox解释器。在这些部分中,每个章节的结构都是相同的。 每一章节挑选一个语言功能点,介绍背后对应的概念,并逐步介绍实现方法。
对我来说需要花不少时间去尝试和试错,但最后还是成功地把这两个解释器按照章节分块,每一小块的内容都会建立在前面几章的基础上,而不需要后续章节的知识。从第一章开始,你就会拥有一个可以运行和使用的工作程序。随着章节的推移,它的功能越来越丰富全面,直到你最终拥有一门完整的语言。
除了大量妙趣横生的英文段落,章节中还会包含一些其它的惊喜:
1.2.1 代码
本书的内容是关于实现解释器的,所以包含真正的实现代码。所需要的每一行代码都在本书内展现,而且每个代码片段都会告知您需要插入到代码中的具体位置。
许多其他的语言书籍和语言实现都使用Lex和Yacc3这样的工具,也就是所谓的编译器-编译器,这种工具从一些更高级别的(语法)描述中自动生成一些实现的源代码文件。这些工具有利有弊,而且双方都有强烈的意见--有些人可能将其说成是宗教信仰。
我们这里不会使用这些工具。我想确保魔法和困惑不会藏在黑暗的角落,所以我们会选择手写所有代码。正如您将看到的,这并没有听起来那么害怕,因为这有助于您将真正理解每一行代码以及两种解释器的工作方式。
为了写书,书中代码和“真实世界”的代码是有区别的,因此这里的代码风格可能并不是编写可维护的生产型软件的最佳方式。可能我的某些写法是不太规范,比如省略private或者声明全局变量,请理解我这样做是为了让您更容易看懂代码。书页不像IDE窗口那么宽,所以每一个字符都很珍贵。
另外,代码也不会有太多的注释。这是因为每一部分代码前后,都使用了一些真的很简洁的文字来对其进行解释。当你写一本书来配合你的程序时,欢迎你也省略注释。否则,你可能应该比我使用更多的 //。
虽然这本书包含了每一行代码,并讲授了每一行代码的含义,但它没有描述编译和运行解释器所需的机制。我假设你可以简单地拼凑出一个makefile,或者在一个熟悉的IDE中的创建一个工程,来让代码运行起来。那些简单教程很快就会过时,我希望这本书能像XO白兰地一样醇久,而不是像家酿酒(一样易过期)。
1.2.2 片段
由于本书包含了实现所需的每一行代码,所以代码片段相当准确。此外,即使是在缺少主要特征的时候,我也尝试将程序保持在可运行状态。因此我们有时会添加临时代码,这些代码将在后续被其他的代码片段替换。
一个完整的代码片段可能如下所示:
中间是要添加的新代码。这部分代码的上面或下面可能有一些淡出的行,以显示它在周围代码中的位置。还会附有一小段介绍,告诉您在哪个文件中以及在哪里放置代码片段。如果简介说要“replace _ lines”,表明在浅色的行之间有一些现有的代码需要删除,并替换为新的代码片段。
1.2.3 题外话
题外话中包含传记简介、历史背景、对相关主题的引用以及对其他要探索的领域的建议。 您无需深入了解就可以理解本书的后续部分,因此可以根据需要跳过它们。 我不会批评你,但我可能会有些难过。
1.2.4 挑战
每章结尾都会有一些练习题。不像教科书中的习题集那样用于回顾已讲述的内容,这些习题是为了帮助您学习更多的知识,而不仅仅是本章中的内容。它们会迫使您走出文章已有的路线,需自行探索。它们将要求您研究其他语言,弄清楚如何实现功能,换句话说,就是使您走出舒适区。
克服挑战,您将获得更广泛的理解,也可能遇到一些挫折。如果您想留在旅游巴士的舒适区内,也可以跳过它们,都随你便4。
1.2.5 设计笔记
大多数编程语言书籍都是严格意义上的编程语言实现书籍。他们很少讨论如何设计正在实现的语言。实现之所以有趣,是因为它的定义是很精确的。我们程序员似乎很喜欢黑白、1和0这样的事物5。
就个人而言,我认为世界只需要这么多的FORTRAN 77实现。在某个时候,您会发现自己正在设计一种新的语言。一旦开始这样做,简明和人性化的一面就变得至关重要。诸如哪些功能易于学习,如何在创新和熟悉度之间取得平衡,哪种语法更易读以及对谁有帮助6。
所有这些都会对您的新语言的成功产生深远的影响。 我希望您的语言取得成功,因此在某些章节中,我以一篇“设计笔记”结尾,这些是关于编程语言的人文方面的一些文章。我并不是这方面的专家——我不确定是否有人真的精通这些,因此,请您在阅读这些文字的时候仔细评估。这样的话,这些文字就能成为您思考的食材,这也正是我的目标。
1.3 第一个解释器
我们将用Java编写第一个解释器jlox。重点关注是概念。 我们将编写最简单,最易读的代码,以正确实现该语言的语义。 这样能够帮助我们熟悉基本技术,并磨练对语言表现形式的确切理解。
Java是一门很适合这种场景的语言。它的功能很强大,我们不会被繁琐的实现细节淹没,但代码仍是非常明确的。与脚本语言不同的是,它的底层没有隐藏太过复杂的机制,你可以使用静态类型来查看正在处理的数据结构。
我选择Java还有特别的原因,就是因为它是一种面向对象的语言。 这种范式在90年代席卷了整个编程世界,如今已成为数百万程序员的主流思维方式。 很有可能您已经习惯了将代码组织到类和方法中,因此我们将让您在舒适的环境中学习。
虽然学术语言专家有时瞧不起面向对象语言,但事实上,它们即使在语言工作中也被广泛使用。GCC和LLVM是用C++编写的,大多数JavaScript虚拟机也是这样。 面向对象的语言无处不在,并且针对该语言的工具和编译器通常是用同一种语言编写的7。
最后,Java非常流行。这意味着您很有可能已经了解它了,所以你要学习的东西就更少了。如果您不太熟悉Java,也请不要担心。我尽量只使用它的最小子集。我使用Java7中的菱形运算符使代码看起来更简洁,但就“高级”功能而言,仅此而已。如果您了解其它面向对象的语言(例如C#或C++),就没有问题。
在第二部分结束时,我们将得到一个简单易读的实现。但是我们得到的不会是一个执行效率高的解释器。它还是利用了Java虚拟机自身的运行时工具。我们想要学习Java本身是如何实现这些东西的。
1.4 第二个解释器
所以在下一部分,我们将从头开始,但这一次是用C语言。C语言是理解实现编译器工作方式的完美语言,从内存中的字节到流经CPU的代码。
我们使用C语言的一个重要原因是,我可以向您展示C语言特别擅长的指针,但这并不意味着您需要非常熟练地使用它。您不必是丹尼斯·里奇(Dennis Ritchie)的转世,但也不应被指针吓倒。
如果你对C的掌握还没到那一步,建议找一本关于C的入门书,仔细阅读,读完后再回来。读完本书你将成为一名更优秀的C程序员。这里仅举几例用C完成的语言: Lua、CPython和Ruby的MRI等。
在我们的C解释器clox中8,我们不得不自己实现那些Java库提供给我们的东西。我们将编写自己的动态数组和哈希表。我们将决定对象在内存中的表示方式,并构建一个垃圾回收器来回收它。
我们的Java版实现专注于正确性。既然我们已经完成了,那么我们就要变得更快。 我们的C解释器将包含一个编译器9,该编译器会将Lox转换为有效的字节码形式(不用担心,我很快就会讲解这是什么意思)之后它会执行对应的字节码。这与Lua,Python,Ruby,PHP和许多其它成功语言的实现所使用的技术相同。
我们甚至会尝试进行基准测试和优化。到最后,我们将为lox语言提供一个强大,准确,快速的解释器,并能够不落后于其他专业水平的实现。这对于一本书和几千行代码来说已经不错了。
习题
1、在我编写的这个小系统中,至少有六种特定领域语言(DSL),它们是什么?
2、使用Java编写并运行一个“Hello, world!”程序,配置一个你需要的makefile或IDE项目让它跑起来。如果您有调试器,请先熟悉一下,并在程序运行时对代码逐步调试。
3、对C也进行同样的操作。为了练习使用指针,可以定义一个堆分配字符串的双向链表。编写函数以插入,查找和删除其中的项目。 测试编写的函数。
设计笔记:名称是什么?
写这本书最困难的挑战之一是为它所实现的语言取个名字。我翻了好几页的备选名才找到一个合适的。当你某一天开始构建自己的语言时,你就会发现命名是非常困难的。一个好名字要满足几个标准:
- 尚未使用。如果您不小心使用了别人的名字,就可能会遇到各种法律和社会上的麻烦。
- 容易发音。如果一切顺利,将会有很多人会说和写您的语言名称。 超过几个音节或几个字母的任何内容都会使他们陷入无休止的烦恼。
- 足够独特,易于搜索。人们会Google搜索你的语言的名字来了解它,所以你需要一个足够独特的单词,以便大多数搜索结果都会指向你的文档。不过,随着人工智能搜索引擎数量的增加,这已经不是什么大问题了。但是,如果您将语言命名为“ for”,那对用户基本不会有任何帮助。
- 在多种文化中,都没有负面的含义。这很难防范,但是值得深思。Nimrod的设计师最终将其语言重命名为“ Nim”,因为太多的人只记得Bugs Bunny使用“ Nimrod”作为一种侮辱(其实是讽刺)。
如果你潜在的名字通过了考验,就保留它吧。不要纠结于寻找一个能够抓住你语言精髓的名称。如果说世界上其他成功的语言的名字教会了我们什么的话,那就是名字并不重要。您所需要的只是一个相当独特的标记。
Footnotes
-
静态类型系统尤其需要严格的形式推理。破解类型系统就像证明数学定理一样。事实证明这并非巧合。 上世纪初,Haskell Curry和William Alvin Howard证明了它们是同一枚硬币的两个方面:Curry-Howard同构。 ↩
-
pidgins,洋泾浜语言,一种混杂的英语 ↩
-
Yacc是一个工具,它接收语法文件并生成编译器的源文件,因此它有点像一个输出“编译器”的编译器,在术语中叫作“compiler-compiler”,即编译器的编译器。Yacc并不是同类工具中的第一个,这就是为什么它被命名为“Yacc”—Yet Another Compiler-Compiler(另一个Compiler-Compiler)。后来还有一个类似的工具是Bison,它的名字源于Yacc和yak的发音,是一个双关语。 ↩
-
警告:挑战题目通常要求您对正在构建的解释器进行更改。您需要在代码副本中实现这些功能。后面的章节都假设你的解释器处于原始(未解决挑战题)状态。 ↩
-
我知道很多语言黑客的职业就基于此。您将一份语言规范塞到他们的门下,等上几个月,代码和基准测试结果就出来了。 ↩
-
希望您的新语言不会将对打孔卡宽度的假设硬编码到语法中。 ↩
-
编译器以一种语言读取文件。 翻译它们,并以另一种语言输出文件。 您可以使用任何一种语言(包括与目标语言相同的语言)来实现编译器,该过程称为“自举”。你现在还不能使用编译器本身来编译你自己的编译器,但是如果你用其它语言为你的语言写了一个编译器,你就可以用那个编译器编译一次你的编译器。现在,您可以使用自己的编译器的已编译版本来编译自身的未来版本,并且可以从另一个编译器中丢弃最初的已编译版本。这就是所谓的“自举”,通过自己的引导程序将自己拉起来。 ↩
-
我把这个名字读作“sea-locks”,但是你也可以读作“clocks”,如果你愿意的话可以像希腊人读“x”那样将其读作“clochs”, ↩
-
你以为这只是一本讲解释器的书吗?它也是一本讲编译器的书。买一送一。 ↩