第一章:简介
童话故事不单只是童话故事:不是因为它们告诉我们恶龙是存在的,而是因为它们告诉我们恶龙可以被打败。
—— 尼尔·盖曼《怪诞随意门》中的G. K. 切斯特尔顿Fairy tales are more than true: not because they tell us that dragons exist, but because they tell us that dragons can be beaten.
– G. K. Chesterton by way of Neil Gaiman, Coraline
我很兴奋我们能一起启程。这本书是一本关于来实现用来解释编程语言的直译器的书。它也是一本来解说为了实现的有意义,如何设计一门编程语言的书。这本书也是一本我希望在我刚踏足众语言的土地那会儿,最希望拥有的书;而我也花了将近十年来写它。
家人朋友们,那时我那么的漫不经心,抱歉啦!
每一页我们都会一步一步地拼造出两门功能完整的语言的两个完整的直译器。我已经假设这是你们第一次涉足“语言”,所以这本书将会包含用来建造一个完整的、可用的、快速的编程语言实作方法所需要用到的每个概念和每行代码。
为了把两个完整的实作方法塞进一本书里而不能让它们俩吃闭门羹,这本书较其他同类型的书不那么侧重于理论。由于我们将会在书里眼皮子底下真的一块一块砖头地建造整个系统出来,我会向你们介绍它们的历史和背后的概念。我将尽力让你们熟悉那些术语,从而你可以在置身于充满编程语言研究院的鸡尾酒疯狂排队(a cocktail party full of PL (programming language) researchers)当中不用做局外人。
真奇怪,这种情况反正我个人已经遇到了好多遍了。你不亲眼看见你压根儿不能相信他们当中有些人多么能喝。
但我们很多时候将会绞尽脑汁砌好语言,让整个语言的系统运作起来。这就是为什么我说理论不是不重要。做一个能准确和正经地想出一门语言的语法(syntax)和语意(semantics)的人,是当我们搞编程语言设计时的一项重要技能。但是,具体到我自己个人,我透过实践将会学的很快。让我真的吸收一大篇充满抽象的概念的段落里头的内容对我来说很难。但如果我对某样东西编程,运行它,然后再对它除错(debug),我就理解它了。
静态型别系统(static type systems)某程度上讲需要严谨而细腻的理性思维。钻进一个强型别系统对我来说非常像在证明一条数学公式。
然而这不是巧合。上世纪的前半部分,哈斯凯尔·柯里(Haskell Curry)和威廉·阿尔文·霍华德(William Alvin Howard)证明了编程和数学其实差不多:详见柯里-霍华德同构(the Curry-Howard isomorphism)。
这就是我于你们的目标。我想你们抛弃对于一门真正的编程语言如何运转的刻板印象。我的目标:当你们以后读更多其他的理论性书籍,它们的概念将会牢牢固定在你的脑海里头,粘附在那肉眼可见的基质(substrate)。
1.1 为啥我得学这些?
每本关于编译器的书里头的每章简介好像都会有这个部分。我不晓得是什么有关编程语言的东西造成了这种存在主义的疑惑。我不认为一本鸟类学的书的作者会讲清楚为什么他们会写那些书。他们就只是觉得读者爱鸟儿们然后开始讲课。
但是编程语言有点儿不一样。我假设我们任何人中,造出来一门巨大成功的、通用的编程语言的机率十分之小。所有有名的、世界通用的编程语言的设计者们居然可以塞进一架小小的大众公交车(Volkswagen bus),甚至人少得连天窗都不用打开。如果你们学如何炼造编程语言的唯一目的就是为了加入这群精英中的精英,那么我真的无话可说了。幸好,世界不止是这样。
1.1.1 小语言比比皆是
每门引起巨大反响的编程语言背后都有上千门很小很小的语言。我们曾经叫它们“小语言”(little languages),但随着专业术语名词长度的膨胀它们现在被称为“领域特定语言”(domain specific languages)。这些皮钦语(pidgins)都是些为了完成某样特殊的工作而特意设计的。例如应用程式的脚本语言(application scripting languages)、模版引擎(template engines)、文本标记格式(markup formats),还有配置文件(configuration files)等等。
一些你可能正在用的小语言。
几乎每件大型的软件专案都需要一堆这些语言。当情况允许时,最好的就是重用现在已经存在的语言而非自己再造一个出来:当你开始整说明文档(documentation)、除错器(debuggers)、编辑器支持(editor support)、语法高亮(syntax highlighting)、……等等等等,这就变成了一项苦差事。
但是仍然有很大机率你找不着你需要的库(library)从而你得自己从头开始锻造一堆语法分析器(parser)或者其他工具出来。即使你要用现存的工具,你将无可避免地最后需要重新把它的什么东西都再捣鼓一遍。
1.1.2 实现编程语言是很好的练习方法
长跑选手们在训练时有时会负重在脚踝或者在大气稀薄的高海拔地区训练。当他们卸下重担,比较轻松的四肢和比较富氧的空气使得他们可以跑的更远、更快。
同理,实现一门编程语言确实是对于一个人的综合编程水平的测试。要达标,代码通常写得十分复杂,而执行时间也十分重要。你必须精通递归(recursion)、动态阵列(dynamic arrays)、数据树(trees)、数据表(graphs),还有哈希表(hash tables)等等。你可能至少曾在你的日常编程中用过哈希表,但你真的了解它们吗?不过,当我们一砖一瓦砌出来我们的程序时,我保证你了解。
我打算给你们讲一个直译器其实并没有你们想象中的那么深奥可怖,但同时实现一个仍然具有不少挑战。接受挑战吧,你终将会成为一个更沉着稳定的程序员,并且更懂得于你的日常工作中用我向你介绍过的数据结构(data structures)和算法(algorithms)。
1.1.3 还有一个原因
这——最后一个原因——让我很难说“是”,因为它跟我的内心实在差的不多了。从我的孩提时代,我开始接触“编程”起,我就觉得语言有一种神奇的魔力。当我第一次尝试敲一个BASIC程式时我想象不到BASIC它本身是如何被制造出来的。
后来,我对我的大学同学在谈论他们的编译器讲课时的脸庞的敬畏和可惧让我确信编程语言黑客(language hackers)跟普通人类不是一个物种——前者就像一位巫师,能体会到奥术美学(arcane arts)的巫师。
那就是一幅迷人的画面,但它也有黑暗面。我不觉得自己像位巫师,所以我觉得我天生就缺了点儿什么才不能跟他们谈到一起。虽然我对我自己在学校笔记本上面乱化的编程语言关键字(keywords)所着迷,但它依然花了我数十年的时间来真正地、认真地学习它们。“魔法”的能力,能排除别人之外的能力,竟排除了我。
而那些实践者也不忘添油加醋。两篇开创性的有关编程语言的短文在其封面上印了一只龙和一位巫师。
当我终于开始拼砌我的小小直译器时,我马上意识到了,当然,完全没有任何“魔法”。代码就是代码,而那些语言黑客也是人。
确实,一个人要学习为数不多的几项技术(techniques),这些技术可能跳出这个“语言设计”的世界就没有几个人会用到的了,这些技术的某些部分也确实比较难。但它们并不比你已经跨过的困难难得多。我希望如果你曾被语言设计给吓着而这本书帮你舒缓压力、惊吓的话,也许这本书能让你比以前更勇敢了呢,说不定呢?
而且,谁知道呢,可能你会造出下一代传奇般的程式语言。一定要有人这样做。
1.2 这本书是如何编排的
这本书被分成三部分。你正在读第一部分。第一部分让你熟悉下环境、解释一下你可能不动的超级专业的术语,和向你介绍Lox,我们将会实作的编程语言。
其余两部,每部分都会建造一个完整的Lox直译器。每部分里,每章的编排架.构都差不多。每一章简介一项语言的特点,教你背后的原理,还有带领你走一趟完整的实现。
我这一方,尝试了无数次,也遇见过无数次的错误,但我最后成功地尝试着把两个直译器变成一个章节那么大的大小;每章都需要前面那一章的东西,但是后面的所有东西却完全不需要。从最开头的第一章起你就开始有了一个小小的、能作业的程序。当一章完结、新章又起之时,我们的直译器会慢慢成长为一门功能完备的语言,最后整个部分收尾而你将有一门完整的编程语言。
除了众多的优美文字之外,每一章都有以下趣味的版边:
1.2.1 代码
我们打造直译器,所以这本书正在真枪实战地写代码。每一行的代码都将包含在里头,而每个代码片段(code snippet)将告诉你在哪里插入新的代码。
许多其他的语言书籍和实作方法使用了譬如Lex和Yacc之类的工具,也就是所谓的编译-编译器(compiler-compilers),它们自动地从一些高级描述(high-level description)中生成一些源代码来实作。这样做有好也有不好,而且也比较意见两极化——有些人可能会说这样做就是犯下罪过——两边的人都如此。
Yacc是一工具,它被传入一语法档(grammar file)然后制造出一份用来制造编译器的源文件,所以它算是一种用来产生编译器的“编译器”,因此得名“编译-编译器”。
Yacc并不是第一位,所以得名“Yacc”——只是另一个编译-编译器(Yet Another Compiler-Compiler)。有一个后期的类似的工具Bison(英语中,Bison是野牛的意思),因为Yacc读起来像“yak”(英语中的“牦牛”)的这个谐音梗而得名。
如果你觉得这些小小的自我参照和谐音梗很迷人、很有趣,那么你来对地方了。否则,好吧,你大概率是位不懂或者缺乏幽默感的书呆子。
在这里我们将尽量避免用到它们。我想保证在你们心中对于编程语言设计这个主题没有半点儿混乱和不懂,哪怕是犄角旮旯的地方也好,所以我们将会用手把什么东西都写一遍。如你所见,它并不如传闻中的那么难,而且它也意味着你将会真正地了解两个直译器中每个工具的每行代码是如何工作的。
一本书的限制和外面“真实的”世界的限制或有不同,所以本书使用的编程风格(coding style)可能不能完全反映出一个好管理的生产性软件(maintainable production software)的设计逻辑。如果我被感觉有半点傲慢,例如,省略private
存储权控制子或者二话不说地直接宣告一个全局变量(global variable),请你理解我这样做是因边幅所限,让代码更赏心悦目。这些纸张比你们的整合开发环境(IDE,Integrated Development Environment)窄得多,而且每个字母都是字母,都算成本。
并且,我们的代码没有太多注解(comments)。那是因为每行代码都配备周边那数段段落的详细讲解。当你未来学我写一本书来介绍你的程式时,我也建议你这样做。否则,你可能得用//
得比我多一点咯。
1.2.2 片段
因为本书真的包含了实作需要用到的每一行代码,这些代码片段(snippets)通常都挺精确的。而且,因为我想我们的程序在缺胳膊少腿的情况下也能正常编译运行,我们有时会加上临时代码(temporary code);它们之后会被正式的代码所取代。
一段包含了所有花里胡俏的东西的片段长这样:
default:
if (isDigit(c)) {
number();
} else {
Lox.error(line, "Unexpected character.");
}
break;
lox/Scanner.java,于scanToken(),替换1行
在正中间是新加入的代码。我可能会顺道写出来一些周边的,上面和下面的,围绕着前者的代码。我也会告诉你这代码该加到哪里:哪个文档和哪个函数。如果下面的提示写着“替换 _ 行”,这意味着在上下两侧中间的,已经占着位置存在着的代码要被清除和被替代。
1.2.3 版边
版边包含了粗略的背景生平介绍、历史背景、有关主题的参照,和让你继续探索其他领域的建议,等等。你并不需要真正意义上了解或者知道这些版边的内容,你可以把它们跳过而不影响你理解后面的内容;我不会说你错,但是我可能会有点失望。
好吧,仅仅一些版边会这样子写。大部分版边都是些幼稚的笑话和业余的绘画。
1.2.4 挑战
每章都在章节完结的时候具一些挑战。不同于学院派古董书的教科书式般的问题集,那种问题集通常都回顾些你已经学过的知识;这些挑战问题会帮你学更多,比整章章节的内容还多。它们驱使你离开别人走过的路而探索你自己的路。它们将让你深入研究其他编程语言、想一想如何实现某些语言特性,从而让你跳出你的舒适圈。
征服这些挑战会让你开拓自己的视野,尽管你可能会稍微地碰撞或擦伤。你也可以完完全全把它们跳过,继续搭乘我们的旅游巴士。这是你的书,随你怎样用都行。
一句警告:这些挑战问题通常让你修改我们现有的直译器的程式代码。我建议你拷贝一份主要的代码后才在复制品里实作这些挑战问题。后面的章节需要用到完全没有变改的、原始的(pristine)没有挑战问题实作了的代码,而不是实作了的代码。
1.2.5 设计笔记
许多“编程语言”的书每本都是一本仅限于实现编程语言的书。它们很少跟你讲如何设计一门将要被实作的编程语言。实现一门语言十分有趣,因为它已被精准地定义了。我们作为程序员似乎偏爱黑白分明的东西——一是一,二是二。
我知道许多鼓捣语言的人都以实现一门语言作为他们的职业。你可以把一门语言的标准和规格(specification,spec)给他们,等几个月,然后他们就会给你该语言(编译器或者直译器的)代码和基准线测试结果(benchmark result)。
我个人来讲,我觉得一个世界只需要无穷多的FORTRAN 77实作方法就行了。某种意义上说,你会觉得你自己正在整一门全新的语言出来。当你真的开始整那些,那么把它设计的人性化就非常非常的重要了:什么特性容易学、怎样才能从前卫和熟悉度取得平衡、哪种语法更加易读(readable),这门语言是设计给谁用的。
但愿你的语言的语法没有包含“打孔卡宽度”的这个硬性规定。
所有的所有很大程度上影响你语言的成功率。我想你的语言做的非常成功,所以每一章都会以一张“设计笔记”收尾,一篇关于人性化语言设计的小短文。我对这行不是专家——我所知道的人也没有一个是专家——所以当它做调味来来看待就行了。调味料能使得事物变得更好吃,也是我写这些设计笔记的主要目标。
1.3 第一个直译器
我们使用Java将会打造我们第一个直译器。我们将把焦点放在概念上。我们将会写些最精简、最干净的代码,从而我们可以快且准地实作我们的语言的语意。这也使得我们能轻易掌握基本的技术,还有轻易地理解一门语言到底该怎样运行的。
这本书使用Java和C,但是不同读者们把我的代码转化成其他不同语言。如果你不钟爱Java和C,你可以看一看该清单。
Java确实是一门用来实现这个工具的一级棒编程语言。它十分高级(high-level),高级到我们不用在意那些底层细节,但同时它的内容和语法也十分明显。你可以清晰易明地看到你所声明的变数的数据型别(data structures),不像脚本语言(scripting languages)一样把所有复杂的细节都隐藏在黑暗之处。
我选择了Java,也是因为它是一门物件导向语言(object-oriented language)。这种编程模式作为一个典范席卷了整个90年代的程序世界,导致它现在仍然是数百万程序员的主流设计方法。它的赔率不错,如果你已经用这套方法来组织你的代码、类(classes)和方法(methods)的话,你就能继续坐在舒适圈里享受知识带给你的好处。
虽然有时候程序界的老学究会看不起物件导向语言,但是它们仍被广泛使用——即便是编程语言设计本身亦是如此。GCC和LLVM都是使用C++写成的,大部分JavaScript虚拟机器(virtual machines)亦然。物件导向语言是无处不在的,而面向某一语言的编译器和工具通常都会以该语言本身写成。
一编译器读取某一语言的源代码,翻译它们,然后产出一个由其他语言写成的其他源代码。你可以实作一个面向任何语言的编译器;甚至是面向你实作的语言本身,名曰自我托管(self-hosting)。
你不可以用你正在写的的编译器来编译它自己本身,但是如果你有一个面向相同语言的,用其他语言实作的编译器,你可以用那个来编译你自己的编译器。现在你甚至可以使用那个产物来继续编译你未来版本的编译器,然后把原本那一个旧的删掉(discard),叫做引导程式(bootstrapping),就像你用靴子上面的带子拉你自己一样(译注:英语中“boot”有“启动”或“长靴”的意思,而“strap”有“带子”或“皮带”的意思)。
而且,Java非常非常的知名。这意味着你很有可能已经了解过它了,所以你极有可能跳过这本书的初学者部分。如果你对Java不太熟悉,不要害怕。我尽量只用这门语言本身的小小一部分的功能。我使用了Java 7的钻石运算符(diamond operator,译注:像C++的template<...>
,形状为<数据型别>
)来让代码看起来更精简,但是就“进阶的”功能来说,仅此一例而已。如果你已熟悉另一物件导向语言,如C#或C++,那你随便学两下就已可以应付本书了。
第二部分的完结之时,我们将已经会有一个简单的、可读的实作方法。它并不快,但准确。可是,我们只能通过Java虚拟机器的自己的运行时间设施(Java virtual machine’s own runtime facilities)完成这项任务。我们像Java它自己完成这些事情。
1.4 第二个直译器
然后在再下一部分,我们会重头再来,但是使用C。C对于帮助理解一个直译器如何工作来说是一门完美的语言,你得追根究底地编程编到记忆里的字节(bytes in memory)和流淌于CPU之间的代码(code flowing through the CPU)。
我们使用C的一个重要的原因便是我可以给你们看C语言的某方面的专长,但那就是意味着你得对它们非常熟悉。你并不需要成为丹尼斯·里奇(Dennis Ritchie,译注:C语言的创造者、Unix系统的关键开发者)再世,但你也不能被指针(pointers)吓到。
如果你仍然没有达到那种高度,你仍然会被指标吓着,那么请咀嚼一本C语言的入门书籍,然后再回来这里。作为时间的回报,你会在读完这些书之后成为一位更强大的C语言程序员。由于许许多多的编程语言都使用C实作:Lua、CPython、Ruby MRI(亦作“CRuby”),还有许多;所以学习好C将会对你很有用。
我们的C直译器——clox——得自己独自再次实现Java免费提供给我们的东西。我们会写我们自己的动态阵列和哈希表。我们的程序将会独自决定到底多少个物件(objects)会摆在记忆体(memory)当中,然后构建一个垃圾收集器(garbage collector)来回收(reclaim)它们。
我读clox的名字像是读“sea-locks”,但是你可以读成“clocks”甚至是“cloch”,后者就像你读希腊语中的“x”一样;如果能让你高兴的话,那请随便。
我们的Java实作方法聚焦于准确率。然后把这项素质保留,我们也得开始追求快而准。我们的C直译器将会包含一个编译器,用来把Lox代码翻译成执行效率高的位元组表示方法(efficient bytecode representation,别担心,我会很快让你知道那是什么意思),然后让它执行。这方法已用在Lua、Python、Ruby、PHP,还有其他十分成功的语言。
你以为这本书仅仅是一本关于直译器的书?这也是一本关于编译器的书。买一送一!
我们甚至会尝试对我们的语言进行基准线测试(benchmarking)和优化(optimization)。到最后,我们的语言将会有一个健硕的、准确的、快速地直译器,它能够跟其他专业的编程语言实作方法差不多。对于仅仅一本书和几千行代码来说已经很不错了。
挑战
- 为了完成这本书和所有的代码拼砌出来的小系统,我至少使用了六种领域特定语言。它们是什么?
- 写一个Java的“Hello, world!”程式代码,然后让它运行。同时,构建Makefile或者你的IDE的专案设定以让它能够正常运行。若果你有除错器(debugger),也请熟悉熟悉它和试一试使用它。
- 对于C语言,做同样的事情。来练习一下使用指针,定义一个堆内存分配字符串(heap-allocated string)双向链表。写些函数来插入(insert)、寻找(find),还有从它里头删除(delete)项目(items)。测试它们。
设计笔记:要用什么名字?
写这本书的最大难关之一便是为我们的小小直译器起一个名字。我看过了印满了候选名单的很多很多页中的每一页,最后才找着了Lox,一个好的名字。从你第一天构建你的语言开始,命名就非常地困难。一个好的名字:
- 并不在使用。 你可以卷入任何社会上或者法律上的麻烦,若果你不小心覆盖了别人的名字的话。
- 很容易读出来。 如果你的语言发展得蒸蒸日上的话,成群结队的人都会读和写你的语言的名字。多余一两个英语音节(syllables)或者很多字母的名字会让他们无尽地感到很烦。
- 它很独特,你随便一搜都可以搜出来。 人们会使用搜索引擎来学习你的语言,所以如果你要确保你语言的名字罕见到大部分的搜索结果都会与你的语言有关。尽管今时今日搜索引擎广泛地使用人工智能(AI)的技术令到这不再是一项大问题,但是你把你的语言命名为“for”并不会为你的用户带来任何好处。
- 它没有带有对于任何一个文化的负面内涵。 这非常难以保证,但是它值得我们考虑和深思。Nimrod的设计者最后把它改名为“Nim”因为太多人记得宾尼兔(Bugs Bunny)这部动画片使用了“Nimrod”作为动画里头对人的侮辱。(讽刺的是,这部动画片制作的软件就是用了Nim这门语言。)
如果你的潜在名字排除万难,过了五关斩了六将,保留它。不要放弃寻找一个能捕获到你语言的精髓的名字称谓。如果世间其他有名编程语言的名字能告诉我们任何道理的话,那道理就是名字并不重要。你仅仅需要一门好的语言配好的标记(token)。