自己动手写编程语言——为什么还要创造另一种编程语言?

108 阅读22分钟

这本书将向你展示如何构建你自己的编程语言,但首先,你需要问自己:为什么我要这么做?
对少数人来说,答案很简单:因为这件事非常有趣。😊
然而,对大多数人而言,构建一门编程语言是一项巨大的工程,在投入精力之前,我们必须确定这件事值得去做。你是否具备所需的耐心与坚持?

本章将指出一些构建自己编程语言的合理理由,同时也会说明在某些情况下,其实没有必要去造一门新语言。毕竟,为应用领域设计一个类库往往更简单,并且同样有效。不过,库也有它的局限性,而有时候,唯有一门新语言才能满足需求。

在本章之后,本书的其余部分会默认:你已经经过慎重思考,并决定动手构建一门语言。但在此之前,我们要先考虑初始选项,本章将涵盖以下主题:

  • 编写自己编程语言的动机
  • 编程语言实现的类型
  • 如何组织字节码语言的实现
  • 本书示例中所使用的语言
  • 编程语言与类库的区别
  • 对其他软件工程任务的适用性
  • 确立你所要实现语言的需求
  • 案例研究:启发 Unicon 语言的需求

让我们先从动机开始。

编写自己编程语言的动机

当然,有些编程语言的发明者是计算机科学领域的“摇滚明星”,比如 Dennis Ritchie 或 Guido van Rossum!在上个世纪,成为计算机科学的明星似乎容易得多。1993 年,我听一位参加过 ACM 第二届编程语言历史会议的人说过这样一份报告:“大家一致认为,编程语言领域已经死了。所有重要的语言都已经被发明出来了。”

然而,仅仅一两年后,Java 的出现就推翻了这一观点;之后十几次,新的重要语言(如 Go)不断涌现。经过短短六十年,就断言这个领域已然成熟、再无新东西可发明,是极不明智的。

不过,说实话,追求名气并不是发明编程语言的好理由。靠语言发明成名或发财的机会极其渺茫。出于好奇心、想要弄清事物如何运作,当然是合理理由(只要你有时间和兴趣);但或许更好的理由是出于必要

有些人必须创造一种新语言,或是为现有语言写一个新实现,以支持新处理器或与竞争对手抗衡。或者,你在开发某个领域的程序时,发现现有最好的语言(和编译器/解释器)缺少关键特性,而这些缺失正在带来巨大痛苦。这种情况常常成为硕士论文或博士论文的主题。偶尔也会有人提出一种全新的计算范式,这种新范式需要一种全新的编程语言来表达。

在讨论动机的同时,我们也来看看语言的不同类型、其组织方式,以及本书将用到的示例。

编程语言实现的类型

无论你的理由是什么,在构建一门编程语言之前,你都应挑选最合适的工具和技术。本书会为你选定。首先要考虑的是实现语言——也就是你打算用哪门语言来构建这门语言。

一些学术界的人喜欢炫耀用某门语言实现了这门语言本身,但通常只是半真半假(或者他们既 impractical 又爱显摆)。更核心的问题是:你要构建哪种类型的语言实现?

  • 纯解释器:直接执行源代码。
  • 本地编译器 + 运行时系统:如 C 语言的模式。
  • 转译器(Transpiler) :把你的语言翻译成另一种高级语言。
  • 字节码编译器 + 字节码机:如 Java 的方式。

第一种方法有趣,但通常速度太慢,不适合实际项目。
第二种方法往往性能最佳,但代价太高,一个优秀的本地编译器可能需要数年开发。
第三种方法是最容易、最有趣的,我自己也用过,效果不错。不要小看转译器,它并不是“作弊”。不过它的问题在于:生成高级代码反而让你的语言过度依赖底层语言,而语言本身也在不断变化。许多语言就死在了这种依赖上。
本书主要聚焦第四种方法:字节码编译器 + 字节码机,因为它在性能与灵活性之间取得了很好的平衡。书中也会提供关于转译器与预处理器的章节,供有兴趣的人参考;另外还会有一章讲解本地代码编译,适合追求极致执行效率的场景。

字节码机的概念由来已久,UCSD Pascal、SmallTalk-80 等都曾让它名声大噪。后来,Java 的 JVM 更是让字节码机成为家喻户晓的词汇。它是一种由软件解释的抽象处理器,本书避免称之为“虚拟机”,以免与 VirtualBox 这类硬件虚拟化工具混淆。

