在尝试构建一门编程语言之前,你需要先给它下定义。这包括设计语言在“表面上”可见的特性,例如构成词与标点的基本规则;还包括更高层级的规则(称为语法),它们约束在更大的程序片段中(如表达式、语句、函数、类、包与程序)词与标点的数量与顺序。语言设计还包括底层含义,也就是语义。
编程语言设计常常从为每个重要特性编写示例代码开始,同时展示每种构造可能的变体。以挑剔的眼光写例子,能让你在初期就发现并修正许多潜在的不一致之处。基于这些例子,你可以归纳出每种语言构造所遵循的一般规则。把你从示例中理解到的规则用句子写下来。注意规则分两类:词法规则规定哪些字符必须视为一个整体(例如单词,或多字符运算符如 ++);语法规则则规定如何把多个词或标点组合成更大层级的意义——在自然语言里对应短语、句子或段落,在编程语言里可能是表达式、语句、函数或完整程序。
当你为语言想好了应有的一切并写下了词法与语法规则之后,就该撰写一份语言设计文档(或语言规范) ,以便在实现语言时随时参考。以后当然可以修改,但有一份计划会更好推进。
本章将围绕以下主题展开:
- 确定语言中提供的单词与标点类别
- 指定控制流
- 决定要支持的数据种类
- 规划整体的程序结构
- 完成 Jzero 语言的定义
- 案例研究——在 Unicon 中设计图形能力
先从识别你的语言源代码中允许出现的基本元素开始。
确定语言中提供的单词与标点类别
编程语言中的单词与标点可以分成若干类别。在自然语言中,单词按词性分类(名词、动词、形容词等)。在编程语言里,你需要“发明”的对应类别可通过以下方式构造:
- 定义一组保留字/关键字
- 规定标识符(变量、函数、常量的名字)中允许出现的字符
- 设计字面量(内置数据类型的常量值)的表示形式
- 定义单字符与多字符的运算符与标点
你应在语言设计文档中对每一类给出精确描述。有时只需列出具体单词或标点即可;但在另一些情况下,你需要给出模式或其他方式,来明确某一类别中允许与不允许的形式。
对保留字而言,目前给出一份列表就够了。对“名字”而言,精确描述必须包含诸如“允许哪些非字母符号”的细节。比如在 Java 中,名字必须以字母开头,后面可跟字母与数字;下划线被允许并被视为字母。在别的语言里,连字符也可以用于名字内部,于是 a、-、b 三个符号可以构成一个合法名字,而不是 a 减去 b。当精确定义困难时,给出完整的一组示例也能满足需要。
常量值(字面量)是词法分析器中一个令人意外且主要的复杂来源。精确描述 Java 中实数的形式大致如下:Java 有两种实数——float 与 double——它们在结尾之前看起来都一样,结尾处可选的 f/F 或 d/D 用来区分两者。在此之前,实数必须包含小数点(.)、指数部分(e/E),或两者兼有。如果有小数点,那么小数点一侧或另一侧至少要有一位数字;如果有指数部分,它必须是一个 e/E,后接可选的负号与一位或多位数字。更麻烦的是,Java 还有一种奇特的十六进制实常量格式:以 0x/0X 开头,后接十六进制数字,可选的尾数部分由一个点与若干十六进制数字构成,并且必须有一个幂部分:p/P 后接十进制数字,表示将该数乘以 2 的该幂次方。如果你要写诸如 0x3.0fp8 这样的常量,那么这种基于 IEEE 的格式就是为你准备的。
描述运算符与标点通常几乎和列出保留字一样容易。不过二者有一个重要差别:运算符通常伴随优先级规则,你需要加以确定。比如在数值计算中,乘法几乎总是比加法优先级高,因此 x + y * z 会先计算 y * z,再把 x 与乘积相加。在多数语言中,至少有 3~5 个优先级层级,而许多主流语言有 13~20 个优先级层级,需要仔细权衡。
下图给出了 Java 的运算符优先级表,从最低到最高。我们在 Jzero 中会用到它:
图 2.1:Java 运算符优先级
上图表明 Java 的运算符很多,被组织成 10 个优先级层级(我可能稍作了简化)。在你的语言里,层级可以更少,但如果你想构建一门“真正的语言”,就必须处理运算符优先级问题。
与之相似的还有运算符结合性。在许多语言中,大多数运算符是左结合,但也有少数是右结合。例如,表达式 x + y + z 等价于 (x + y) + z;而 x = y = 0 等价于 x = (y = 0)。
最小惊讶原则同样适用于运算符的优先级与结合性,也适用于你最初选择把哪些运算符放进语言。如果你定义了算术运算符却赋予它们非同寻常的优先级或结合性,人们会立刻拒绝你的语言。反之,如果你正在引入新的、可能是领域特定的数据类型,那么对这些类型新引入的运算符,你在优先级与结合性上的自由度会更大。
当你确定了语言中的“词与标点”之后,就可以向更大构造迈进了。这就是从词法分析过渡到语法的过程;而语法之所以重要,是因为在这个层级上,代码片段足以描述要执行的计算。后续章节我们会更详细讨论,但在设计阶段,你至少应该考虑:程序员将如何指定控制流、如何声明数据与构建完整程序。首先,需要为控制流做规划。
指定控制流
控制流描述程序的执行如何在源代码的不同位置之间推进。对受过主流编程语言训练的程序员而言,大多数控制流构造都很熟悉。你的语言设计中的创新点,便可以聚焦在那些新颖或领域特定的特性上——它们往往就是你创建新语言的初衷。让这些新特性尽可能简单且可读。设想它们应当如何融入语言的其余部分。
每门语言都必须有条件与循环,几乎都用 if 与 while 开始。你当然可以为 if 表达式发明专门语法,但除非有充分理由,否则这只会“自废武功”。下面是 Java 中一些控制流构造,它们肯定会出现在 Jzero 里:
if (e) s;
if (e) s1 else s2;
while (e) s;
for (…) s;
还有一些 Java 中较少见、而 不在 Jzero 里的控制流构造;如果它们出现在程序中,Jzero 编译器该怎么办?
switch (e) { … }
do s while (e);
既然这些构造不属于 Jzero,那么默认情况下,如果它们出现在输入源码里,我们的编译器只会打印一条晦涩的语法错误,解释并不充分。在接下来的两章里,我们会让 Jzero 的编译器能就“不支持的 Java 特性”给出更友好的错误提示。
除了条件与循环,语言通常还需要有调用子程序并返回的语法。所有这些无处不在的控制流形式,都是对底层机器改变指令执行位置能力(即 GOTO)的抽象。如果你为“改变指令执行位置”发明了更好的记法,那将是一件大事。
在设计大多数控制流构造时,最大的争议往往是:它们应当是语句,还是应当做成产生结果的表达式,从而能嵌入到外层表达式中使用。我用过一些语言,其 if 表达式的结果很有用——C/C++/Java 甚至提供了专门的运算符 i ? t : e。但我尚未见过把 while 循环做成表达式并“非常有意义”的语言;最好的一些做法也不过是让 while 表达式产生一个结果,告诉我们循环是因为测试条件退出还是因为内部的 break 退出。
如果你是从零发明一种语言,一个重要问题是:是否应该提出新的控制结构来支持你的目标应用领域。比如,你希望语言对股票投资提供特殊支持。如果你能提出一种更好的控制结构来表达该领域内的条件、约束或迭代操作,那么使用你语言的人在该领域中可能就会获得竞争优势。程序最终要运行在底层的冯·诺依曼指令集上,因此你必须想清楚如何把这种新控制结构映射到诸如布尔逻辑测试与 GOTO 之类的指令上。
无论你决定支持哪些控制流构造,你还需要设计一套数据类型与声明机制,以反映你的语言中程序将要处理的信息。
决定支持哪些数据类型
在进行语言设计时,至少要考虑三大类数据类型。本节将分别说明。第一类是原子(标量)原生类型,常称为一等数据类型。第二类是复合/容器类型,用于存放并组织一组值。第三类(可能是前两类的变体)是应用领域特定类型。你应当为这三类各自拟定计划。
原子类型(Atomic types)
原子类型通常是内建且不可变的。顾名思义,你不能修改已有的原子值,只能把它们组合起来计算出新值。几乎所有语言都为数值以及少量其他类型提供了内建原子类型。布尔类型、空(null)类型,或许还有字符串类型,是常见的原子类型,但有的语言还会提供更多。
你需要决定原子类型要做到多复杂:用你的语言编写的程序需要多少种不同的整数与实数的机器表示?一些更高层的语言(如 BASIC)可能把所有数字都用一种类型表示;而更低层的语言(如 C/C++)可能为不同大小和种类的整数提供 5~10(甚至更多)种表示,实数也有若干种。提供的越多,使用你语言的程序员获得的灵活性与控制力越强,但你后续实现的难度也越大。此外,复杂度上升会降低可读性,使程序更难理解。
同样地,不可能设计出一种单一的字符串类型就能完美适配所有大量使用字符串的应用。但你要支持多少种字符串类型呢?一种极端做法是不提供字符串类型,只提供用于保存字符的短整型;在这类语言里,字符串被视为复合类型——也许只由库支持而非语言层面支持。字符串可能被实现为数组或对象,但即便如此,这类语言通常也会有一些特殊的词法规则,允许用双引号把一串字符写成字符串常量。另一种极端是:鉴于字符串在许多领域中的重要性,你的语言可能要为不同字符表示(ASCII、UTF-8 等)提供多种字符串类型,并配套辅助类型(如字符集)以及专门的类型与控制结构来支持字符串的分析与构造。许多流行语言把字符串当作一种特殊的原子类型。
如果你特别“聪明”,也可只支持少数几种数值与字符串的内建类型,但尽量让它们足够灵活。一旦超出整数、实数与字符串,唯一的“通用型”类型就是容器类型,它们让你能够组装数据结构。
围绕原子类型,你需要思考的问题包括:
- 它们各自有多少个取值?
- 这些取值在源代码中如何写成字面量?
- 有哪些运算符或内建函数以此类型作为操作数或参数?
第一个问题决定该类型在内存中需要多少字节。第二、第三个问题又回到语言中“词与标点”的规则设定。第三个问题还会提示:为了在你的语言里支持这种类型,代码生成器或运行时系统要投入多大工作量。原子类型实现的工作量或多或少,但很少像我们接下来要讨论的复合类型那样复杂。
复合类型(Composite types)
复合类型帮助你以协调一致的方式分配与访问多个值。不同语言在复合类型的语法支持上差异巨大。有的语言只支持数组与结构体(Java 程序员可把它们类比为“没有方法的类”),其余数据结构都要求程序员在此之上自行构建;很多语言把更高层的复合类型放在库里提供;而有些更高层的语言则把许多复杂数据结构做成内建并提供语法支持。
最普遍的复合类型是数组:用一段连续的整数索引访问多个值。你的语言很可能也会有类似数组的东西。主要的设计考量是索引如何给出,以及大小变化如何处理。多数主流语言使用从 0 开始的索引。0 基索引便于进行索引计算、实现更简单,但对新手不够直观。有些语言使用从 1 开始的索引,或允许程序员指定从任意整数开始的一段索引范围。
关于大小变化,有些语言的数组类型不允许改变大小,或者让程序员大费周章地基于现有数组去构造不同大小的新数组。另一些语言则有意让“向数组添加值”这件事廉价而简单。没有一种设计能完美适配所有应用,因此你需要做出取舍并接受后果:是支持多种类数组以适配不同用途,还是设计一种足够聪明的类型来较好覆盖常见的使用场景?
除了数组,你还应考虑需要哪些其他复合类型。几乎所有语言都支持记录/结构体/类这一类,用来把不同类型的值按字段名分组访问。你做得越“花”,实现语言的复杂度就越高。如果你的语言需要“像样的”面向对象,就要准备在编译器与运行时上投入时间;类与继承这类特性并非“免费午餐”。设计者被建议保持简单,但作为程序员,我也不愿使用完全不给我这种能力的语言。
你也许能想到若干对你的语言至关重要的其他复合类型——这很好,尤其是在你关心的程序里它们会被大量使用。这里再谈一种极具实用价值的复合类型: (哈希)表,也常叫字典。表类型介于数组与记录之间:你用名称来索引值,并且这些名称不是固定的——程序运行时可以计算出新名称。任何省略这种类型的现代语言,都会失去大量潜在用户。因此,你的语言或许应该包含一种表类型。复合类型是组装复杂数据结构的通用“胶水” ,但你也应考虑是否需要一些专用类型(原子或复合),以支持那些用通用语言难以编写的应用。
领域特定类型(Domain-specific types)
除了你决定纳入的通用原子与复合类型之外,你还应思考:你的语言是否面向某个领域特定的细分场景;如果是,它能提供哪些数据类型来支持该领域?介于“提供领域特定类型与控制结构的DSL”与“像 C++、Java 这样为一切提供库的通用语言”之间,存在一个平滑的连续谱。类库很强大,但对某些应用与领域而言,仅靠库的方式可能比专为该领域设计的语言更复杂、更易出错。比如,Java 与 C++ 提供了字符串类,但在复杂文本处理应用上,它们未必比那些具备专用类型与控制结构的语言更有优势。
除了数据类型,你的语言设计还需要思考程序如何被组装与组织。
整体程序结构
在考察整体程序结构时,我们需要关注完整程序如何被组织与装配,以及一个重要问题:你的语言里允许多深的嵌套。还有一个常被放在后面的点:**程序的源代码从哪里、怎样开始执行?**在以 C 为基础的语言中,执行从 main() 函数开始;而在脚本语言中,源代码会在被读取时立即执行,因此不需要 main() 来“启动齿轮”。
程序结构还引出一个基本问题:整个程序是否必须一次性翻译并一起运行,还是可以将不同的包、类或函数分别编译,然后在运行时链接和/或加载在一起?语言发明者可以通过几种方式回避大量实现复杂度:把机制做进语言本身(既然是内置的,就不必操心链接问题)、要求在运行时提供整个程序的源码,或者生成某种众所周知的标准可执行格式,把链接与装载的繁重工作交给他人的链接器与装载器。
也许与整体程序结构最相关的最大设计问题是:哪些构造可以嵌套,以及嵌套有什么限制(如果有的话) 。用一个例子最能说明这一点。大约在 1970 年前后,有两门不太出名的语言为争夺主导地位而“较量”:C 与 Pascal。
C 语言几乎是扁平的——一个程序就是一组链接在一起的函数,只有相对小粒度的东西可以嵌套:表达式、语句,勉强再加上 struct 定义。
相反,Pascal 的嵌套与递归能力极强,几乎一切都可以嵌套。尤其是函数可以在函数内部任意深度地嵌套。尽管 C 与 Pascal 在能力上大致等价,且 Pascal 起步略早、在高校课程中一度更受欢迎,但最终是 C 胜出了。为什么?原因很多,其中一个可能的因素是:函数嵌套增加了复杂度,却未带来足够的价值。
由于 C 的胜出,许多现代主流语言(尤其是 C++ 与 Java)一开始也几乎是扁平的。但随着时间推移,它们添加的嵌套越来越多。为什么会这样?也许语言自然会随着时间添加特性,直至非常复杂。Niklaus Wirth 早已预见到这一点,并提倡回归软件的小而简,但他的呼吁大多石沉大海;他的语言也支持大量嵌套。
对你这个初露头角的语言设计者而言,实践结论是什么?别过度工程化。尽可能保持简单。非必要不嵌套。而且要有所准备:每次无视这条建议,作为实现者,你都要为之付出代价。
现在,让我们从 Jzero 与 Unicon 中抽取一些语言设计示例。对 Jzero 而言,由于它是 Java 的子集,设计要么基本沿用 Java 的设计(几乎没什么可做),要么是减法式的:我们从 Java 中拿掉什么来得到 Jzero?拿掉之后的“观感”会是什么样?尽管早期努力想保持小而简,Java 仍是一门庞大的语言。如果我们把“Java 里有而 Jzero 没有”的东西都列出来,将会是一份很长的清单。
受限于篇幅与编程时间,Jzero 必须是 Java 的一个极小子集。不过理想情况下,输入给 Jzero 的任何合法 Java 程序都不应“尴尬地失败”——要么能够正确编译并运行,要么打印一条有用的解释性信息,说明使用了 Jzero 不支持的 Java 特性。为便于你理解本书后续内容,也为了让你的预期保持在可控范围,下一节将进一步说明 Jzero 包含与不包含的内容。
完成 Jzero 语言定义
上一章我们罗列了本书要实现的语言的需求,上一节也展开讨论了部分设计考量。作为参考,本节将给出 Jzero 语言的更多细节。如果你发现本节与我们的 Jzero 编译器之间有不一致,那就是编译器的 Bug。语言设计者通常会用更精确的形式化工具来定义语言的各个方面;用于描述词法与语法规则的记号将在接下来的两章给出。本节将以通俗的方式描述这门语言。
-
一个 Jzero 程序由单个文件中的单个类组成。该类可以包含多个方法与变量,但全部都是
static。 -
Jzero 程序从名为
main()的静态方法开始执行(此方法是必需的)。 -
Jzero 允许的语句类型包括:赋值语句、
if语句、while语句,以及返回类型为void的方法调用。 -
Jzero 允许的表达式包括:算术、关系与布尔逻辑运算符,以及返回非
void的方法调用。 -
Jzero 支持的原子类型有:
bool、char、int、long。其中int与long都等同于 64 位整数类型。 -
Jzero 也支持数组。
-
作为内置类,Jzero 支持
String、InputStream、PrintStream,但仅提供它们常见功能的子集:String:支持连接运算符以及charAt()、equals()、length()、substring(b, e)方法;同时支持String.valueOf()静态方法。InputStream:支持read()与close()。PrintStream:支持print()、println()与close()。
至此,我们定义了一门玩具语言(外形类似 Java)编写基础计算所需的最小功能集合。它并不意图成为一门真正的通用语言。不过,我们鼓励你在本书之外扩展 Jzero,比如加入浮点类型、以及带有非静态成员变量的用户自定义类等——这些是我们因篇幅所限未能纳入的特性。接下来,我们将通过考察 Unicon 语言的一个方面,继续观察语言设计能带给我们的启示。
案例研究——在 Unicon 中设计图形设施
Unicon 的 2D 与 3D 图形是内建的,且体量不小。Unicon 图形设施的设计,是一个展示编程语言设计中取舍的真实范例。多数编程语言并不内建图形(甚至不内建任何 I/O),而是把全部输入/输出交给库来处理。比如 C 语言通过库进行 I/O,Unicon 的图形设施也是构建在 C 语言 API 之上。说到库,许多语言会模仿其底层实现语言(如 C 或 Java),试图对底层 API 做一一对应的翻译。高层语言构建在低层语言之上时,这么做可以完整暴露底层 API,但代价是在使用这些能力时拉低了语言层级。
对 Unicon 而言,这条路不可取;Unicon 的设计强调易编程与可移植性,这两点都不允许去一一映射 Xlib、OpenGL 之类复杂的 C 图形库。相反,Unicon 通过向语言中加入两块大型扩展来提供图形:先是 2D,再是 3D。接下来分别讨论与 2D、3D 图形相关的设计问题。下一节先介绍 Unicon 的 2D 图形设施。
2D 图形的语言级支持
Unicon 的 2D 图形设施,是 Icon 语言在“冻结”之前加入的最后一项大特性。向 Icon 增加图形的公开理由,是支持对软件可视化工具的快速试验与开发。(我没有跟博士导师提起,我也想借此更容易地写电子游戏。)
在图形设施诞生时,Icon 几乎已定型。为了避免大幅改动遭到否决,图形设施的设计尽量减少语法层面的变化;唯一的表层改动,是在图形系统中加入了 19 个关键字,用于表示特殊值。Icon 与 Unicon 的关键字看起来像变量名,但前面带一个和号 & 。
除了一项之外,其余关键字都用于简化鼠标与键盘输入事件的处理。最主要新增的关键字是 &window。所有图形函数默认使用这个窗口;除非把另一个窗口值作为可选的第一个实参传入。
上一章提到的“保留人们热爱 Icon 的特性”的需求,同样适用于新增特性。因此,图形设施的设计需要与 Icon 既有的 I/O 特性保持一致。Icon 的 I/O 设施包含一种文件类型,以及执行输入/输出的内建函数与运算符。
在 Unicon 中,引入了一个新的类型(“window”),它是 Icon file 数据类型的子类型/扩展。对 Unicon 程序员而言,窗口是一个单一而简单的对象:可以创建并在上面绘制。文件上的现有(文本)I/O 操作被扩展为同样适用于窗口,然后再叠加图形输出能力。
Unicon 的图形输出能力由约 40 个内建函数组成,用于绘制不同的图形基本元。围绕这些函数的设计决策包括:选择一组最小且不重叠的图形功能,并把它们的参数与返回值设计得尽量简单且灵活。这些目标通过广泛的参数默认值与可变参数(在可能处允许函数接受任意数量的参数,从而一次处理任意数量的图形基本元)来实现。
在设计语言特性时,控制结构与程序组织是重要考量。在多数语言里写图形程序,程序员很快就会被教导(并被迫)把控制权交给库,把程序组织成一组回调函数(在各种事件发生时由库调用)。而在 Unicon 中,程序保留控制权;语言运行时系统会定期检查图形事件,处理常见任务(如从后备存储重绘窗口内容),并把其他事件排队,等到应用的控制流需要时,再在 Unicon 语言层面处理。
数年之后,3D 图形硬件支持变得无处不在。下一节讨论在 Unicon 中加入 3D 图形支持时的设计问题。
添加对 3D 图形的支持
2D 图形作为 Icon(与 Unicon)file 数据类型的扩展被加入,支持常规文件操作(open/close/read/write)。能在相关的窗口里操纵像素与其它图形基本元,是在此基础上的“加分项”。类似地,3D 图形被作为 2D 图形的扩展加入。3D 窗口在三维空间中支持相机视图基本元,并以与 2D 设施相同的记法(做出适当扩展)支持颜色、字体等相同属性。3D 窗口也提供与 2D 窗口相同的输入能力,并新增了若干图形输出基本元。
2D 窗口的画布是一个可读写的二维像素数组;而 3D 窗口的画布包含一份显示列表(display list) ,每一帧都会据此重绘。在 Unicon 中,可以直接操作显示列表,以产生各种动画效果,比如改变单个 3D 对象的大小或位置。显示列表也是细节层次(LOD)管理与3D 对象选取的核心。为此,语言新增了一种控制结构,用于标记并命名显示列表的片段;这些片段随后可以被启用/禁用,或被选择以接受用户输入。
受篇幅所限,这里对 Unicon 图形设施的设计讨论并不完整。最初在 2D 设施里采取的是有意为之的极简主义。尽管结果是成功的,但你也可以认为 Unicon 的图形设施本可以做得更多——例如,或许可以发明新的控制结构,进一步简化图形输出操作。无论如何,这段设计讨论让你了解:当把一个新领域加入到现有语言中时,可能会遇到哪些问题。
总结
本章呈现了若干与语言设计相关的问题。你在本章获得的技能包括:词法设计(为数据类型创建字面量记法)、语法设计(运算符与控制结构)、以及程序组织(决定从何处、以何种方式开始执行)。
之所以需要在设计上花些时间,是因为你要清楚你的语言能做什么,才能去实现它。如果把设计决策一拖再拖,直到实现时才处理,那么犯错的代价会更高。语言设计涵盖:支持哪些数据类型,变量声明与赋值的方式,控制结构,以及从指令级到完整程序级不同粒度代码所需的语法。当你完成(或自以为完成)后,就可以开始编码了——从读取源代码的函数开始,这也是下一章的重点。