本文摘自《松本行弘:编程语言的设计与实现》
通过实际创造一门新的编程语言,可以学到编程语言的设计思路和实现方法。随着开源的普及,创造新编程语言的门槛一下子降低了许多。创造编程语言不仅可以提升你作为技术者的价值,而且还可以使你从中获得很大的乐趣。
大家都知道我是编程语言 Ruby 的作者,我其实还是一个编程语言迷,对编程语言的痴迷程度无人能及。Ruby 是我出于兴趣钻研编程语言的最大成果,把它称为我兴趣的副产品可能更为贴切。副产品就能如此普及看起来很了不起,但与其把它全部归功于我的实力,倒不如说运气的成分更大。Ruby 已经诞生 20 多年了,如果没有这么多年来发生的各种事情与邂逅,根本不可能有今天这样的成绩。
大家有创造编程语言的经历吗?对于有过编程经历的人来说,编程语言是非常亲切的存在,但是他们往往会认为编程语言是现成的东西,也许谁都没有想过自己去创造一门新的编程语言。这也是情理之中的事情。
与人们说话用的语言(自然语言)不同,世界上所有的编程语言都是由某个地方的某个人创造的。它们不是自然产生的,而是根据明确的意图和目的被设计并实现的。所以,如果过去没有这些创造编程语言的人(编程语言的作者),那么我们今天可能还在用汇编语言编程呢。
在人们刚开始编程时,编程语言就随之出现了,可以说编程的历史就是编程语言的历史。
本书的目标是要你自己创造一门编程语言。可能有的读者会想:“现在再创造编程语言还有什么意义呢 ?”我稍后回答这个问题,现在我们先来看一下编程语言的历史。
早期的编程语言是由在工作中切切实实与编程语言打交道的人创造的,这些人大多就职于企业的研究所(比如 FORTRAN、PL/1 的发明)、大学(比如 LISP)以及标准委员会(比如 ALGOL、 COBOL)等。也就是说,设计开发编程语言是专业人士的工作,但是这个传统随着 20 世纪 70 年代计算机的普及开始发生了变化。一些计算机爱好者在拥有了自己的计算机后,出于兴趣开始编程,甚至开始开发新的编程语言。
其中最具有代表性的就是 BASIC 语言。BASIC 语言原本是美国达特茅斯学院用于教学的编程语言,它的语法非常简单,用极少的代码实现了最基本的功能,所以深受 20 世纪 70 年代编程爱好者的喜爱,并被他们广泛使用。
这些编程爱好者也开始开发自己版本的 BASIC 语言。当时,个人计算机 1 的内存顶多几千兆,他们开发的 BASIC 语言就是可以在内存如此之小的机器上工作的小规模版本。这些小规模的 BASIC 程序大小不到 1 KB,它们在 4 KB 左右的内存上也能工作,跟现在需要大内存的语言处理器比起来真是令人惊讶。
1通常称为微机。微机是微型计算机、微型机的简称。
以个人开发的 BASIC 为代表的小规模语言(Tiny 语言)处理器不久便以各种各样的形式进行了发布。当时的软件有的以 Dump list 的形式刊登在计算机杂志上,有的将程序数据进行音频转换后收录在杂志附带的薄膜唱片(sonosheet)中发布。现在的人恐怕已经不知道薄膜唱片了吧。薄膜唱片是指塑料做的薄薄的唱片,不过唱片这个词几乎没有人用了。据说当时的计算机爱好者都用唱片播放器连接计算机来读取数据,而不使用磁带录音机这个最普遍的外部存储设备。
20 世纪七八十年代是计算机杂志(当时称为微机杂志)的全盛时期,在日本以下 4 种杂志竞争激烈。
这 4 种杂志中现在只有 I/O 仍在发行,不过也大不如前了。作为一个了解当时情况的人,我的内心充满了无限感慨。
这之后,My Computer 杂志派生出了 My Computer BASIC Magazine,又发生了很多事情,继续讲下去恐怕就会变成上岁数人的叙旧了,所以点到为止吧。如果去问问现在三四十岁的程序员,相信他们中间很多人都会眉飞色舞地讲起那个年代的事情。
当时的微机杂志附带了收录 BASIC 的薄膜唱片,除此之外还介绍了其他几个小规模语言,如 GAME、TL/1 等。这些语言都反映了当时那个时代的特色,非常有趣,我会在本节的最后对其进行介绍,请大家务必读一读。
为什么从 20 世纪 70 年代后期到 80 年代前期开始兴起个人创造编程语言了呢?我认为最大的原因是当时难以获取开发环境。
20 世纪 70 年代后期广泛使用的微机是 TK-80(图 1-1)那样的主板裸露在外的单板机,很多都是半成品,需要自己去钎焊。这样的机器不可能自带开发环境之类的东西,软件都要自己输入机器语言之后才会工作。
图 1-1 TK-80
20 世纪 70 年代末期才出现 PC-8001 和 MZ-80 那样的“成品计算机”。然而,这种计算机顶多带一个 BASIC 开发环境,因此人们很难自由地选择开发语言。虽说市面上也有商用的语言处理器,但 C 编译器的定价就要 19.8 万日元,这不是普通人可以轻易买得起的。于是,人们便有了热情去创造一门自己的编程语言。
可现在获取语言的开发环境已经不再是麻烦事了。各种编程语言和开发环境作为开源软件被公开,即使是非开源的,也可以轻松地通过网络得到免费版本。
这样一来,现在自己创造编程语言岂不是没有任何意义吗?如果这个问题的答案为“是”,那么本书在第 1 章开头就结束了。
我认为(而且为了这本书也应当这么回答),这个问题的答案为“否”。即使是现在,自己创造一门新的编程语言也是有意义的,而且有很重要的意义。
而且现在很多广泛使用的编程语言也都是在开发环境容易获取的情况下,由个人设计和开发出来的。如果个人开发编程语言真的没有意义,那么 Ruby、Perl、Python 和 Clojure 这些语言也就不会诞生了。
不过即便如此,我认为 Java、JavaScript、Erlang 和 Haskell 这些语言也可能会以其他形式出现,因为它们会作为业务和研究的一环被开发出来。
那么如今个人设计开发编程语言的动力究竟是什么呢?回顾我自身的经历以及参考其他语言作者的意见,我认为有以下几点理由。
首先,编程语言的实现可以说是计算机科学的综合艺术。作为语言处理器的基础,词法分析和语法分析也可以应用在网络通信的数据协议的实现等方面。
实现语言功能的库和实现其中的数据结构,这正是计算机科学要做的事情。尤其是编程语言的应用范围广泛,很难事先预测会被用于什么方面,因此库和数据结构的实现难度也就更大,但也变得更加有意思了。
另外,编程语言还是人与计算机间的接口。设计这样的接口,就需要深入考察人是如何思考问题的、下意识中有什么样的期待。反复进行这样的考察,对编程语言之外的应用程序接口(API)设计、用户界面(UI)设计,甚至用户体验(UX)设计都是有益的。
也许有人会感到意外,实际上在 IT 行业,对编程语言感兴趣的人不在少数。这是毋庸置疑的,因为编程与编程语言有着切不断的关系。以编程语言为主题的活动和会议等往往都会吸引很多人参加,由此我们也能感受到编程语言的魅力。正因如此,很多人在网上发现新的语言后就会开始尝试。就拿 Ruby 来说,它在 1995 年被发布到网上之后,仅仅 2 周左右就吸引了 200 多人加入邮件列表,着实令人惊讶。
可是,虽然有很多人愿意尝试使用新的编程语言,却几乎没有人会去设计并实现一门编程语言,而且是超越杂志提及的“小儿科语言”那种程度的能够实用化的编程语言。但我保证,仅凭设计出一个实用的编程语言这一点,你就会得到人们的尊敬。
在这个开源的时代,技术人要想生存下去,在技术社区的存在感是非常重要的。虽然技术人只要开源其软件就能达到站稳脚跟的效果,但编程语言的“特殊感”会进一步提升其品牌效应。
另外,编程语言的设计与实现比任何事情都更有趣。的确如此。与计算机科学相关的具有挑战性的工程也是这样。设计编程语言还可以帮助使用这门语言的程序员思考,甚至左右他们的想法,这一点也非常有意思。
通常来说,编程语言有一种从别处获取的、不容侵犯的感觉。如果是自己创造编程语言,就完全没有这个问题。你可以按照自己的喜好进行设计,如果不满意或者有更好的想法,也可以自由地修改。从某种意义上来说,这是终极的自由。
编程在某种意义上是对自由的追求。通过亲自编程,我们可以获得单纯使用他人的软件时享受不到的自由。至少对我来说,这是编程的一个重要动机。于我而言,创造编程语言是获取更高程度自由的手段,也是我的乐趣与快乐的源泉。
虽说自己创造一门编程语言有这么多好处,但并不是每个人都会去做。正如上文所说的那样,对编程语言感兴趣的人虽然有一些,但着手去创造编程语言的人几乎没有。说是“感兴趣的人有一些”,但从占总人口的比例来看,其实少到可以算作误差范围的程度,更不用说有动力去创造新编程语言的人了,就算没有也不足为奇。
我自己在关注编程语言几年后就着了迷,但是在进入大学主修计算机科学之后,才注意到并不是所有人都对编程语言感兴趣。这是因为我在偏僻的乡下长大,周围没有喜欢编程的人可供比较。这一点对我来说也不知道是幸还是不幸。
“难道我跟别人不一样?”意识到这一点的时候,我很震惊。因为当时的微机杂志上刊登了很多关于 TL/1 等编程语言的文章。我本以为对编程感兴趣的人(和我一样)很可能也会对编程语言着迷,但实际上并非如此。
本来就对编程语言不感兴趣的人自不用说,即使是感兴趣的人,也很难走到自己设计并实现编程语言这一步。
关于这个问题的原因,我思考过很长时间。作为编程语言设计者,在参加编程语言相关的活动时,我也曾以过来人的身份鼓励别人尝试一下,但结果总是不尽如人意。当然,万事开头难,开始一件新的事情是需要很大勇气的。但即使是这样,反响也太差了。
问了很多人之后,我才知道大家为什么不去着手尝试了。那是因为就算有兴趣创造一门新的编程语言,在开始之前多半也会有某种心理障碍,也就是觉得“编程语言有现成的,本来就不需要自己去设计和开发”。难得有那么几个人不会产生这种心理障碍,却又觉得语言的实现似乎很难。也就是说,他们觉得编程语言很有趣,自己也想做做看,却不知道如何去实现。
仔细想来,关于编程语言的实现的书虽然出乎意料地出版了很多,但大部分都是大学教材的难度,非常不容易理解。另外,与编译原理有关的“文法类型”和“Follow 集合”等晦涩的术语也频繁出现。
但是认真想一想,我们的目的是出于兴趣创造自己的编程语言,而不是去掌握编程语言的实现所需的所有知识。如果你认为在没有完全掌握正确的知识之前就无法着手创造编程语言,那就大错特错了,你的热情会被逐渐消磨殆尽。
成就一番伟大的事业首先需要的就是热情,不能保持热情是不行的。一旦有了创造编程语言的热情,就应尽快开始,以后再根据需要慢慢地掌握所需的知识即可。
本书主要介绍创造简单的语言处理器所需要的基本知识以及工具的使用方法,并不涉及编程语言实现的较难部分。相较于理论背景,我更想把重点放在如何设计编程语言上。
微机杂志中介绍的Tiny语言
GAME
GAME(General Algorithmic Micro Expressions)是由 BASIC 派生的 Tiny语言。它最大的特征是关键字全部是符号,以及所有的语句都是赋值语句。
例如赋值给“
?
”时会输出数值,反过来将“?
”赋值给变量时会要求输入数值。字符串的输入输出使用“$
”。另外,将行号赋给“#
”时为 goto 语句,将行号赋给“!
”时为 gosub语句(调用子程序)。另外,像 "ABC" 这样的一个字符串语句会打印出字符串,后面有“
/
”的话会换行。这是一门非常有意思的编程语言,示例代码如图 1-A 所示。它既像 BASIC,又不像 BASIC,请大家好好感受一下。
GAME 是一门非常简洁的语言,用 8080 汇编语言编写的解释器的大小还不到 1 KB。另外,由中岛聪(当时居然还是高中生)开发的使用 GAME 编写的 GAME 编译器,代码仅有 200 行左右。真不知道我们应该惊叹 GAME 的语言表现能力,还是中岛聪的技术能力。
TL/1
同一时期在 ASCII 杂志上发表的 Tiny语言中还有 TL/1(Tiny Language/1),它的名字应该是模仿了美国 IBM 公司开发的编程语言 PL/1。与受 BASIC 影响使用符号的 GAME 语言不同,TL/1 拥有类似于 Pascal 的语法,让人觉得更加“正常”。另外,TL/1 的语言处理器是编译型的,与主体为解释型的 GAME 比起来速度更快。但实际上 GAME 也有编译器,这一点我们在前文中介绍过。
TL/1 的特征是语法类似于 Pascal,以及变量类型只有 1 字节的整数。各位读者也许会想这样怎么编写代码,不过当时的主流 CPU 是 8 位的,所以 TL/1 设计成这样也不是很怪异。虽说是运行在 8 位 CPU 上,但包括 GAME 在内的其他语言都提供了 16 位的整数类型。
那么 1 字节无法表示的超过 255 的数值该如何编写呢?答案是按字节进行分割,用多个变量组合表示。比如用 2 个变量保存 16 位整数,边看计算溢出时的进位标志边计算(图 1-Ba)。在当时的 8 位 CPU 上,大部分处理用 16 位整数就已经足够了(地址用 16 位的话就可以访问所有地址空间)。作为 Tiny语言,这样的功能已经足够了。各位读者如果有兴趣,可以显式地查看进位标志,使用多个变量进行 24 位计算或 32 位计算。
可以处理指针和字符串
另外,指针也无法仅用 1 字节来表示。这里用
mem
数组进行访问,也就是说,用下面表达式中hi,lo
表示的 16 位地址来访问指定地址的内容。mem(hi, lo)
下面是将地址的值替换为
v
。mem(hi, lo) = v
当时的个人计算机(微机)最多只有 32 KB 的内存,所以能用 16 位地址访问就已经足够了。
还有就是字符串。当然,我们也可以将字符串当作字节数组,对每个字节依次进行操作,但是这样处理太麻烦,因此 TL/1 设计了用于数据输出的
WRITE
语句。例如,用 TL/1 开发的 Hello World程序如图 1-Bb 所示。TL/1 中变量本应只有 1 字节的整数,却出现了字符串。实际上,
WRITE
是为了能够处理字符串而单独增加的语法。
WRITE
之外的语句是无法处理字符串的,所以不能进行普通的字符串处理,只能够操作 1 字节的整数。现在看来可能会觉得很不可思议,但是在不属于 Tiny 语言的 Pascal 和 FORTRAN 中,输入输出也是被特殊处理的,这在当时也许是一种比较普遍的做法。100---------------- Comment ------------------- 110| 如果紧接在行号之后的不是空白,则将该行作为注释 120-------------------------------------------- 130 200 / " FOR循环语句 是 变量名=初始值,最终值 ... @=(变量名 + 步长) " / 210 A=1,10 220 ?(6)=A 230 @=(A+1) 240 300 / " IF语句的例子 " / 310 B=1,2 320 ;=B=1 " B=1 " / 330 ;=B=2 " B=2 " / 340 @=(B+1) 350 400 / " 数值输入与计算 " / 410 "A = ?" A=? 410 "B = ?" B=? 420 "A + B = " ?=A " + " ?=B " = " ?=A+B / 430 "A * B = " ?=A " * " ?=B " = " ?=A*B / 440 500 / " 数组与字符输出 " / 505--------令数组的地址为$1000 510 D=$1000 520 C=0,69 525--------作为2字节数组写入 530 D(C)=(C+$20)*256+C+$20 540 @=(C+1) 560 C=0,139 570--------作为1字节数组读取,并输出为字符 580 $=D:C) 590 @=(C+1) 600 700 / " GOTO 与 GOSUB " / 710 I=1 720 I=I+1 730 !=1000 731* ?(8)=I*I 740 ;=I=10 #=760 750 #=720 760 900 / "程序结束 " / 910 #=-1 920 1000 / " 子程序 " / 1010 ?(8)=I*I 1020 ]
图 1-A GAME 语言的示例代码
% (a) % 以"%"开始的行是注释,当时不能使用日语 BEGIN A := 255 B := A + 2 % overflow C := 0 ADC 0 % add with carry END
% (b)
BEGIN
WRITE(0: “hello, world”, CRLF)
END
图 1-B TL/1 语言的示例代码
时光机专栏
我原本打算改造 mruby
本节相当于从 2014 年 4 月刊开始连载的第一期内容。我热忱地讲述了编程语言的设计。
在第一期的时候,我还没有想好要设计开发一门什么样的编程语言。当时我打算改造一下自己编写的语言处理器 mruby,因此在连载时介绍了 mruby 源代码的获取方法以及代码目录结构等内容。不过在实际操作中完全没用上 mruby 的源代码,所以本书省略了这部分内容。
虽然本书不会涉及这部分内容,但由于 mruby 比较简单,所以它还是很适合作为编程语言的实现的教材来使用的。如果有读者想阅读 mruby 的源代码进行学习,可以从 http://www.mruby.org/ 这个网址开始各种尝试。另外,mruby 的源代码可以从 GitHub 网站(https://github.com/mruby/mruby)上下载。
如果在学习的过程中有什么疑问、意见,或者发现了错误,请通过 GitHub 的问题追踪器报告给我们。现在 mruby 的开发正朝着国际化方向发展,因此建议使用英语描述问题。另外,大家也可以通过我的推特账号(@yukihiro_matz)用英语或日语与我交流。
本节将简单地讲解编程语言与语言处理器的关系以及语言处理器的结构,为开始设计编程语言做准备。我们首先会制作一个计算器程序,同时也将以mruby的实现为例,介绍一下实用的语言处理器。
虽说我们要创造一门编程语言,但具体要做什么,恐怕没有多少人能回答得上来吧。这是因为大部分人只去学习现有的语言,从未考虑过设计一门语言。
编程语言拥有多层构造。首先,在大的层面上,可将编程语言分为表示交流规则的“语言”和处理此语言使其在计算机上运行的“语言处理器”。很多人在使用“编程语言”这个词时,往往都会将语言和语言处理器混同起来。
语言是由语法和词汇构成的。语法是一种规则,规定了在该语言中如何表述才能使程序有效;而词汇是能从使用该语言编写的程序中调用的功能的集合,之后会以库的形式逐渐增加。在设计语言的场景中说起词汇,就是指该语言一开始就具备的内置功能。
不知道大家有没有注意到,在定义语法和词汇的过程中并没有用到软件。构思设计“我心中的最强语言”是不需要使用计算机的。实际上,我在乡下读高中时,还没怎么掌握编程技术,但想着将来有一天或许自己要设计开发编程语言,就用自己瞎想的编程语言在记事本上写了很多程序。以前回老家时也找过那时候的记事本,但是怎么也找不到,估计是扔掉了吧,想想就觉得可惜。我也不记得是什么样的语言了,不过好像是受了 Pascal 和 Lisp 的强烈影响写出来的。
语言处理器是能够使语法和词汇在计算机上实际运行的软件。要想使编程语言成为真正的语言,而非仅仅停留在一个想法上,是离不开语言处理器的。无法运行的编程语言在严格意义上不能称为编程语言。
当你打算制作语言处理器时,如果不了解语言和语言处理器究竟是什么结构,就无法实现你的创作愿望。方便起见,这里我们使用现成的语言处理器来介绍一下语言处理器的结构。我们先不拘泥于技术细节,来了解一下语言处理器的概况。
语言处理器是计算机科学的集合,是一款非常有意思的软件。计算机专业的大学生应该多少都学过语言处理器的制作方法,可以说语言处理器是计算机科学的基础(之一)。这就能够解释为什么有关语言处理器的图书比比皆是了。
但是很多介绍自制编程语言的方法的书,都将过多的笔墨用在了介绍语言处理器的制作方法上,几乎没有一本书涉及语言设计的相关知识。可能这些书所说的“自制编程语言的方法”就等同于“语言处理器的制作方法”,因此这么写也无可厚非。这些书的目的是教给你自制编程语言的方法(即语言处理器的制作方法),至于你是否真的会去制作,则不是它们要考虑的范围。
而本书将焦点放在了语言的设计上。不过,像曾经的我那样只是在记事本上写下自己空想的“理想语言”是没有现实意义的,因此我会先讲解一下语言处理器的基础知识作为导入。
首先我们来了解一下语言处理器的构成。
语言处理器大体上可分为解释语法的“编译器”、相当于词汇的“库”,以及实际运行软件所需的“运行时(系统)”。这三大构成要素的比重会因语言和处理器性质的不同而发生变化(图 1-2)。
图 1-2 语言处理器的构成要素
早期出现的语言,比如 TinyBASIC 这样简单的语言,语法较少,编译器基本不做什么事情,主要的处理都在运行时完成。这样的处理器称为“解释器”(interpreter)(图 1-3)。
图 1-3 BASIC 语言处理器
编译器与运行时一体化的“解释型”的例子。在很多情况下,库也不单独分开
但是这样纯粹的解释型语言越来越少了。现在很多语言的处理器都是先将程序编译为内部代码,再在运行时执行内部代码。当然 Ruby 也是其中之一。这种“编译器+运行时”的组合形式,看起来像源代码未经转换就被直接执行了,因此有时也被称为“解释型”。
另外,像 C 语言这种在与机器非常接近的层面上追求效率的语言,乎不存在运行时,只有解释语法的编译器部分非常突出,这样的语言处理器被称为“编译型”(图 1-4)。语言处理器的构成要素与语言处理器自身的分类同名,这容易让我们感到混乱。在 C 语言这类语言中,作为转换结果的程序(可执行文件)是可以直接运行的软件,所以不需要负责运行的运行时。部分运行时的工作,比如内存管理等,由库和操作系统的系统调用负责。
图 1-4 C 语言处理器
输出可执行文件的“编译型”的例子。因为可执行文件可以被直接运行,所以几乎没有等同于运行时的部分,库负责了一部分运行时的工作(内存管理等)
在语言处理器中,既有像 Ruby 这样“表面看上去是解释型但内部有编译器在工作”的语言处理器,也有像 Java 这样“表面看上去是编译型但内部有解释器(虚拟机)在工作”的语言处理器。Java 是一种“混合型”的语言,它将程序转换为虚拟机的机器码(JVM 字节码),并由虚拟机(JVM)来执行(图 1-5)。
图 1-5 Java 语言处理器
虚拟机编译器的例子。编译器输出虚拟机的机器码(字节码),由运行时(虚拟机)负责执行
另外,Java 为了提高运行效率,运行时采用了将字节码转换为机器码的即时编译(Just In Time Compiler)等技术,变得越来越复杂。
接下来,我们看一下语言处理器的各个构成要素的内部构造。
首先是编译器。编译器的工作是将编程语言的源代码转换为可执行的形式。
很多编译器都会把转换处理分成多个阶段进行,按照源代码由近及远的顺序分为“词法分析”“语法分析”“代码生成”“优化”。不过,这只是一个大致的分类,并非所有编译器都会运行所有阶段。
词法分析简单来说就是“将源代码由字符序列转换为有意义的单词(token)序列”的工序。将只是字符串的源代码整理为有些许意义的单词序列,后续阶段的处理就会变得简单。比如,将 Ruby 程序
puts "Hello\n"
进行词法分析,转换为
标识符(puts) 字符串("Hello\n")
我会在后面介绍语法分析时解释单词序列的意思。
词法分析的处理通常按照如下顺序进行:语法分析器调用函数请求下一个单词时,词法分析器从函数内部的源代码中将字符逐个取出,整理为一个单词后,返回下一个单词。
我们可以借助 lex 工具根据编写单词的规则自动生成词法分析函数。例如,为数字和简单的四则运算生成词法分析函数的 lex 程序如图 1-6 所示。
%%
"+" return ADD;
"-" return SUB;
"*" return MUL;
"/" return DIV;
"\n" return NL;
([1-9][0-9])|0|([0-9]+.[0-9]) {
double temp;
sscanf(yytext, “%lf”, &temp);
yylval.double_value = temp;
return NUM;
};
[ \t] ;
. {
fprintf(stderr, “lexical error.\n”);
exit(1);
}
%%
图 1-6 为计算器编写的 lex 程序 calc.l
看一下数值处的规则就会明白,在编写构成单词的模式时可以使用正则表达式。这个例子中虽然只有运算符、数值和空格等,但在这条延长线上还可以增加各种各样的单词。这个 lex 程序(假定保存为 calc.l 文件)用 lex 执行后会生成名为 lex.yy.c 的 C 文件。编译这个文件就可以使用 yylex()
函数进行词法分析了。
像这样,使用 lex 即可简单地实现词法分析,但 mruby 并没有用 lex。这是因为 Ruby 中根据语法分析确定的状态,即使是字面相同的文字,有时也会产生不同的单词。实际上 lex 也能写出带状态的词法分析函数,不过自己编写也不是什么特别难的事情,我也想尝试去写写看,所以就没有使用 lex。写 Ruby 的时候,我还很年轻。
语法分析是检查在词法分析阶段准备好的单词是否符合语法,并进行符合语法的处理的工序。
语法分析的方法有好几种,其中最有名、最简单的方法是使用别名为“生成编译器的编译器”的语法分析函数生成工具,比如 yacc(yet another compiler compiler)。mruby 也用了 yacc,准确来说是用了 yacc 的 GNU 版本 bison。除了 yacc 之外,生成编译器的编译器还有 ANTLR 和 bnfc 等,这里就不一一介绍了。
yacc 中,编译器解释的语法是根据 yacc 编写规则编写的。例如,计算器输入的语法如图 1-7 所示。
%{
#include
static void
yyerror(const char *s)
{
fputs(s, stderr);
fputs("\n", stderr);
}
static int
yywrap(void)
{
return 1;
}
%}
%union {
double double_value;
}
%type
%token
%token ADD SUB MUL DIV NL
%%
program : statement
| program statement
;
statement : expr NL
;
expr : NUM
| expr ADD NUM
| expr SUB NUM
| expr MUL NUM
| expr DIV NUM
;
%%
#include “lex.yy.c”
int
main()
{
yyparse();
}
图 1-7 计算器语法分析 calc.y
从开头到 %%
的部分是定义部分,定义单词的种类和类型。另外,%{
和 }%
之间的部分由于直接插入到了所生成的 C 语言程序中,所以会进行头文件的引用等。
%%
与 %%
之间的部分是计算器的语法定义。这个定义是以语法定义规范巴科斯范式(Backus-Naur Form,BNF)的写法为基础的。%%
之后的部分也被直接插入到 C 语言程序中,因此动作(action)部分调用的函数的定义等会被放置在这里。
接下来我们看一下计算器的语法。第一个例子我会选用非常简单的语法来介绍,与普通的计算器一样,这里没有运算符的优先顺序。
我们从第一个规则开始看起。BNF 是规则的描述,默认第一个规则在前面。第一个规则如下所示。
program : statement
| program statement
;
从这个规则中我们可以看出,正确的计算器语法为 program
,意为“program
的定义是在 statement
或 program
之后紧跟着 statement
”。“:
”是定义,“|
”是“或者”,而单词的排列意味着各部分的定义会连接起来。在这个规则中,单词 program
也出现在了右侧,形成了递归,这没有关系。yacc 中就是像这样利用递归来写循环的。
接着来看下一个规则。
statement : expr NL
;
这个规则的意思是“statement
的定义是在 expr
之后紧接着 NL
”。这里没有定义 NL
的意思,它是词法分析器碰到回车时传过来的单词。
接着再看 expr
的定义。
expr : NUM
| expr ADD NUM
| expr SUB NUM
| expr MUL NUM
| expr DIV NUM
;
这个规则的意思是“expr
的定义是 NUM
(表示数值的单词),或者在 expr
之后紧跟着运算符,再之后跟着数值”。这里也用递归实现了循环。因此,“1
”是数值,所以是 expr
。“1+1
”是 expr
“1
”与运算符“+
”以及数值的排列,所以也是 expr
。同理,“1+2+3
”等也都是 expr
。大家可以大概想象出 BNF 的结构了吗?
我们试着运行一下前面编写的计算器程序(图 1-8)。用 lex 执行图 1-6 的程序,用 yacc 执行图 1-7 的程序之后,对生成的名为 y.tab.c 的 C 源文件进行编译,就完成了计算器的语法检查。计算的部分完全没有实现,所以只进行了语法检查。如果输入的代码语法正确就什么也不做,如果语法错误就会显示 syntax error
并结束运行。
图 1-8 计算器程序的编译和运行
不能进行计算的计算器是没有意义的,所以我们让它来实际计算一下。在 yacc 中,我们可以编写规则成立时运行的动作。也就是说,在图 1-7 的 yacc 代码中添加实际的动作进行计算和显示,计算器就完成了。具体来说,就是将图 1-7 程序中的 statement
和 expr
的规则部分替换为图 1-9 的代码。
statement : expr NL
{
fprintf(stdout, "%g\n", $1);
}
;
expr : NUM
| expr ADD NUM
{
KaTeX parse error: Can't use function '$' in math mode at position 4: = $̲1 + $3; … = $1 - $3;
}
| expr MUL NUM
{
KaTeX parse error: Can't use function '$' in math mode at position 4: = $̲1 * $3; … = $1 / $3;
}
;
图 1-9 计算器程序的动作
在这个计算器的例子中,计算和显示直接在动作部分进行,也就是所谓的“纯粹的解释器”。然而在实际的编译器中很少这样直接进行处理,因为无法支持循环以及用户自定义的函数。例如 mruby 中创建了表示语法结构的树结构,并传递给后续的代码生成处理。我们来看一个创建树结构的例子:将 mruby 的 if
语句转换为图 1-10 中的 S
表达式(本质是结构体的链接)。
# 将如下的Ruby程序
if cond
puts "true"
else
puts "false"
end
(if (lvar cond)
(fcall “puts” “trued”)
(fcall “puts” “false”))
图 1-10 mruby 语法树的结构
在代码生成处理中,除了遍历语法分析处理中生成的树结构之外,还会生成虚拟机的机器码。
好像自 Java 虚拟机兴起后,这种“虚拟机的机器码”就多被称为“字节码”。的确,Java 虚拟机的机器码是以字节为单位的,所以叫字节码也无可厚非(其前身 Smalltalk 也是以字节为单位的字节码)。但 mruby 的机器码是 32 位的,因此称它为“字码”(word code)可能更合适。由于字节码这个称谓不够准确,字码这一术语又不常用,所以在 mruby 内部就称为 iseq(instruction sequence,指令序列)。另外,在 iseq 上附加了符号(symbol)等信息的程序信息(代码生成的最终结果)被称为 irep(internel representation,内部代码)。
mruby 的代码生成处理并没有做很难的分析。如果想深度分析,在语法分析处理的动作部分直接生成代码也是可能的。但 mruby 出于各种原因还是将代码生成处理拆分为了几个阶段,采用了类似于 S
表达式的树结构作为中间代码。
这么做的第一个原因是考虑到了可维护性。在语法分析的动作部分的确可以生成代码,程序的大小也会因此而缩小(尽管缩小得不多),但语法分析与代码生成的一体化会使程序变得更加复杂,问题也不易被发现。
动作部分是根据与规则的模式匹配的顺序被调用的,因此相比程序化的动作,运行顺序难以预测,调试起来也非常困难。考虑到可维护性,采用在动作部分只生成语法树这种简单的结构才是明智之举。
这样一来,对于嵌入式 mruby 来说,内存使用量的增加将令人担忧,但幸运的是编译部分(包含语法分析和代码生成部分)可以在运行的时候被分离出来。也就是说,将 Ruby 程序预先转换为 irep,那么在运行的时候就不需要进行编译了。这样处理有助于节约内存,即使在内存容量较小的环境中,我们也不必过于担心内存使用量。
将语法分析结果的树结构进行代码生成处理后,就会生成图 1-11 那样的 irep。ireq 原本是二进制文件(结构体),不易理解,因此就把它转换成这种我们能理解的形式了。
图 1-11 代码生成结果(irep)
根据实现方式的不同,编译器有时会在代码生成前后进行优化处理。在 mruby 的情况下,由于 Ruby 语言的特性使其很难进行优化,所以只在代码生成处理的过程中进行极少的优化。
这种优化被称为“窥孔优化”(peephole optimization),在指令生成时仅参考正前方的指令来进行可能的优化。mruby 编译器实施的部分优化如表 1-1 所示。
表 1-1 mruby 的优化
类别 |
原来的指令 |
优化后 |
---|---|---|
删除没有意义的赋值 |
|
删除 |
删除交换指令 |
|
|
削减赋值 |
|
|
削减赋值 |
|
|
删除重复的 |
|
|
mruby 在编译处理结束之后执行的处理有两种:一种是直接运行编译结果,运行时使用 mruby 适用的虚拟 CPU,在运行虚拟 CPU 时,也会使用对象管理等运行时和库;另外一种是将编译结果写到外部文件,这样就能够生成直接链接编译结果的程序,能够在去掉编译器的状态下执行 Ruby 程序,这对于内存限制严格的嵌入式系统来说是一个很有效的方法。
本节介绍了语言处理器的构成,但并没有涉及语言设计的内容,虽然我对此感到不太满意,但为了能介绍得更详细,也只好这样了。
时光机专栏
讲解语言处理器是一件困难的事情
本节是杂志 2014 年 5 月刊中刊登的内容,介绍了语言处理器相关图书中都会提到的 yacc 的使用方法等。其中用了很老的计算器程序作为示例,令我有些汗颜。
不过,值得肯定的一点是,除了计算器这种“小儿科”的程序之外,本节还介绍了 mruby 这个实用的语言处理器的构成。之所以介绍这部分内容,是因为无法用计算器程序讲解代码生成和优化。
虽说如此,本节也仅限于向大家介绍了“有这种东西存在”,对此我有些遗憾。让我感到左右为难的是,过于详细讲解 mruby 的实现会使内容变得太难,可是不提的话自己又觉得不满意。真是难以抉择。
本节介绍的 yacc 编写规则会在后面介绍 Streem 的实现时多次出现,届时本节的内容就会起到作用。
本节将介绍编程语言处理器的核心部分——虚拟机(Virtual Machine,VM)的实现。在介绍完用于实现虚拟机的四大技术之后,我们将看一下mruby的虚拟机实际拥有的指令。
我们在 1-2 节中讲过,运行源代码编译结果的是运行时。运行时有多种实现方法,本节要讲的虚拟机就是其中之一。
虚拟机这个单词有多种不同的含义,本节中指“用软件实现的(无实际硬件的)计算机”。
这与在虚拟机软件和云计算等语境中出现的虚拟机的含义不同。在虚拟机软件等语境中,虚拟机是指通过把实际存在的硬件用某种软件封装进行虚拟化,从而实现多个系统的同时运行以及系统在硬件间的迁移。维基百科中把这种虚拟机归类到了“系统虚拟机”中,而把本节所要介绍的虚拟机归类到了“进程虚拟机”中。
Ruby 到版本 1.8 为止都没有实现(进程)虚拟机,而是通过遍历编译器生成的语法树(支持用指针链接起来的结构体所实现的 Ruby 程序语法的树结构)来运行程序的(图 1-12)。这种方法虽然非常简单,但每执行一个指令都要访问指针,成本不容小觑。在 Ruby 1.8 出来之前大家都说 Ruby 很慢,这就是其中一个原因。
int
vm(node* node) {
while(node) {
switch (node->type) {
case NODE_ASSIGN:
/* 赋值处理 */
...
break;
case NODE_CALL:
/* 方法调用处理 */
...
break;
...
}
/* 跳到下一个节点 */
node = node->next; /* ← 这里慢 */
}
}
图 1-12 语法树解释器(概要)
我觉得需要说明一下为什么这么简单的结构运行速度会那么慢。大家都知道硬盘的访问速度要比内存的访问速度慢很多,可内存的访问速度又如何呢?大家平常写代码时,很少会注意内存的速度吧。
但实际上,CPU 与内存之间的距离出乎意料地远。与 CPU 的执行速度相比,通过内存总线读取指定地址的数据的速度要慢很多。在访问内存时,CPU 只能等待数据的到来,这个等待时间就会对执行速度产生影响。
为了削减这样的等待时间,CPU 中内置了“内存缓存”(memory cache)的机制,该机制简称为“缓存”。缓存是 CPU 电路中嵌入的小容量的高速内存。通过事先将数据从主存读取到缓存中,把对内存的读写转化为对高速缓存的读写,能够削减访问内存的等待时间,提高处理速度。
由于缓存必须嵌入到 CPU 内部,所以其容量有着严格的限制,能够预先读入的数据很少 2。为了有效利用缓存,需要把接下来要访问的内存空间事先读取到缓存中,但这是非常困难的。一般来说,只有在形成内存访问局部性时才可能做到。也就是说,由于程序一次性访问的内存空间非常小且距离非常近,所以会对一次性读取到缓存的内存空间进行多次读写。
2现在的 CPU 都把缓存分为多个层级来增大缓存容量。即便如此,容量还是比主存小得多,而且也没有解决难以事先将接下来要访问的内存空间读入到缓存的问题。
遗憾的是,从缓存访问的立场来看,图 1-12 那样的语法树解释器是最糟糕的。构成语法树的节点都是一个个单独的结构体,各自的地址不一定邻近,也不会连续。这就导致难以事先将接下来要访问的内存空间读入到缓存中。
这里如果将语法树转换为指令序列,并储存到连续的内存空间上,那么内存访问局部性就会有所增强,性能也会因为缓存的作用而得到极大的提升。
Ruby 1.9 中引入的被称为 YARV 的虚拟机就使用这样的方法实现了性能提升。YARV 是 Yet Another Ruby VM(另一个 Ruby 虚拟机)的缩写。之所以叫这个名字,是因为当初开发时已经有多个以运行 Ruby 为目的的虚拟机在开发了。起初,YARV 只是一个实验项目,但在这些虚拟机中只有它达到了能运行 Ruby 语言全部特性的效果,因此最终 YARV 替代了 Ruby 自己的虚拟机。
采用虚拟机的语言中最有名的应该是 Java 了吧,但虚拟机这项技术并不是在 Java 中首次出现的,而是在 20 世纪 60 年代后期就已经有了。比如,20 世纪 70 年代初出现的 Smalltalk 语言就因从早期就采用了字节码而名声大噪(这只是部分原因)。再往前说,后来设计了 Pascal 语言的尼古拉斯·沃斯(Niklaus Wirth)以 Algol68 语言为基础设计的 Eular 语言据说也完成了虚拟机的实现。Smalltalk 之父艾伦·凯(Alan Kay)说,Smalltalk 的虚拟机的实现受到了 Eular 的虚拟机的启发。
说起 Pascal 就会想起 UCSD Pascal。由加州大学圣地亚哥分校开发的 UCSD Pascal 把 Pascal 程序变更为字节码 P-code 之后运行。将 Pascal 程序变更为 P-code,可以轻松地将 UCSD Pascal 移植到各种操作系统和 CPU 的计算机上,这也使得 UCSD Pascal 作为具有较强移植性的编译器被广泛使用。
从这里我们就能明白,虚拟机最大的优点就是拥有可移植性。配合各种各样的 CPU 生成机器语言的代码生成处理是编译器中最复杂的部分。根据后续出现的各种 CPU 重新开发代码生成处理,对语言处理器的开发者来说是很大的负担。
现在 x86 和 ARM 等架构占据统治地位,CPU 的种类比以往减少了许多,但在 20 世纪六七十年代,新架构层出不穷,甚至同一家公司的同一系列的计算机也会根据型号而使用不同的 CPU。虚拟机在减少这类负担上起到了很大作用。
另外,虚拟机能够配合目标语言进行设计,因此我们就可以将指令集的范围限定在实现这个语言所必需的指令中。与通用 CPU 相比,可以缩小规格,开发也变得更简单。
但虚拟机并非只有优点。与在硬件上直接执行相比,模拟虚拟的 CPU 运行的虚拟机在性能上有很大的问题。采用了虚拟机的语言处理器会产生几倍,甚至几百倍的性能损失。不过我们可以使用 JIT 编译等技术在一定程度上减少这种性能损失。
用硬件实现的真正的 CPU 与用软件实现的虚拟机在性能上各有不同。下面我们来看一下虚拟机性能相关的实现技术,以下是具有代表性的几种。
(1) RISC 与 CISC
(2) 栈与寄存器
(3) 指令格式
(4) 直接跳转
RISC 是 Reduced Instruction Set Computer(精简指令集计算机)的缩写,是通过减少指令的种类、简化电路来提高 CPU 性能的架构。在 20 世纪 80 年代流行的架构中,具有代表性的 CPU 有 MIPS 和 SPARC 等。在移动设备上广泛使用的 ARM 处理器就属于 RISC。
CISC 是与 RISC 相对的一个词汇,是 Complex Instruction Set Computer(复杂指令集计算机)的缩写,简单来说就是“不是 RISC 的 CPU”。CISC 的每个指令执行的处理都非常大,而且指令的种类繁多,因此实现起来也比较复杂。
不过,RISC 与 CISC 的对立是 21 世纪之前的事情了,在如今的硬件 CPU 中,RISC 与 CISC 的对立没有任何意义。这是因为纯粹的 RISC 的 CPU 失去了人气,现在已经很少见到了。即便如此,SPARC 还是存活了下来,被日本超级计算机“京”等设备采用。
RISC 中前景较好的 ARM 也在不断增加指令,朝着 CISC 的方向发展。而作为 CISC 代表架构的英特尔 x86,通过在表面上提供复杂的指令集 3 以维持与过去版本的兼容性,并在内部把指令转换为类 RISC 的内部指令(μ op),从而实现了高速运行。
3前几天有消息称 x86 的 move
指令过于复杂,仅用这个指令就可实现图灵完全。也就是说,理论上仅用 move
指令就能编写出任何算法。
但对虚拟机来说,RISC 和 CISC 之争有不同的意义。如果是用软件实现的虚拟机,我们就不能忽视取指令(Instruction Fetch,IF)处理所需要的成本。也就是说,做同样的处理时所需的指令数越少越好。好的虚拟机指令集是类 CISC 架构的指令集,它的全部指令都是高粒度的。
虚拟机的指令要尽可能地抽象,程序设计得小一些会比较好。有些虚拟机以紧凑化为目标,提供复合指令,把频繁被连续调用的多条指令整合为一条,这样的技术称为“指令融合”或“super operator”。
虚拟机架构的两大流派是栈式虚拟机和寄存器式虚拟机。栈式虚拟机原则上通过栈对数据进行操作(图 1-13),而寄存器式虚拟机的指令中包含寄存器编号,原则上对寄存器进行操作(图 1-14)。
push 1 ← ① 向栈push 1
push 2 ← ② 向栈push 2
add ← ③ 将栈中的两个数相加,然后将结果push到栈中
执行各指令时栈的状态
图 1-13 栈式虚拟机的指令及其结构
load R1 1 ← ① 将第1个寄存器赋值为1
load R2 2 ← ② 将第2个寄存器赋值为2
add R1 R1 R2 ← ③ 将第1个寄存器和第2个寄存器的数值相加,并将结果保存到第1个寄存器
图 1-14 寄存器式虚拟机的指令
与寄存器式虚拟机相比,栈式虚拟机更为简单,程序也相对较小。然而,由于所有的指令都通过栈来交换数据,所以对指令之间的先后顺序有很大的依赖,很难实施交换指令顺序这样的优化。
而寄存器式虚拟机由于指令中包含寄存器信息,所以程序相对较大。这里需要注意的是,程序大小与取指令处理的成本不一定相关,这一点我们在后面也会提到。另外,寄存器式虚拟机由于显式指定了寄存器,所以对指令顺序依赖较小,优化空间较大。不过,小规模语言高度优化的例子几乎不存在,所以这一点也就没那么重要了。
那么栈式虚拟机和寄存器式虚拟机哪个更好呢?这个问题现在还没有定论,使用这两种架构的虚拟机都有很多。表 1-2 展示了这两种架构在各种语言的虚拟机中的使用情况。我们发现,即使是同一语言,也会因实现的不同而采用不同的架构,有时采用栈式虚拟机,有时采用寄存器式虚拟机。这种现象很有趣。
表 1-2 各种语言的虚拟机架构
语言 |
虚拟机 |
架构 |
---|---|---|
Java |
JVM |
栈式虚拟机 |
Java |
Dalvik(Android) |
寄存器式虚拟机 |
Ruby |
YARV(Ruby 1.9 之后的版本) |
栈式虚拟机 |
Ruby |
mruby |
寄存器式虚拟机 |
Lua |
lua |
寄存器式虚拟机 |
Python |
CPython |
栈式虚拟机 |
Smalltalk 出现之后,虚拟机解释的机器语言(指令序列)就开始被称为字节码了。这是因为 Smalltalk 的指令是以字节为单位的。后来“字节码”这个单词被继承了字节单位这一特性的 Java 发扬光大。
不过,不是所有虚拟机都拥有字节单位的指令集,例如 YARV 和 mruby 的指令集就是用 32 位整数表示的。对很多 CPU 来说,32 位整数是最容易处理的长度,多被称为“字”(word),所以这些指令序列的学名叫“字码”可能更为合适。但是“字码”这个词不仅不好读,还不容易让人理解,所以完全没有得到普及,以至于人们慢慢地就放弃了,有时就直接管它叫字节码了。
字节码与字码都有各自的优缺点。与每个指令必定消耗 32 位的字码相比,字节码的程序更加紧凑。另一方面,由于字节码中的 1 个字节相当于 8 位,只能表示 256 个状态,所以操作数(指令的参数)只能保存在指令之后的字节中,这样就会增加从指令序列中取出数据的取指令次数。前面也说过,在用软件实现的虚拟机中,取指令处理的成本较高,因此字码在性能上更有优势。
另外,字码在“地址对齐”这一点上也有优势。在一些 CPU 中,地址如果不是特定数的倍数,直接对其进行访问就会出错。在这种情况下就需要从已对齐的(地址统一为特定数的倍数)地址中取出数据,将偏移的部分切取出来。即便访问不会出错,成倍数的地址与不成倍数的地址(因为在内部进行了前文所述的切取等)在访问速度上也会有很大差别。
地址为 2 的倍数称为 16 位对齐,为 4 的倍数称为 32 位对齐。字码中所有的指令都必须符合地址对齐这一标准,字节码则并非如此。根据 CPU 种类和地址状态的不同,有时字节码平均每个指令的取指令成本会很高。
总的来说,字节码的指令序列相对较短,在内存使用量上有优势,但从取指令的次数和所需时间等性能方面来看,字码更有优势。
接下来我们看一下虚拟机指令的实际例子,比如图 1-15 中的 mruby 指令。
图 1-15 mruby 的指令结构
mruby 的指令通过末尾的 7 位来确定指令种类。通过 7 位来确定指令种类,这就意味着最多可以实现 128 种指令。实际上,包括预备的 5 种指令在内,mruby 共准备了 81 种指令。
指令长度共 32 位,其中 7 位用于确定指令种类,这就表示剩余的 25 位可用于操作数。mruby 的指令可根据操作数部分的使用方法(划分方法)划分为 4 种类型。
指令类型 1 包含 A
、B
、C
这 3 个操作数。A
是 9 位,B
也是 9 位,C
是 7 位。也就是说,操作数 A
和 B
的最大值是 511,操作数 C
的最大值是 127。操作数 A
和 B
多用于指定寄存器。例如,寄存器之间的移动指令 OP_MOVE
在此类型中的命令为
OP_MOVE A B
这表示把操作数 B
指定的寄存器的内容复制到操作数 A
指定的寄存器上。OP_MOVE
指令不使用操作数 C
。
使用操作数 C
的指令,例如有调用方法的 OP_SEND
。
OP_SEND A B C
这表示调用操作数 A
指定的寄存器(这里称为寄存器 A
)中保存的对象中通过操作数 B
指定的符号 4(准确来说是符号表中第 B
个符号)所代表的方法。范围在 A
+ 1
到 A
+ 1
+ C
的寄存器的值是方法的参数,方法调用的返回值保存到寄存器 A
中。
4符号(symbol)是指语言处理器在内部识别方法名时使用的值,不同的字符串会被分配不同的值。
正如刚才介绍的 OP_MOVE
指令那样,有些操作数在类型 1 的几个指令中都没有用到。虽然这部分空间被浪费掉了,但是从访问的便捷性和效率来考虑,这种情况还是可以接受的。
指令类型 2 中没有操作数 B
和 C
,取而代之的是一个大的(16 位)操作数。这个操作数分为无符号数(Bx
)和有符号数(sBx
),根据指令的不同区分使用。使用 Bx
的有 OP_GETIV
等指令,如下所示。
OP_GETIV A Bx
这表示将符号表中第 Bx
个符号指定的 self
实例变量保存在寄存器 A
中。
使用 sBx
的指令有跳转命令,形式如下。
OP_JMP sBx
这个指令可以使下一个指令的地址由现在的地址跳转到偏移 sBx
个位置的地方。sBx
是有符号数,因此前方后方都可以跳转。OP_JMP
指令不使用操作数 A
。使用操作数 A
的有条件跳转的指令例子如下所示。
OP_JMPIF A sBx
这表示在寄存器 A
为真的情况下,跳转 sBx
个位置。
指令类型 3 把操作数部分整合为 1 个 25 位的操作数(Ax
)进行处理。类型 3 的指令只有 OP_ENTER
。
OP_ENTER Ax
OP_ENTER
根据 Ax
指定的位模式进行方法的参数检查。OP_ENTER
将 25 位中的 23 位分割为 5/5/1/5/5/1/1 来解释参数,每位的含义如表 1-3 所示。
表 1-3 OP_ENTER 的参数指定
位 |
内容 |
---|---|
5 |
必需参数的数量 |
5 |
可选参数的数量 |
1 |
是否有 |
5 |
末尾的必需参数的数量 |
5 |
关键字参数的数量(暂未使用) |
1 |
是否有关键字 |
1 |
是否有块(block)参数 |
从头开始分割 25 位的 Ax 操作数
指令类型 4 把 B
和 C
操作数的部分(16 位)分割为 14 位的 Bz
操作数和 2 位的 Cz
操作数。指令类型 4 的指令只有 OP_LAMBDA
。
mruby 准备了从指令中获取操作数的宏,使用这些宏就可以从指令(的字)中获取操作数。这些宏不会进行指令类型的检查,所以开发者要注意正确使用宏。获取 mruby 指令的操作数的宏如表 1-4 所示。
表 1-4 获取 mruby 指令的操作数的宏
宏名称 |
含义 |
---|---|
|
获取指令的类别 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
如果可以使用这样的结构将源代码转换为虚拟机的指令序列,就可以轻松实现虚拟机的基本结构。
虚拟机的中心部分,也就是解析循环(interpreter loop),用伪代码表示时如图 1-16 所示。
typedef uint32_t code;
int
vm_loop(code *pc)
{
code i;
for (;? {
switch (GET_OPCODE((i = *pc))) {
case OP_MOVE:
stack[GETARG_A(i)] = stack[GETARG_B(i)];
break
case OP_SEND:
…
break;
…
}
}
}
图 1-16 虚拟机的基本结构(使用 switch 语句)
是不是简单到让你吃惊?即使指令增加,也只是 switch
语句的 case
增加了而已。
不过,就算基本结构很容易实现,要实现具有实用性的语言还是有很多事情需要考虑,比如这里没有提到的怎样实现运行时栈、如何构建方法调用和异常处理的机制等。由此我们也能看出,理论和实践之间还隔着一条巨大的鸿沟。
在很多情况下,实用的虚拟机都是速度优先的,因此我们也想提高解析循环的效率。提高虚拟机解析循环效率的技术中比较有名的是直接跳转(direct threading),其中使用了 GCC(GNU Compiler Collection)的扩展特性。
GCC 中可以获取标签(label)的地址并跳转到这个地址。标签的地址可以通过“&& 标签名
”获取,跳转到标签的方法是“goto * 标签
”。使用此项功能,我们就可以使用跳转代替 switch
语句来构建虚拟机。
使用直接跳转实现解析循环的代码如图 1-17 所示。
typedef uint32_t code;
#define NEXT i=*++pc; goto *optable[GET_OPCODE(i)]
#define JUMP i=*pc; goto *optable[GET_OPCODE(i)]
int
vm_loop(code pc)
{
code i;
/ 按指令编号顺序排列的标签地址 */
static void *optable[] = {
&&L_OP_MOVE, &&L_OP_SEND, …
};
JUMP;
L_OP_MOVE:
stack[GETARG_A(i)] = stack[GETARG_B(i)];
NEXT;
L_OP_SEND:
…
NEXT;
…
}
图 1-17 使用直接跳转的情况
实际上包括 mruby 在内,使用直接跳转的虚拟机的实现中基本上都提供了编译选项,供用户选择是使用 switch
语句还是使用直接跳转。这是因为标签地址的获取只是 GCC 的扩展特性,不能保证一直可用。使用切换宏实现循环的代码如图 1-18 所示。
typedef uint32_t code;
/* 只支持带GCC扩展功能的编译器 */
#if defined _ GNUC _ || defined _ clang _ || defined _ _INTEL_COMPILER
#define DIRECT_THREADED
#endif
#ifdef DIRECT_THREADED
#define INIT_DISPATCH JUMP;
#define CASE(op) L_ ## op:
#define NEXT i=*++pc; goto *optable[GET_OPCODE(i)]
#define JUMP i=*pc; goto *optable[GET_OPCODE(i)]
#define END_DISPATCH
#else
#define INIT_DISPATCH for (;? { i = *pc; switch (GET_OPCODE(i)) {
#define CASE(op) case op:
#define NEXT pc++; break
#define JUMP break
#define END_DISPATCH }}
#endif
int
vm_loop(code *pc)
{
code i;
#ifdef DIRECT_THREADED
static void *optable[] = {
&&L_OP_MOVE, &&L_OP_SEND, …
};
#endif
INIT_DISPATCH {
CASE(OP_MOVE) {
stack[GETARG_A(i)] = stack[GETARG_B(i)];
}
NEXT;
CASE(OP_SEND) {
…
}
NEXT;
…
}
END_DISPATCH;
}
图 1-18 使用切换宏的情况
使用这项技术,即使在没有 GCC 扩展特性的编译器上,也可以用 switch
语句得到相应的速度。而在有 GCC 扩展特性的编译器上,则可以使用直接跳转技术实现速度更快的虚拟机。
本节讲解了运行时的核心部分——虚拟机的实现,至此语言处理器的基础部分就粗略地讲解完了。下个月 5 开始我会把讲解的重心放在语言设计上。
5本书是由杂志连载内容整理而来的,因此有“下个月”之说。——译者注
时光机专栏
虽然我也想在Streem语言中使用虚拟机……
本节是 2014 年 6 月刊中刊登的内容。接着上一节对 yacc 的介绍,这里讲解了虚拟机的实现。讲解时使用了 mruby 作为示例,是因为过于简单的例子不容易让大家把握虚拟机实现的整体情况。最重要的原因是,我打算在 mruby 的虚拟机的基础上实现其他语言(今后要去实现的语言)的虚拟机。
实际上 Streem 的实现采用了直接遍历语法树这种简单的解释器,所以本节讲解的内容对于 Streem 来说不会起到任何作用,但对于虚拟机的实现还是有价值的,因此本书选择保留这部分内容。虽然我也打算把 Streem 的简单的解释器替换为本节介绍的虚拟机,但却苦于没有时间。时间管理成为我最大的障碍,这种情况已经不是一次两次了……
关于语言的实现我们已经有了大致的了解,接下来就来思考一下语言的设计吧。作为案例,本节我们将回顾一下Ruby早期的设计。Ruby是作为一门支持脚本编程的面向对象语言开发的。
假设你想创造一门新的编程语言,并且不是玩玩看的心态,而是希望它有朝一日能成为在全世界广泛使用的“人气语言”。那么,你该如何做呢?
比起性能和功能,语言规范更能决定一门新的编程语言的人气。然而,基本上没有哪本书或哪个网页会告诉你如何设计一门语言。
不过仔细想想,也几乎没有什么人设计过正经的语言。市面上虽然有自制编程语言相关的教材,但是这些教材介绍的都是编程语言的实现方法,里面介绍的语言也不过是一些例子而已,大多是现有语言或现有语言的子集。语言的设计则在这些教材的考虑范围之外,可能连编写这些教材的人也没有设计人气语言的经验。
的确如此。没有多少编程语言能达到在世界上被广泛使用的程度。即使把历史上所有的人气语言全算上,恐怕也不到几百种。当然,这也要看怎么定义“人气”这个词了。也就是说,这些语言的设计者在全世界也不过几百人,而且其中一些人已经不在了。
作为为数不多的语言设计者的一员,我觉得我有使命向大家介绍语言设计的秘诀。本书真正的目的也在于此。
有志成为语言设计者的人在开始设计新的语言时,脑海中经常会掠过以下疑问。
我们没有必要为这些疑问而烦恼,因为即使你为此烦恼,对你设计一门好的语言也毫无益处。
不过,这里我们还是来思考一下这些问题。比如第一个问题,其实只要是图灵完全的语言,就可以用来编写所有算法。现有的编程语言都已经证明是图灵完全的,所以从软件开发(=编写算法)的角度来看,完全不需要新的语言。
然而,现实是在过去五十多年不断有新的语言被创造出来,这并不是因为已有的语言不能编写某个算法,而是因为用新的语言编写起来更方便或者写起来更爽。而你之所以会产生是否真的需要新语言的疑问,恰恰就是因为你的心底已经有了创造一门语言的想法。既然有了这样的想法,就无须为“是否有必要”这种问题而烦恼了。
对于“这门语言是做什么的”和“目标用户是谁”的问题,我觉得有必要做一下补充。
作为资深的编程语言迷,我学习了很多编程语言。在 Ruby 成名之后,我与很多语言设计者也都进行过交流,比如 C++ 的设计者本贾尼·斯特劳斯特卢普(Bjarne Stroustrup)、Perl 的设计者拉里·沃尔(Larry Wall)、Python 的设计者吉多·范罗苏姆(Guido van Rossum)和 PHP 的设计者拉斯马斯·勒德尔夫(Rasmus Lerdorf)等。从和他们的交流中我总结出一点,那就是除了设计者本人以自用为目的设计的语言以外,其余的语言大多没有流行起来。
如果连自己都不打算用,在设计时就考虑不到细节,也无法保持激情去将自己设计的语言培养成人气语言。不少语言都是经过十年以上的时间才变得有人气,因此,要想创造一门人气语言,考虑细节和保持激情不可或缺。也就是说,人气语言的目标用户首先是设计者本人,然后才是拥有相似特质的用户。而“这门语言是做什么的”则取决于设计者本人想做什么。
决定了目标用户和语言用途之后,就没有必要为最后一个问题,也就是“采用什么样的功能”而烦恼了。不过这里面也隐含着一些诀窍,之后我们再进行说明。
漂亮话说再多也没有说服力,这里我们来看一下 Ruby 的案例。我与 Ruby 打交道了二十多年,可以说的东西有很多。这里我们重点回顾一下决定语言设计方向的开发初期。
先从 Ruby 的开发背景说起。
Ruby 的开发始于 1993 年。我对编程语言产生兴趣是在 20 世纪 80 年代初,当时我还在鸟取县读高中,从那个时候起我便对 Pascal、Lisp 和 Smalltalk 等编程语言产生了浓厚的兴趣。
那时我没有自己的计算机,还不能自由地编写程序,但不知道为什么就对编程语言产生了兴趣,真是不可思议。比起写什么程序,编程语言这一编写程序的手段对我的吸引力更大。
但是,因为我住在乡下,找不到什么资料或者文献来学习,所以吃了不少苦头。那个时候互联网还没有普及,学校图书馆里也基本没有计算机相关的书,这让我头疼不已。
为了获得编程语言的相关信息,我只好在计算机杂志上找编程语言的相关内容,或者去附近的书店看一些类似于大学教材的书(书很贵,当时买不起),所以我一直很感激当时经常去的那家书店。
后来我上了大学,图书馆里摆满了各种图书、杂志和论文,非常齐全,当时就觉得自己生活在了天堂。我就是这样掌握了编程语言的相关知识,这些知识在我后来的语言设计中也起到了非常大的作用。就像没有不读书的作家、没有不了解旧棋谱的职业棋手一样,在设计新的语言时,广泛了解现有语言的相关知识是很重要的。
时间到了 1993 年。那时我已经大学毕业,成为了一名职业程序员,工作就是根据公司的业务要求开发软件。在那之前我开发了公司使用的内部系统,还在 UNIX 工作站上开发了桌面以及可以添加附件的邮件系统等。如今在 Windows 和 Mac 系统上这没有什么稀奇的,但在当时的 UNIX 工作站上却没有这样的系统。即使有类似的也基本不支持日语,所以只能自己开发。
但是在泡沫经济破灭之后,公司整体就变得不景气了,内部系统又不能带来经济效益,于是公司决定停止新功能开发,继续使用已经开发完成的功能。
开发团队被解散,只有少数人作为维护人员留了下来。不知是幸还是不幸,我也是这些少数人之一。但是因为已经停止了开发,所以我也没什么事情可做。偶尔有人打过来电话说计算机无法正常运行,我也只要回复“请重启一下”就行。那段日子就是这么过来的,完全是在坐冷板凳。
不过,这也不全是坏事情。虽然公司不景气,我不用怎么加班 6,而且也没有了奖金,与泡沫经济时期相比收入减少了很多(当时刚结婚的我手头比较紧),但幸运的是我没有被开除,所以也不用去找工作。眼前有计算机,事情少而且不重要,所以也没人管。时间和精力都很充沛,就开始想去做点什么了。那段时间开发了几个实用的小程序,后来因为一个偶然的契机,我决定去实现埋藏在心中多年的一个梦想——创造一门编程语言。
6通常日本公司都会有加班费。——译者注
这个“偶然的契机”是这样的。当时和我同部门的一位前辈策划出一本书,在开始动笔时他找我商量:“我决定写一本通过创建编程语言来学习面向对象的书,你可以帮我写编程语言的部分吗?”
作为编程语言迷的我对这个策划内容非常感兴趣,于是就答应了。但这个策划最终没能通过编辑会议的评审,很快就流产了。创造一门编程语言是我多年来的一个梦想,我好不容易鼓起了干劲,不想就这样停止。以前只是徒有梦想,想象不出语言完成时是什么样子,所以一直没有动力去做,现在好不容易燃起了激情,就此停止就太可惜了。
正是这股“干劲”开启了 Ruby 二十年的历史。当时,我做梦都没想到 Ruby 能成长为一个被全世界广泛使用的语言。
前面我们已经探讨过了在打算创造一门新语言时脑海中会涌现的疑问,虽然在二十年后的今天我可以明确地说自己已经对这些问题不在意了,但当时的我很年轻,还是稍微犹豫了一下。在经过短暂的思考之后,我决定创造属于自己的语言。现在想想,就是当时的这个选择决定了后来的一切。
那时我是 C 程序员,多使用 C 和 shell 脚本语言。工作中中等规模以上的系统用 C 来开发,而日常使用的比较小规模的程序则用 shell 脚本开发。当时(实际上现在也是)我既没有对 C 感到不满意,也没有觉得创造一门给 C 增加面向对象功能的新语言有什么吸引力。这可能是因为当时已经有了 C++,还有我在大学毕业设计中设计过一门以 C 为基础的面向对象语言(虽然没有达到令自己满意的程度)。
我反而对 shell 脚本不是很满意。当时我使用的是 bash,如果仅仅是排列一下命令行,再加上一些简单的控制结构的话,那么用这种简单的语言也就足够了。但是,随着程序不断完善而逐渐变得复杂,就容易出现连自己都看不懂的情况,这让我觉得不太满意。另外,shell 脚本没有正规的数据结构,这一点也让我感到不满。总之,shell 脚本只是加了些逻辑控制的命令行输入,说到底也不过是个“简易语言”,这正是它的问题所在。
当时在与 shell 脚本相近的领域(脚本语言领域)里有更接近普通语言的 Perl 语言,但是在我看来,Perl 语言也带着一种“简易语言”的感觉。对于 Perl 只有标量(字符串和数值)、数组和散列这几种数据结构,我也很不满。因为这样就无法直接表达一些复杂的数据结构了。
当时还是 Perl 4 的时代,Perl 5 的面向对象功能只不过是坊间传闻,但是传闻中的 Perl 5 的面向对象功能听上去也不是很让人满意。我觉得相较于 Perl,拥有更丰富的数据结构的语言会更好。另外,我从高中时就开始痴迷面向对象编程,所以我希望编程语言不仅能够处理结构体,还能够真正地支持面向对象编程。
另外,还有一门叫作 Python 的语言。那个时候关于 Python 的信息还很少,我下了很多功夫去研究,结果发现面向对象功能是后来加上去的,而且感觉这门语言过于普通,所以我不太喜欢。我也知道自己的想法是多么地自大,但只要一说起“理想的语言”这个话题,我这个编程语言迷的话匣子就关不上了。
可能有人会问“过于普通”是什么意思。这是说 Python 在语言层面上不支持正则表达式,字符串操作功能也不够强大,让人感觉不到它在语言层面上支持脚本开发(这里指的是 20 年前的 Python)。
通过缩进来表示代码块是 Python 的特征之一,这是一个很有意思的尝试,但同时也是它的一个缺点。比如,当你想根据模版自动生成代码时,如果不能保持正确的缩进,程序就不能正常工作;由于代码块是通过缩进来表示的,所以在语言层面上需要明确区分表达式和语句,等等。
这样说来,Python 与普通的 Lisp 方言相比,除了语法更容易理解一些之外,似乎也没有什么区别。现在想想,我完全忽视了社区和类库的存在,但当时我还没有认识到它们的重要性。
不过,通过考察其他语言,我清楚自己想做什么样的语言了,那就是一个类似于 shell 脚本、比 Perl 更加接近普通语言、可以自己定义数据结构并具有面向对象功能的语言。当然,这门语言要比 Python 更能无缝地进行面向对象编程,而且还必须支持包括字符串操作在内的脚本编程所需的特色功能,以及具备库。
近年来,脚本编程变得越来越重要,Perl 和 Python 的出现频率也越来越高,然而同在脚本编程领域的面向对象编程的必要性却没有得到足够的认识。
当时人们一般认为面向对象语言是仅在大学研究或大规模的复杂系统开发中使用的技术,而不会在脚本编程这种小规模的简单编程中使用。不过,这种情况总算有了转变的苗头。
Perl 终于计划在今后支持面向对象功能。Python 虽然已经是面向对象语言,但是这个功能是后来增加的,所以(当时)并非所有的数据都是对象。当我想去编写一门面向对象语言的时候, Python 已经成了支持面向对象编程的过程式编程语言。如果这时出现一门以脚本编程为主、支持过程式编程的面向对象语言,那么它一定非常好用,至少我自己很乐意去使用。
说着说着干劲就来了。程序员三大美德 7 之一的傲慢在我身上体现得淋漓尽致,我决定,既然要做,就要做出不输给 Perl 和 Python 的东西来。盲目自信是可怕的,但往往这样的自信会成为动力的源泉。
7Perl 的设计者拉里·沃尔说程序员有三大美德,分别是懒惰、急躁和傲慢。当然,普通情况下不会称这些特质为美德。
于是我开始了 Ruby 的开发。最开始决定的是名字,名字很重要。Perl 的名字源于“珍珠”(pearl)这个单词,于是我决定仿效 Perl,为这门语言选一个宝石的名字。宝石的名字大多比较长,比如 Diamond 和 Emerald,我一直找不到合适的,挑来挑去最后只剩下了 Coral(珊瑚)和 Ruby(红宝石)。Ruby 这个名字既短又美,于是我最终选择了它。那个时候没怎么细想,不过因为编程语言的名字经常被人叫起,所以最好既好读又让人印象深刻。
如果大家决定开发自己的语言,就一定要多花精力想一个好名字。能够清晰地表达出语言特征的名字是最好的,不过像 Ruby 这种与语言特征完全无关的名字也可以。最近出现了常用名字的“googleability”(可搜索性)很低的问题。这个问题在 1993 年 Ruby 开始开发的时候还不存在。
接着决定的是使用 end
关键字表示代码块。C、C++ 和 Java 在代码块里都使用大括号({}
)来括住多条语句,这么做会出现一个问题,那就是把单条语句变为多条语句时容易忘记加大括号(图 1-19)。尽管 Pascal 用 begin
和 end
代替了大括号,但因为也有单条语句和多条语句的区别,所以也存在同样的问题。
// 多条语句时用大括号括起来
if (cond) {
statement1();
statement2();
}
// 单条语句时也可以不使用大括号
if (cond)
statement1();
// 把单条语句变为多条语句时忘记加大括号
if (cond)
statement1();
statement2(); // 不出现语法错误
图 1-19 单条语句和多条语句的问题
我不喜欢这种单条语句和多条语句的问题,所以想在自己的语言中杜绝这种问题的发生,实现这个目标的方法有三种。
(1) 单条语句中不允许省略大括号的 Perl 方式
(2) 用缩进表示代码块的 Python 方式
(3) 不区分单条语句和多条语句,用 end
结束代码块的 Eiffel 方式(图 1-20)
■ 单条语句的情况
if cond
statemen1();
end
■ 多条语句的情况(与单条语句无区别)
if cond
statemen1();
statemen2();
end
■ 有多个代码块时像梳子一样
if cond
statemen1();
elsif cond2
statemen2()
else
statemen3()
end
图 1-20 Eiffel 方式(梳子型代码块结构)
多年来我一直使用 Emacs 文本编辑器,非常熟悉它的语言模式,而且最喜欢这个语言模式提供的自动缩进功能。输入一些代码后,编辑器就会自动帮你缩进,这种感觉就像是和编辑器合力编写代码一样。
在 (2) 的 Python 方式中,缩进本身是用来表示代码块结构的,因此没有自动缩进的余地(不过,在行的末尾输入冒号,缩进会更加深入)。另外,使用缩进表示代码块的 Python 中明确区分了语句和表达式,由于我受不区分语句和表达式的 Lisp 的影响较大,所以对这一点不是很喜欢。因此,我最终没有采用这种用缩进表示代码块的 Python 方式。
上学时 Eiffel 给了我很大影响,那时我读了一本名为《面向对象软件构造》的书,受其影响,我设计了一门语义上类似于 Eiffel(但是语法类似于 C 语言)的语言作为毕业设计。尽管不能说这个尝试取得了成功,但接下来我准备在语法上(而非语义上)借鉴 Eiffel,看看效果如何。
这里让我担心的依旧是自动缩进功能。在当时的 Emacs 语言模式中,主流做法是像 C 那样用符号标记代码块,以此进行自动缩进,而 Pascal 等使用关键字表示代码块的语言的模式则是用快捷键来增加或减少缩进,这样就没有了自动缩进的畅快感。
于是我花了几天时间与 Emacs Lisp 展开搏斗,使用正则表达式对 Ruby 语法进行了简单的分析,创建了在使用 end
的语法中也可以自动缩进的 Ruby 语言模式的模型,由此也证明在使用 end
的、语法类似于 Eiffel 的语言中也可以实现自动缩进功能。这样一来,我就可以放心地在 Ruby 的语法中使用 end
了。反过来说,如果当时没有成功开发出可以自动缩进的 Ruby 语言模式,那么 Ruby 语法也就不是现在的样子了。
在设计上选择使用 end
的代码块结构还有一个预料之外的好处。因为 Ruby 的很大一部分是使用 C 实现的,所以就必然需要区别使用 C 和 Ruby。不过 C 和 Ruby 的代码风格完全不同,所以可以一眼看出当前是在用哪种语言工作,这就降低了大脑的模式切换成本。虽然这个成本微不足道,但是它对保持良好的编程劲头还是非常有好处的。另外,今后当 Perl、Python 和 Ruby 被当成脚本语言的竞争对手时,我想每种语言都拥有不同的代码块构造(Perl 是大括号,Python 是缩进,Ruby 是 end
)或许能帮助它们继续生存下去。
说点题外话,如果使用了上述解决多条语句问题的方法,就不能像 C 那样编写 else if
语句了。因为 C 的 else if
会被解释为在 else
后面紧跟着一个无大括号的单条 if
语句(图 1-21)。用 Ruby 的语法编写 else if
语句,代码如图 1-22 所示。
// (a) 使用了else if的以下语句
if (cond) {
...
}
else if (cond2) {
...
}
// 如果不省略大括号,就会变成这样
if (cond) {
…
}
else {
if (cond2) {
…
}
}
图 1-21 C 的 else if
# 总之,如果Ruby没有用elsif
# 就需要写成下面这样
if cond
...
else
if cond2
...
end
end
if cond
…
elsif cond2
…
end
图 1-22 Ruby 的 else if
从图 1-22 的代码来看,还是用 elsif
比较好。顺便说一句,Perl 和 Ruby 用的是 elsif
,而 shell 脚本和 Python(还有 C 预处理器)用的是 elif
。这个差别真是有意思。
据说 Python 是从 shell 脚本和 C 预处理器那里继承的 elif
这一写法,而 shell 脚本等又是从古老的 Algol 系列继承而来的。此外,像 shell 脚本的 fi
和 esac
那样将表示开始的关键字倒着拼写来表示结束,据说也是起源于此。
很遗憾我不知道 Perl 为什么用了 elsif
,但 Ruby 是因为以下两点。
elsif
与 else if
发音相同而且长度较短(elseif
长一些,而 elif 的发音发生了改变)elsif
一个关键字也是有历史原因的。
再扯得远一些,Perl 的语法虽然跟 C 基本相同,但因为不能省略大括号,所以基于图 1-21 的原因不支持 else if
。不过,如果在语法上明确加入 else
和 if
的组合,兴许也能支持 else if
。在很久之前,有一天我一时兴起改了一下 Perl 的源代码,没想到只花几分钟稍微修改了一下 yacc 描述,就做出了支持 else if
的 Perl。不知道 Perl 社区的人为什么至今还没有动手去做,真是让人费解。
确定了基本方针和语法的方向之后,接下来就到了实现环节。幸好我手上还有以前随便做的“小儿科”语言的源代码,所以就决定以这个为基础进行开发。
Ruby 的开发始于 1993 年 2 月,之后大致完成了语法分析器和运行时的基础部分,并在半年后的 8 月份开始运行了最早的 Ruby 程序(一个 Hello World
程序)。
老实说,那段时期是整个 Ruby 开发过程中最艰难的一段时间。程序员只有看到自己写的代码正常运行起来才能感受到编程的喜悦,而那个时期 Ruby 没有任何可以运行的东西,写来写去也达不到可运行的状态,以至于我几乎没有了支撑下去的动力。
虽说写了语法分析器,但它能做的也只是语法检查。要想运行程序,还需要字符串类,因为 "Hello World"
是字符串对象。要编写字符串类,就需要有以 Object 为顶点的面向对象系统,而输出字符串又需要管理 IO 的对象,像这样,需要的东西一个接一个地增加。充分具备程序员三大美德之一的“急躁”的我居然能忍耐那半年,简直是一个奇迹。
虽说实现了 Hello World
的输出程序,但光凭这一点 Ruby 还不能算得上一个可用之物,至此所实现的内容也只是停留在教科书的示例程度。要想达到人气语言这一目标,接下来才是重点。
如何给语言加上自己的特性?如何招揽人气? Ruby 早期的设计是如何考虑的?我要讲的东西还有很多很多。
不过,“叙旧”叙得有点久,这次的篇幅已经用完了。1-5 节将会继续本节的内容,为大家介绍 Ruby 设计的案例学习的后半部分,敬请期待。
时光机专栏
了解一下常见语言的历史吧
本节是 2014 年 7 月刊中刊登的内容。这里终于开始了对语言设计相关内容的介绍,讲述了 Ruby 语言的开发背景以及历史经过,回答了“为什么想要去做”“在哪些地方遭遇了挫折”“为什么采用这样的语法”等问题。
虽然都是很久以前的事情,但实际上很少有人能讲出常见语言的背景以及隐藏在各种设计背后的理由,所以我认为这一节和下一节是本书的一大亮点。
但话说回来,这些内容本身不过是一些没有用处的知识而已。为了后来人,我真心希望大家能从这些过去的事情中吸取一些教训,比如:
- 设计即决定
- 即使是像语法这样基本的东西,也有各种需要考虑的地方
- 不仔细考虑的话设计就会出错
- 即使仔细考虑也有可能犯错
1-4节讲述了Ruby的诞生,本节将接着上一节的内容,继续讲述Ruby语言设计的相关内容,介绍变量名的命名方法、继承的思考方式、错误处理以及迭代器等是如何确定的,并从中总结语言设计的窍门。
在前面的内容中,Ruby 确定了基本的语法结构,作为编程语言迈出了第一步,但如果只是这样,它也不过是一个随处可见的平庸的语言。现在 Ruby 在语法上只确定了代码块用“do
~end
”括起来、用 elsif
实现 else if
这几点,接下来还需要在细节上加以完善。
在这个阶段,我心目中的 Ruby 除了要满足“成为面向对象语言”这个功能方面的要求以外,还要实现以下几个目标。
“脱离简易语言的范畴”是指在语言规范上不草率了事。当时,特别是在脚本语言领域,很多语言都把完成工作放在第一位,而(貌似)在语言规范上草率了事。比如,明明没什么必要,却以容易实现为由给变量名加上符号,或者用户自定义函数与内置函数的调用方法不同等。
“易写易读”这一点比较抽象。程序不是写一次就结束的,而是要在调试等的过程中反复琢磨,反复修改。对于相同的操作,代码的规模越小越容易理解,所以简洁的代码是最为理想的,不过也不能过于简洁。
世界上也有一些异常简洁的语言,但在事后回过头来看用这些语言写的代码时,则往往无法理解代码的意思,这样的语言通常称为“Write Once Language”,意思是写好之后就不管了。在使用这种语言的情况下,重新解读代码往往要比从头再写一遍更费工夫。只有通过平衡取舍,才能达到易写易读的效果,语言的设计一直都是如此。
另外,在写代码时,如果被迫编写一些在本质上与想做的事情无关的东西,哪怕只是一点点,也会让人感到不快,相信大家都会有这样的想法。这是因为开发时自己只想把精力集中在软件应该用在什么地方这种本质问题上。在不影响理解的前提下,尽量砍掉与本质无关的东西,使实现变简洁,这才是我们希望看到的。
Perl 是 Ruby 开发初期参考的语言之一。Perl 的变量名开头带有符号,其含义如表 1-5 所示。
表 1-5 Perl 的变量名规则
变量名 |
含义 |
---|---|
|
标量(字符串或数值) |
|
数组(标量数组) |
|
散列(关联数组) |
|
访问数组元素 |
|
访问散列元素 |
其中比较有趣的是访问数组的方式。虽然取数组(@foo
)的第 0 个元素,但符号用的却是 $,是如此。也就是说,开头的符号代表了这个变量表达式)的类型。这是因为 Perl 曾是一种通过变明示数据类型的静态类型语言(让人惊讶)。
后来,Perl 引入了引用的概念,这使得包括数组和散列在内的所有东西都可以作为标量来表示。因此,这个静态类型的原则就变得没有那么重要了。
但是在看到变量名时,我们最想知道的不是这个变量的类型而是作用域。有些语言(比如 C++)的编码规则要求全局变量或者成员变量前面要有特定的前缀。而在变量名中加入类型信息的编码规则,比如以前美国微软公司经常使用的匈牙利命名法,最近已经完全看不到了。这就说明明示类型信息已经没有必要了。
于是 Ruby 在变量名中增加了表示作用域的符号(表 1-6),比如 $
是全局变量,@
是实例变量。然而,如果最常用的局部变量和常量(类名等)也加上符号,就会重蹈 Perl 的覆辙。
表 1-6 Ruby 的变量名规则
种类 |
符号 |
示例 |
---|---|---|
全局变量 |
|
|
实例变量 |
|
|
局部变量 |
小写字母 |
|
常量 |
大写字母 |
|
经过再三考虑,我决定把规则定为局部变量前面使用小写字母,常量前面使用大写字母,这样就不会有那么多难看的符号了。另外,如果大量使用全局变量,就会使整个程序中到处都是难看的 $ 符号,这也将有助于我们自然地去推广良好的编码风格。
变量名中包含作用域信息的好处是无须再一一寻找变量声明,因为有关变量作用的信息会以一种紧凑的形式展现在你的面前。变量声明用于向编译器提供变量的作用域和类型等信息,与本质的处理没有关系。如果可以的话,我是不想写这种东西的,更不想为了读懂程序而到处去找变量声明,所以才确定了这样的规则,Ruby 也因此没有变量声明之类的东西。变量在最开始赋值时就会被生成,而不再进行变量声明。
慎重起见,这里我再补充一句,我并没有否定声明的优点,特别是类型声明的优点。静态类型语言即使不运行也能在编译时检查出类型不匹配的错误,这让我觉得很了不起。只是我想把精力集中在本质问题上,而且也不想写类型声明,所以目前更倾向于动态类型。
在设计 Ruby 时,还有一个从一开始就想好的事情,那就是让这个语言成为真正的面向对象语言。
当时的面向对象语言有 Smalltalk 和 C++,大学研究等领域也在使用 Lisp 系的面向对象语言(Flavors 语言等)。据说还有一门叫作 Eiffel 的语言,主要在国外的金融业等行业中使用,但实际的语言处理器只有商用版本,而且在日本很难获取。
这些原因使得面向对象编程距离我们很遥远,而日常的编程,特别是像脚本的文字处理那种规模又小、复杂程度又不高的编程,一般被认为没有必要使用面向对象编程。
所以当时的脚本语言没有一开始就具备面向对象功能的。即使有支持面向对象编程的功能,也是后来添加上去的,因此大多缺乏一种整体感。
但是,高中时读过的那一点关于 Smalltalk 的资料让我觉得面向对象编程才是理想的编程,我相信在脚本编程领域面向对象也一定是有效的,因此在设计语言时,自然一开始就想朝着面向对象的方向去设计。
这里让我烦恼的是继承功能的设计。各位读者可能知道,在支持面向对象编程的语言功能中,继承分为单一继承(也叫单重继承)和多重继承。继承是指从现有的类中继承功能,并附加新功能到新的类。其中,作为基础的现有的类(称为父类)的数量只有一个的情况称为单一继承,有多个的情况称为多重继承。
单一继承是多重继承的子集,只要有多重继承就能实现单一继承。多重继承在 Lisp 系的面向对象语言中非常发达,C++ 后来也引入了这项功能,只是不知道在 1993 年的时候这项功能的使用情况如何 8。
8根据《C++ 语言的设计与演化》中所说,C++ 是在 1989 年的 2.0 版本中引入多重继承的。1993 年的时候这个功能刚出现不久,可能还没什么人用。
不过,多重继承有单一继承没有的问题。在单一继承的情况下,类之间的继承关系只是单纯的一列,类阶层整体是树结构(图 1-23)。而多重继承允许多个父类存在,因此类之间的关系呈网状,形成 DAG(Directed Acyclic Graph,有向无环图)结构。在多重继承中,继承的父类也可能同样有多个父类。如果不加注意,类之间的关系马上就会变得复杂。
图 1-23 单一继承(左)与多重继承(右)
单一继承中,类之间的关系只是单纯的一列,不需要担心继承的优先顺序,搜索方法时也只需按照从下(子类)到上(父类)的顺序查找即可。
但在类关系是 DAG 结构的多重继承的情况下,搜索顺序就不一定是唯一的了(图 1-24)。既有深度优先搜索,也有广度优先搜索,很多支持多重继承的语言(CLOS、 Python 等)还采用了这两种方法之外的 C3 搜索方法。
图 1-24 DAG 的搜索顺序
但是,无论选择了哪种方法,都有很难直观地说清楚的情况。这么复杂的继承关系本来就让人难以理解。
那么把多重继承变为简单的单一继承就没有问题了吗?虽然前面说过单一继承的类关系简单,非常容易理解,但这并不代表单一继承就没有问题。
单一继承的问题是无法跨越继承范围来共享方法等的类属性。在没有共同的父类的情况下,属性无法共享,只能复制代码。DRY 原则 9 认为,复制代码是一种恶习,是不好的做法。
9DRY 是“Don't Repeat Yourself ”的缩写,这是一个软件设计原则,指软件开发时要避免重复。
我们来看一个实际的例子。Smalltalk 中有管理输入输出的 Stream
类,这个类中有负责读取的子类 ReadStream
和负责写入的子类 WriteStream
,还有既可以读又可以写的子类 ReadWriteStream
。
在支持多重继承的语言中,ReadWriteStream
一般会被设计为继承 ReadStream
和 WriteStream
这两个类(图 1-25a),这是多重继承的比较理想的一个案例。但是 Smalltalk 不支持多重继承,所以就让 ReadWriteStream
成为 WriteStream
的子类,然后将 ReadStream
的代码复制过来(图 1-25b)。假如 ReadStream
发生变更,那么复制了 ReadStream
代码的 ReadWriteStream
也必须相应地进行修改,否则就会出现 bug,这是最糟糕的。
图 1-25 ReadWriteStream
Mix-in 给了我解决这个问题的启示。Mix-in 是在 Lisp 系的面向对象语言 Flavors 中诞生的一项技术。Flavors 虽然是支持多重继承的面向对象语言,但是为了减轻刚才讲过的多重继承的问题,该语言对第二个之后的父类进行了如下限制。
按照这些规则,多重继承的网状结构会变成第一个父类为树结构,第二个之后的父类为像长出了树枝一样的结构。使用这个技术实现和图 1-25 相同的结构,如图 1-26 所示。虽然和直接使用多重继承的结构大不相同,但是保持了单一继承的简洁性,而且不需要复制代码。
Mix-in 的确是个好方法,不过它只是多重继承用法上的一个技巧,并没有强制力,因此我考虑在语言层面上强制使用 Mix-in,也就是准备两种类:一种是用于主继承的普通的类,另一种是只能作为 Mix-in 使用的特殊的类。这个特殊的类需要遵循 Mix-in 的规则,禁止实例化和从普通类继承。
Ruby 的模块就是根据这个想法诞生的。module
语句定义的内容恰好满足前面所说的 Mix-in 的性质(图 1-26 的 Readable
和 Writable
就相当于 module)。使用这一技术,我们就能回避多重继承的缺点,降低复杂程度。
图 1-26 Mix-in
差不多和 Ruby 在同一时期,其他语言(例如曾经的太阳微系统公司研究的 Self 语言)也使用 trait 或者 mixin 等名字提供了这样的结构。
软件开发中最麻烦的就是错误处理。想打开的文件不存在、网络连接中断、内存不足等,软件不按预期工作的异常情况要多少有多少。像 C 这样的语言在出现了异常的函数调用之后,就需要去检查函数是否正常结束(图 1-27)。
从图 1-27 的代码中可以清楚地看出,“打开文件”的意图只用一行代码就能描述出来,而与此相比,错误处理则非常烦琐。这就违反了“简洁地表达意图”这一 Ruby 的设计原则,无论如何都要想办法解决。
FILE *f = fopen(path, "r");
if (f == NULL) { // 文件没有正常打开
switch (errno) { // 错误的详细信息保存于变量errno中
case ENOENT: // 文件不存在
...
break;
case EACCES: // 文件访问权限错误
...
break;
...
}
}
图 1-27 C 的错误处理
最先考虑的是使用 Icon 语言的错误处理结构。美国亚利桑那大学开发的 Icon 语言中所有函数调用都会返回成功(返回值)或者失败的结果。如果函数调用失败,那么调用者的函数也会运行失败,这一点与 C++ 和 Java 等语言的异常处理结构相似。Icon 的特殊之处在于把失败值当成布尔值处理。
也就是说,下面这行代码因某种原因执行失败时,这个调用者的函数也会执行失败,导致处理中断。
line := read()
下面的代码表示,当 read() 执行成功时,write()
函数会被执行,否则什么都不做。
if line := read() then
write(line)
而下面的代码则表示,只要 read()
执行成功,就会循环去 write()
这个 read()
函数的返回值,read()
或者 write()
中只要有一个执行失败,就会中止循环。
while write(read())
这种结构不需要使用特殊的语法就可以自然地编写异常处理,这一点很有魅力,但是这样的异常处理不容易引人注意,而且与“普通”语言差别太大,往往让人敬而远之,处理效率方面也令人担心,因此我最终没有采用这种结构。假如我采用了这种结构,那么 Ruby 就和现在大不相同了。
如果你想设计一门人气语言,那么在采用不同于其他语言的设计时,就需要考虑它是否会成为语言的亮点。如果你特别执着于这个设计,那么也可以不做出让步,但假如你明明并不是那么在意,却没经过仔细考虑就采用了“怪异的语法”,可能就为今后埋下了祸根。
虽然放弃了使用 Icon 语言式的异常处理,但我还是希望 Ruby 能拥有某种形式的异常处理功能,于是我决定采用 C++ 等语言中的“普通”的异常处理结构(Java 那时还没有出世),不过我对关键字有一点讲究。
C++ 的异常处理用的是 try
~catch
语句,不过我不怎么喜欢这个关键字。因为 try 给人一种“试试看”的感觉。既然所有的方法调用都可能会发生异常,那么说“试试看”就不太恰当了。另外,catch 这个词也不能让人联想到异常处理。
于是,我从设计代码块时参考过的 Eiffel 语言中借鉴了 rescue 一词。rescue 这个词给人一种“从危险状态中解救出来”的感觉,用在异常处理中正合适。
另外,ensure
这个关键字也是从 Eiffel 语言中借鉴过来的,用于无论是否发生异常都执行事后处理(有些语言使用的是 finally
这个词)。Eiffel 中的关键字 ensure
不是用于异常处理,而是用于表示在 DBC(Design by Contract,契约式设计)中使用的方法运行后应该满足的事后条件。
最后要说的是代码块。其实原本我并没有特别重视代码块的设计,但后来人们经常说代码块是 Ruby 最大的一个特征,作为 Ruby 的设计者,我也颇感意外。
麻省理工学院开发了一门名为 CLU 的语言,用一句话介绍,CLU 就是面向对象语言的前身或者抽象数据型语言。
CLU 有一些引人注目的特征,其中一个就是迭代器(iterator)。迭代器就是“将循环抽象化的函数”。
迭代器可以通过如下方式调用。
for i:int in times(100) do
...
end
这段代码调用了名为 times
的迭代器函数。迭代器函数是只能在 for
语句中被调用的特殊函数。用 CLU 语言实现这个 times
函数,如图 1-28 所示。
times=iter(last:int) yields(int)
n:int := 0
while n < last
yield(n)
n := n + 1
end
end times
图 1-28 用 CLU 语言实现的 times 函数
在迭代器函数中,当 yield
被调用时,传递给 yield
的值会被赋值给 for
语句中指定的变量,然后运行从 do
开始的代码块。
同样的处理如果用 C 实现的话,就会用到 for
语句或函数指针。不过使用 for
语句时无法隐藏循环变量以及对内部构造的访问等细节。使用函数指针时,虽然可以隐藏细节(因为 C 语言中没有闭包),但是变量之类的传递会比较麻烦。
而 CLU 的迭代器不存在这样的问题,所以用它来进行循环的抽象化是非常理想的。
于是我考虑把 CLU 的迭代器引入到 Ruby 里,但是再三思考之后,就觉得直接引入不是很好。CLU 的迭代器确实可以很好地将循环抽象化,不过这种结构也可以用于循环以外的场景中,而 CLU 的语法结构则恰恰阻碍了它在循环以外的场景中使用。
Smalltalk 和 Lisp 中有很多函数和方法可以把函数(Smalltalk 中是代码块)当成参数来传递,然后用于循环等处理。例如 Smalltalk 中使用以下代码就可以将数组的每个元素乘以 2,从而得到一个新数组。
[1,2,3] collect: [:a| a * 2].
如果这个处理用 CLU 的语法来写,就可能会写成下面这种形式,看起来不太直观(这里只是模拟了 CLU 的写法,实际上 CLU 是不能这么写的)。
for a in [1,2,3].collect() do
a * 2
end
难道就没有更好一点的写法吗?当时我的大女儿刚出生不久,晚上总是不睡,我就一边哄她睡觉一边琢磨这条语句的写法。
一开始想到的语法是下面这样的。
do [1,2,3].collect using a
a * 2
end
显然这个语法受到了 CLU 的影响。using
这个关键字是从一个叫 Actor 的 PC 语言中借鉴过来的,这里所说的 Actor 与并发编程的 Actor 模型没有任何关系。然而即使这样写,也没能达到像 Smalltalk 的代码块和 Lisp 的匿名函数那样易于理解的程度。
经过反复推敲,最后实现的语法如下所示。
[1,2,3].collect {a| a * 2}
这个语法已经非常接近 Smalltalk 了。只有一个表示变量的“|
”也是受了 Smalltalk 的影响。之后该语法又被不断完善:没有变量时用两个“|
”表示省略;在与其他以 end
结尾的语句混合使用时,为保持风格一致,使用 do
~end
表示代码块。
原本为了循环的抽象化而参考 CLU 语言设计的代码块,在被引入 Ruby 后出现了各种各样的用法,比如下面这些。
select
等)fork
的运行部分代码块的使用几乎遍及各个领域,真是让人惊讶。
然而代码块只不过是在 Lisp、Smalltalk 以及其他函数式语言中广泛使用的高阶函数(把函数作为参数使用的函数)的特殊语句而已,所以自然能实现以上这些用法。但由于它是专门为循环的抽象化而设计的,所以会有一些限制,而让我意外的是,这些限制完全没有妨碍到它的使用。
刚才提到的限制指的是:
实际上正是有了这些限制,代码在大多数情况下才变得更容易理解、更容易设计了。真是塞翁失马,焉知非福。
根据某项调查显示,在函数式语言之一的 OCaml 的标准库里大量存在的高阶函数中,有 98% 都只有一个函数参数。Ruby 的方法参数中只能有一个代码块的限制并没有带来什么问题,或许也是出于同样的原因吧。
看到这里,相信各位读者也应该知道一些语言设计的秘诀了。
第一,要充分调查现有语言存在什么样的问题,以及有哪些解决办法。积累这些琐碎问题的解决经验,有助于完成一个良好的设计。
第二,对个性的追求要限定在“某一点”上。我在介绍 Icon 的异常处理时也提到过,独具一格的语法可能会给初学者带来挫败感,在“某一点”以外的地方采取保守的态度也会给语言带来人气。但是过于保守会使语言在技术层面上平淡无趣,吸引不到用户,所以很难掌握好这个平衡。
第三,站在客观的角度。我们都知道,如果大家一起商量如何设计,最终并不会产生什么好的结果。因为大家会相互妥协以达成统一意见,从而舍弃设计中独特的部分,也就失去了这种独特设计的优点。因此,就算跟人商量,也要仅限于寻求意见。如果最终责任不是由一个人来担负,就无法做出好的设计。
我在设计代码块时,总是跟婴儿和泰迪熊商量,这也是一个办法。也许你会觉得这么做有点傻,但这么做的话,即使对方没有反应,通过说明自己的想法也有助于进一步深入思考,有时甚至会想出更好的主意。
在 1-4 节和 1-5 节,我向大家介绍了 Ruby 设计早期的一些想法,大家感觉怎么样?
即使是一个细微的语言细节,设计者也要在经过各种研究和思考之后才能决定。不知大家是否感受到了这一点。
不仅是语言,所有的设计都是一个权衡折中的过程。完美无缺的设计是不存在的,存在的只是在某种条件下更好的选择。能否让这个选择适用的范围更广并尽可能地接近完美,就要看设计者的能力了。
不过设计语言是一个漫长的过程。即使是 Ruby 这门在大家的认知中还算比较新的语言,从开始开发也已经经过二十多年了。这期间计算机的性能不断提高,环境也发生了变化,于是 Ruby 又出现了新的需要权衡折中的地方。比如 Ruby 诞生之时多核计算机还没有普及,所以不会要求线程去有效利用多核 CPU,然而现在面向个人的计算机也都已经是双核、四核的了。
为了应对这种环境的变化,我每天都在重新思考语言的设计和实现。
时光机专栏
语言设计的秘诀适用于所有设计
本节是 2014 年 8 月刊中刊登的内容。我接着上一节的内容讲述了一些语言设计背后的事情,介绍了 Ruby 的变量命名规则、面向对象功能的设计、异常处理等的设计背景。
一般来说,在介绍一门语言时,大家往往倾向于介绍该语言最终的样子,而不去讲解为什么是这样的。这次我作为 Ruby 的开发者深入讲述了平常不会触及的一些语言设计的相关内容(我还算是讲得较多的)。我记得自己在写稿子的时候也非常开心。
我在写本节的时候还没有明确意识到,“デザイン”和“設計”这两个词对应的英文都是“design”10,但是在日语中,总感觉“デザイン”和“設計”意思不太一样。
日语的“デザイン”给人的感觉是“决定样式”,而“設計”则是“考虑结构”。我最擅长的就是决定编程语言具有什么样的功能和语法,这也是我的工作。我把心思都放在了语言的“美观”上,所以最近开始使用“言語デザイン”(language design)这个词了。大家是否也觉得“言語デザインナー”(language designer)这个头衔很酷呢?
在本节后半部分介绍的语言设计的秘诀中,我讲到了非常重要的内容(算是自卖自夸吧)。这些原则不仅在语言设计上,而且在所有的软件设计上都是通用的。虽然我在编程以外的领域缺乏经验,但我认为这些原则已经超越了编程,适用于设计和需求的确定等所有需要决策的领域。
10“design”这个词在日语中有两种表示方法,一种是根据“design”这个单词的发音产生的外来语“デザイン”,另一种是“設計”。——译者注