字节码机的抽象层次远高于硬件,因此能提供极大灵活性。接下来我们看看如何组织它的实现。

字节码语言实现的组织

总体上,本书的结构会遵循经典的字节码编译器 + 字节码机的组织方式:

  1. 词法分析器:读取源代码字符,划分为单词或记号(token)。
  2. 语法分析器:读取记号序列,检查其是否符合语言语法。如果合法,就生成语法树。
  3. 语义分析器:检查变量和操作是否合法,进行类型检查,并为语法树补充语义信息。
  4. 中间代码生成器:为变量和控制流位置分配内存,并生成与机器无关的中间代码指令。
  5. 最终代码生成器:将中间代码指令转为字节码文件,供加载和执行。

在此基础上,还要实现一个字节码解释器来加载并执行程序。它通常是一个巨大的 switch 循环。对某些高级语言来说,编译器部分可能很简单,真正的“魔法”在解释器中。

整个组织结构可以通过一张图来概括。

image.png

要展示如何构建一门编程语言的字节码机实现,需要写大量代码。如何呈现这些代码非常关键,它不仅会告诉你在开始前需要了解什么,还会影响你能从阅读本书中学到多少东西。

示例中使用的语言

本书采用双语并行示例模式提供代码。

  • 第一个示例语言是 Java,因为它无处不在。希望你已经会 Java(或 C++、C#),至少能中等程度地读懂示例。
  • 第二个示例语言是作者自己的语言 Unicon。在阅读过程中,你可以自行判断哪种语言更适合构建编程语言。

本书会尽可能为两个语言都提供示例,并且尽量保持写法相似。有时候这对 Java 有利,因为 Java 的层次比 Unicon 更低;而在 Unicon 中,很多事情可以写得更简洁或更高级,但我们的 Unicon 示例会尽量贴近 Java。Java 和 Unicon 的差异是明显的,但在我们使用的编译器构建工具下,这些差异会被削弱。

本书使用现代的 Lex 和 YACC 后继工具来生成扫描器和解析器。Lex 和 YACC 是声明式编程语言,可以在比 Java 或 Unicon 更高的层次上解决部分难题。如果有一种 Lex/YACC 的现代替代品(如 ANTLR)同时支持 Java 和 Unicon,那就完美了,但遗憾的是并没有。幸运的是,本书选择的工具与原始 Lex 和 YACC 非常兼容,并做了少量扩展,因此我们能够在 Java 和 Unicon 中共享相同的词法和语法规范!

除了实现语言之外,我们还需要讨论示例语言:即本书中要构建的那门语言。它只是一个占位符,代表你最终打算构建的语言。本书任意地将它命名为 Jzero。这名字取自 Niklaus Wirth 当年发明的教学语言 PL/0(即 Programming Language Zero,对 PL/1 的戏仿)。Jzero 是 Java 的一个极小子集,用来达到类似的教学目的。我曾认真搜索过(比如 Google “Jzero” 和 “Jzero compiler”),但没找到已经定义好的版本,所以我们就边写边定义。

本书的 Java 示例会在 Java 21 下测试(可能其他较新版本也能运行)。你可以从 openjdk.org 获取 OpenJDK,Linux 用户大多能通过操作系统的包管理器直接安装。至于额外的语言构建工具(如 Jflexbyacc/j),会在后续章节需要时逐步介绍。Java 示例的可运行性,可能更多取决于这些工具支持的版本。

Unicon 示例使用 Unicon 13.3,可在 unicon.org 获取。在 Windows 上,你需要下载 .msi 文件并运行安装器;在 Linux 上,则需按照官网说明操作。

编程语言与类库的区别

在了解了语言的基本实现框架之后,我们再来思考:什么时候需要创造一门新语言,而什么时候用就足够了?

除非你纯粹是为了“乐趣”或智力挑战,否则构建编程语言是一件既耗时又可能没必要的事情。如果你的动机完全是实用性的,那么只要库能解决问题,就没必要造语言。

库的作用:

  • 引入新的数据类型(类),并提供公共函数(API)来操作它们。
  • 在硬件或操作系统调用之上提供抽象层。

库做不到的事:

  • 引入新的控制结构和语法来支持新的应用领域。
  • 在现有语言的运行时系统中嵌入或扩展新的语义。

库的缺陷:

  • 往往变得越来越庞大和复杂。
  • 学习曲线有时比语言更陡峭,文档质量更差。
  • 时不时会与其他库发生冲突。
  • 应用一旦依赖库,如果库的后续版本发生不兼容修改,应用可能就会被破坏。

因此,库和语言之间存在一种自然的演化路径:先尝试通过构建或引入优秀的库来支持你的应用领域,如果结果不能满足需求,或者无法简化程序开发的复杂性,那么你就有充分理由去设计一门新语言。

在其他软件工程任务中的适用性

本书讲的是如何构建语言,但学习编程语言实现的工具与技术,对许多其他软件工程任务也大有裨益。

比如,几乎所有文件或网络输入处理都可以归为三类:

  1. 用 XML 库读取 XML 数据
  2. 用 JSON 库读取 JSON 数据
  3. 读取其他格式时,自己写代码解析

本书介绍的技术,尤其适用于第三类:自定义文件格式解析。很多时候,必须处理结构化的自定义数据。

对你们中的一些人来说,自己构建一门编程语言可能是迄今为止写过的最大型项目。如果你能坚持完成,它会教给你大量实用的软件工程技能,除了编译器和解释器本身的知识,还包括:

  • 大型动态数据结构的处理
  • 软件测试
  • 调试复杂问题

这些技能会让你收获颇丰。

动机讲得差不多了,现在该聊聊你要做的第一件事:明确你的需求

为你的语言确立需求

当你确认确实需要一种新的编程语言来完成手头的工作后,花几分钟来确立需求。这是一个开放式的过程,本质上是由你来界定项目成功的样子。明智的语言发明者不会从零开始创造一套全新的语法;相反,他们会把语法定义为对一种流行的现有语言所做的一系列修改。

许多伟大的编程语言(Lisp、Forth、Smalltalk 等)的成功在很大程度上受到限制,其中一个原因就是它们的语法与主流语言“非必要地”差异过大。尽管如此,你的语言需求仍然需要界定“它看起来像什么”,这当然包括语法。

更重要的是,你必须定义一组控制结构或语义,也就是你的编程语言需要在何处超出现有语言。有时这包括为现有语言及其库服务不佳的某个应用领域提供特殊支持。此类领域特定语言(DSL)已经十分常见,以至于有整本书专门讨论这一主题。本书的目标是聚焦于构建这类语言的编译器和运行时系统的具体细部,而不依赖你所工作的具体领域。

在常规的软件工程流程中,需求分析通常从头脑风暴列出功能性和非功能性需求开始。对于编程语言而言,功能性需求关乎终端开发者将如何与之交互的细节。你未必一开始就能预想所有命令行选项,但你大概知道是否需要交互式,还是可以接受单独的编译步骤。前文关于解释器与编译器的讨论,以及本书呈现的编译器,似乎会替你作出选择,但 Python 就是一个例子——它提供完全交互式界面,尽管你在 Python 中输入的源码会被编译为字节码并由字节码机执行,而不是被直接解释。

非功能性需求是你的编程语言必须达到、但不直接关联开发者交互的一类属性。它们包括你的语言需要运行在哪些操作系统上、执行速度要多快、以及用该语言编写的程序需要在多小的空间内运行等。

关于执行速度的非功能性需求,通常会决定你是可以以软件(字节码)虚拟机为目标,还是必须生成原生代码。原生代码不仅更快,也更难生成,并且可能使你的语言在运行时系统特性方面灵活性降低。你可以先以字节码为目标,之后再做一个原生代码生成器。

我学习编程的第一门语言是一个 BASIC 解释器,程序必须在 4 KB 内存中运行。当时的 BASIC 对内存占用要求很低。但即使在今天,你也不难遇到默认无法运行 Java 的平台!例如,在为用户进程配置了内存上限的虚拟机上,即便是编译或运行一个简单的 Java 程序,你也可能需要学习一些尴尬的命令行选项。

除了识别功能性与非功能性需求,许多需求分析方法还会定义一组用例,并让开发者为其撰写说明。发明一种编程语言与一般的软件工程项目不同,但在结束之前,你可能仍然需要做这样一份用例分析。用例是指某人使用软件应用执行的一项任务。当这个“应用”是编程语言时,如果不加以注意,用例很容易泛化得过于宽泛(如“编写我的应用”“运行我的程序”),从而失去实用性。虽然上述两条不够有用,但你可以考虑:你的语言实现是否必须支持程序开发、调试、分离编译与链接、与外部语言和库的集成,等等。其中大多数话题超出本书范围,但我们会涉及一部分。

鉴于本书将展示一种名为 Jzero 的语言的实现,下面列出 Jzero 的一些需求。其中一些需求看起来可能较为任意。你当然可以添加自己的需求,做出你的 Java 方言,但这份清单描述了我们在本书中要实现的目标。若你不确定某条需求的来源,要么它来自我们的灵感源语言(plzero),要么来自我们此前教授编译器构造课程的经验:

• Jzero 应该是 Java 的严格子集。所有合法的 Jzero 程序都应当是合法的 Java 程序。此要求使我们在调试语言实现时可以用 Java 来检验测试程序的行为。
• Jzero 应提供足够的特性以支持有趣的计算。这包括 if 语句、while 循环、多函数以及参数。
• Jzero 应支持少量数据类型,包括布尔、整数、数组以及 String 类型。不过它只需支持这些类型功能的一个子集(后文会看到)。这些类型足以让计算进行有趣的输入与输出。
• Jzero 应产生体面的错误信息,显示文件名与行号;当尝试使用 Jzero 不支持的 Java 特性时也要给出提示。我们需要合理的错误信息来调试实现。
• Jzero 的运行速度应足以满足实践需要。这个要求虽不具体,但意味着我们不会做纯解释器。纯解释器直接执行源代码而不进行任何内部代码生成,这是很复古的做法,让人想起 20 世纪六七十年代;以现代标准看,它们往往慢得难以接受。另一方面,你也完全可以决定你的语言要提供类似 Python 那样高度交互的“纯解释器”体验。不过,这不在 Jzero 的需求中。
• Jzero 应尽可能简单,以便我能讲清楚。遗憾的是,这排除了完整描述一个原生代码生成器,甚至也排除了以 JVM 字节码为目标的实现;我们将提供自己的简单字节码机。

也许随着推进会出现更多需求,但这已经是个开始。由于时间与篇幅所限,这份需求清单或许更重要的是它“不说了什么”,而非“说了什么”。做个对比,下面列出促成 Unicon 编程语言诞生的一些需求。

案例研究——启发 Unicon 语言的需求

本书将以编程语言 Unicon(见 unicon.org)为贯穿案例。我们先从两个合理的问题入手:为什么要构建 Unicon?它的需求是什么?要回答第一个问题,我们倒过来,从第二个问题说起。

Unicon 的存在源于亚利桑那大学推出的一门更早的编程语言 Icon(www.cs.arizona.edu/icon/)。Icon 在字符串与列表处理方面尤为出色,常用于编写脚本与工具,也用于编程语言与自然语言处理项目。Icon 强大的内置数据类型(包括列表与(哈希)表等结构类型)影响了多门语言,包括 Python 和 Unicon。Icon 的标志性研究贡献,是把“目标导向求值”(包括回溯与对生成器的自动恢复)融入到熟悉的主流语法中。这引出了 Unicon 的第一条需求。

Unicon 需求 #1——保留人们热爱 Icon 的特性

人们热爱 Icon 的一项内容是它的表达式语义,包括生成器与目标导向求值。生成器是一类能计算出多个结果的表达式;多种流行语言都具备生成器。目标导向求值是一种执行语义:表达式要么成功要么失败;当失败时,表达式中的生成器可以被恢复,以尝试替代结果,从而使整个表达式有可能成功。这个话题很大,超出本节范围;如需深入,可参见 Ralph 与 Madge Griswold 合著的《The Icon Programming Language(第 3 版)》,地址:www.cs.arizona.edu/icon。

Icon 还提供了丰富的内置函数与数据类型,使得大量程序可以仅通过源码就很好理解。Unicon 的“保留”目标是与 Icon 100% 兼容;最终我们实现的大约是 99% 的兼容性。

从“保留精华”跨越到“代码永生”的目标——让旧源码永远可运行——跨度不小,但对 Unicon 而言,我们把这也纳入了需求 #1。我们对向后兼容性的要求比大多数现代语言更严格。虽然 C 的向后兼容性很强,但 C++、Java、Python、Perl 等语言在不同程度上都偏离了其“黄金时代”所写程序的兼容性。就 Unicon 而言,大约 99% 的 Icon 程序无需修改即可作为 Unicon 程序运行。接下来是 Unicon 的第二条需求:支持大型项目开发。

Unicon 需求 #2——支持在大数据上的大规模程序

Icon 旨在让开发者在小型项目中实现最高生产力;典型 Icon 程序不到 1000 行,但它的抽象层级很高,几百行即可完成大量计算!然而计算机能力在不断提升,现代程序员常常需要编写比 Icon 最初设想更庞大的程序。

出于可扩展性的考虑,Unicon 像 C++ 之于 C 一样,为 Icon 增加了“类”和“包”。Unicon 还改进了字节码目标文件格式,并在编译器与运行时系统上做了诸多可扩展性改良;它也对 Icon 的既有实现进行细化,使许多细节更具扩展性,例如采用更为复杂的哈希函数。第三条需求是:以与内置类型同等的高层抽象,支持无处不在的输入/输出。

Unicon 需求 #3——面向现代应用的高层 I/O

Icon 起初是为经典 UNIX“管道-过滤器”文本处理与本地文件而设计的。随着时间推移,越来越多人希望用它来编写需要更复杂 I/O 形态的程序,如网络或图形。

可以说,尽管 CPU 速度与内存容量增长了十亿倍,1970 年与 2020 年代编程最大的差异在于:现代应用被期望使用多种复杂的 I/O 形态——图形、网络、数据库等。库固然能提供这些 I/O 的访问,但语言级支持能让它们更容易、更直观。

I/O 的定义是个“移动的靶”。Unicon 最初的 I/O 支持包括网络能力、GDBM 与 ODBC 数据库功能,以配合 Icon 的二维图形;随后又扩展到多种常见互联网协议与三维图形。什么算“普适”的 I/O 能力仍在演进,且因平台而异;例如触控输入与手势、或着色器编程能力,如今已十分普遍,也许应该作为本条需求的一部分加入 Unicon。需求 #4 让这一挑战更大。

Unicon 需求 #4——提供可普遍实现的系统接口

Icon 具有极强的可移植性。我曾在各种平台上运行它,从 Amiga 到 Cray,再到使用 EBCDIC 字符集的 IBM 大型机。尽管平台这些年发生了难以置信的变化,Unicon 仍延续 Icon 的目标——最大化源码可移植性:用 Unicon 编写的代码,应该能在所有重要的计算平台上不加修改地运行。

很长一段时间里,“可移植”意味着能在 PC、Mac 与 UNIX 工作站上运行。但同样,“重要平台”的集合也在移动。如今,要满足本需求,Unicon 应该被移植到 Android 与 iOS(如果你把它们视为计算平台)上。是否算数,可能取决于它们是否足够开放、是否用于通用计算任务,但它们显然具备这样的能力。

为满足需求 #3 而实现的那些“多汁”的 I/O 能力,必须以一种能在各大平台上多平台移植的方式进行设计。

在给出 Unicon 的主要需求之后,我们可以回答“为何要构建 Unicon?”这个问题。其一,研究了多门语言后,我得出结论:Icon 的生成器与目标导向求值(需求 #1)是我今后写程序时想要的特性。然而,在允许我为 Icon 添加二维图形之后,Icon 的发明者不再愿意考虑满足需求 #2 与 #3 的进一步扩展。其二,公众对新能力有真实需求,并且出现了志愿合作者与一定的资金支持。于是,Unicon 诞生了。

总结

在本章中,你了解了“发明一门编程语言”与“发明一个库 API 来支持你想做的计算”之间的区别,并考察了多种编程语言实现形态。本章还引导你思考为自己的语言制定功能性与非功能性需求。

你的语言的需求,可能与本文介绍的 Java 子集 Jzero 与 Unicon 的示例需求不同。之所以要定义需求,是因为它能帮助你设定目标,并界定什么叫做“成功”。对编程语言实现而言,需求既包括面向使用你语言的程序员的“外在观感与体验”,也包括它必须运行的软硬件平台。“观感与体验”既涉及外部问题(比如语言实现与用该语言编写的程序如何被调用),也涉及内部问题(例如“冗长度”:为完成某个计算任务,程序员需要写多少代码)。

你也许急于进入编码阶段。虽然新手程序员的“构建-修修补补”心态在脚本或短程序中偶尔奏效,但对于像编程语言这样体量的软件,我们需要先多做一点规划。继本章的“需求”之后,第 2 章《编程语言设计》将帮助你制订一份详细的实现计划,而这也将成为本书余下内容的关注重点。