在我的大学计算机科学实验室里,在漫长的夜晚编码和调试的休息时间,两种永恒的争论蓬勃发展:“emacs 还是 vi?”;以及“最好的编程语言是什么?” 后来,当我开始在工业界工作时,我注意到关于编程语言的争论也在硅谷园区的走廊里进行。那是 90 年代,在 Sun 公司,我们许多人都在关注 Java 在开发者中占据了重要的市场份额,特别是那些以前使用 C 或 C++ 进行开发的人。
我一直认为最佳语言的概念太主观,并且太依赖于手头的编程任务的性质。然而,在我的职业生涯中,我花了大量时间思考我认为更根本的两个相关问题。首先,从总体上看,软件工程是否正在用越来越少的语言完成?也就是说,计算机语言集是否正在趋同?其次,是什么使一种特定语言“更好”或更有用,或者更容易被特定任务采用?
在研究这些问题时,我发现特别有趣的是,不要关注重量级语言的战斗,而是关注它们不太受关注的衍生品,即专用语言。这些语言像野草一样在主流语言发展的道路旁萌芽,它们表现出的特性和历史使人们重新思考对基本语言问题的本能答案。考虑到专用语言,编程语言的开发根本没有趋同,而且实用性似乎与传统的结构概念或从语言设计的角度来看经验上“更好”的属性关系不大。专用语言甚至无视严格的定义,这种定义才配得上规范性的编译器语法学家:它们在某种程度上似乎比成熟的编程语言“更小”;它们并不总是图灵完备的;它们可能缺乏正式的语法(和解析器);它们有时是独立的,但通常是更复杂的环境或包含程序的一部分;它们通常但并非总是被解释执行;它们通常为单一目的而设计,但经常(意外地)从一种用途跳到另一种用途。有些甚至没有名字。
最重要的是,专用语言通常构成了开发大型软件系统(如操作系统)的重要组成部分,无论是作为开发工具的一部分,还是作为大型环境中不同部分之间的粘合剂。因此,挖掘出其中一些鲜为人知的创造,并考察它们与我们更广泛的语言见解之间的联系,尤其有趣。在我的职业生涯中,在从事多个商业操作系统和大型软件组件的工作时,我得出的结论是,不仅新的语言一直在开发,而且它们通常也是大规模软件系统增长和维护不可或缺的一部分。
Unix 环境及其易于连接的小工具的理念是专用语言成长的理想温床。粗略浏览 20 世纪 80 年代早期的 Unix 手册,就会发现 20 多种不同形式的小型语言正在积极使用,如图 1 所示。
这些语言从完整的编程语言 (sh) 到预处理器 (yacc),再到命令行语法 (adb),再到状态机或数据结构的表示 (正则表达式,调试器 “stabs”) 不等。二十年后,当 Sun 发布现代 Unix 系统 Solaris 10 时,几乎所有重要的新操作系统功能都涉及到引入新的专用语言:DTrace 调试软件引入了用于跟踪查询的 D 语言;故障管理系统包含了一种描述故障传播的语言;Zones 和服务管理功能包括 XML 配置语法和新的命令行解释器。
其中一种 Unix 小型语言,即 adb 调试器的历史,特别能说明在大型系统中,一些小型但有用的东西是如何意外演变和保持粘性的。
Unix 的早期开发发生在 DEC PDP 系统上,该系统有一个非常简单的调试器,称为 ODT,即八进制调试技术。(这个了不起的名字让人联想到一种秘密的功夫动作,用于使 PDP 的 12 位寄存器瘫痪。)ODT 程序支持一种极其原始的语法:在每个命令的开头指定一个八进制物理内存地址,并附加一个字符(例如,B 代表断点)或一个斜杠 (/) 来读取并可选地写入该内存位置的内容,如图 2A 所示。
就这样,一种小型语言诞生了。ODT 语法显然启发了在 PDP 上开发的新 Unix 系统的第一个调试器的形式,它简称为 db。在 1971 年的 Unix v3 时期,db 命令语法借用了基本的 ODT 模型,并开始用附加的字符后缀扩展它,以定义寻址模式和格式化选项,如图 2B 所示。
到 1980 年,db 已被 adb 取代,adb 包含在 AT&T SVR3 Unix 发行版中。语法已经演变,在过去几年中添加了新的调试命令,现在不仅支持简单的地址,还支持算术表达式(123+456 / 现在是合法的)。此外,“/” 后的字符现在表示数据格式,“$” 或 “:” 后的字符现在表示操作。adb 语法如图 2C 所示。
添加 “$<” 以读取外部命令文件特别有趣,因为它催生了原始 adb 程序或宏的开发,这些程序或宏执行一系列命令来显示 C 数据结构在特定内存地址的内容。也就是说,要显示内核 proc 结构,您需要获取其地址,然后键入 “$<proc” 来执行一系列预定义的命令,以显示进程的 C 数据结构的每个内存。SunOS 4 中 1984 年的 proc 宏内容如下所示。为了使此输出易于理解,“/” 命令现在可以附加带引号的字符串标签、换行符 (n) 和制表符 (16t),以包含在解码数据中。“.” 变量评估为应用宏时使用的输入地址,“+” 变量评估为该输入地址加上所有先前格式字符的字节计数。然后,宏与内核源代码一起维护。
address $<proc ./"link"16t"rlink"16t"nxt"16t"prev"nXXXX +/"as"16t"segu"16t"stack"16t"uarea"nXXXX +/"upri" +/"upri"8t"pri"8t"cpu"8t"stat"8t"time"8t"nice"nbbbbbb +/"slp"8t"cursig"16t"sig"bbX +/"mask"16t"ignore"16t"catch"nXXX +/"flag"16t"uid"8t"suid"8t"pgrp"nXddd +/"pid"8t"ppid"8t"xstat"8t"ticks"nddxd +/"cred"16t"ru"16t"tsize"nXXX +/"dsize"16t"ssize"16t"rssize"nXXX +/"maxrss"16t"swrss"16t"wchan"nXXX +/16+"%cpu"16t"pptr"16t"tptr"nXXX +/"real itimer"n4D +/"idhash"16t"swlocks"ndd +/"aio forw"16t"aio back"8t"aio count"8t"threadcnt"nXXXX
十多年后,在 1997 年,我在 Sun 公司工作,开发后来成为 Solaris 7 的产品。此版本是我们的第一个 64 位内核,但首选的内核调试工具仍然是 adb,就像 1984 年一样,我们的源代码库现在包含数百个有用的宏文件。不幸的是,adb 的实现基本上不可能从 32 位干净地移植到 64 位以调试新内核,因此开发具有更多现代调试器功能的新干净代码库的时机似乎已经成熟。
当我考虑如何最好地解决这个问题时,我震惊地发现,尽管 adb 的代码库脆弱且结构松散,但它的关键特性在于其语法深深地印在了我们所有最有经验和最有效的工程师的脑海和行为中。(正如当时有人恰如其分地说,“它在手指里。”)因此,我着手构建一个新的模块化调试器 (mdb),它将支持用于高级内核调试和其他现代功能的 API,但仍将与现有语法和宏精确地向后兼容。在新的前缀 (“::”) 之后添加了复杂的新功能,这样它们就不会破坏现有语法(例如,
“::findleaks” 用于检查内核内存泄漏)。然后,整个语法被正确地编码为 yacc 解析器。宏文件被逐步淘汰,取而代之的是编译器生成的调试信息,但 “$<” 语法被保留为别名。又过了十年,mdb 仍然是 OpenSolaris 内核事后调试的标准工具,并已被数百名程序员扩展。
调试器的故事说明,一种小型专用语言基本上可以随机演变,没有清晰的设计,没有一致的语法或解析器,也没有名称,但可以在交付的操作系统中持久存在和发展超过 40 年。在同一时期,许多主流语言来来去去(Algol、Ada、Pascal、Cobol 等)。从根本上说,这个调试器之所以能够幸存下来,原因只有一个:它简洁地编码了其用户执行的确切任务,从而与这些用户建立了联系。获取地址,转储其内容,查找下一个地址,跟踪到下一个感兴趣的位置,转储其内容,等等。对于专用语言而言,与任务和该任务的用户社区的深入联系通常比巧妙的设计或优雅的语法更有价值。
变异,一些是偶然的,一些是故意的,通常在专用系统语言的开发中起着至关重要的作用。一种常见的变异形式涉及将一种语言(例如,表达式或正则表达式)的语法子集添加到另一种语言中。这种类型的变异可以使用预处理器来实现,预处理器将一种高级形式转换为另一种形式,或者将预处理的语法与目标语言的目标语法混合在一起。变异可能会发散到足够远的程度,从而形成一种新的混合语言。解析器工具 yacc 和 bison 是完整的混合语言最著名的例子:语法被声明为一组解析规则,这些规则与响应规则而执行的 C 代码混合在一起;然后,这些实用程序发出一个完成的 C 程序,其中包括规则代码和在语法上执行解析状态机的代码。
早期 Unix 中这种类型变异的另一个例子是 Brian Kernighan 开发的 Ratfor(Rational Fortran)预处理器。Ratfor 允许作者使用 C 表达式和逻辑块编写 Fortran 代码,结果被翻译成带有行号和 goto 语句的 Fortran 语法,如图 3 所示。
一种更奇怪的变异语言是 C 和 Algol 语法的混合体,它是使用 C 预处理器开发的,并用于 adb 的代码中,还有什么?显然,Algol 风格的 Unix sh 语法的作者 Steve Bourne 决心让 Algol 的一些基因组在物种中延续下去。一些示例代码如图 4 所示。
唉,稍后版本的代码通过预处理器运行,然后签入,以便于维护。许多未来的语言都包含了更清晰设计的杂交,以简化从一个环境到另一个环境的过渡。随着 C 的广泛采用,它的表达式语法被引入了大量新的语言中,无论大小,包括 Awk、C++、Java、JavaScript、D、Ruby 和许多其他语言。同样,随着 Perl 的成功,许多其他脚本语言采用了其对正则表达式语法的有用扩展作为新的规范形式。表达式语法等核心概念通常构成小型语言的大部分,而借鉴成熟的模型可以实现快速的语言实现和用户的快速采用。
在大型软件系统的开发中,小型语言通常与主流开发语言或软件系统本身共生共存。前面描述的 adb 宏语言很可能无法在其 Unix 父系统的源代码库之外生存。您最喜欢的电子表格的宏语言是另一个例子:它的存在是为了提供一种方便的方式来操作包含软件应用程序的用户可见的抽象。
在操作系统领域,我最喜欢的鲜为人知的共生例子是 Forth 和 SPARC 汇编语言的结合,这是 Sun 公司在 OpenBoot 固件上工作的一部分。其想法是创建一个小型解释器,用作 SPARC 工作站上的启动环境。之所以选择 Forth 作为新硬件的启动和硬件启动环境,是因为该语言内核很小,可以立即在新处理器和平台上启动。然后,使用 Forth 字典,可以在解释器中动态定义用于调试的新命令。由于 Forth 允许其字典覆盖解释器中单词(标记)的定义,因此有人提出了一个创造性的想法,即使用解释器作为硬件的宏汇编器。创建了一组字典,重新定义了 SPARC 中的每个操作码(ld、move、add 等),并使用 Forth 代码计算汇编指令的二进制表示形式,并将它们存储到内存中。因此,整个低级函数可以用看起来像汇编语言的形式编写,以 Forth 标头为前缀,并键入到小型解释器中,然后解释器会在解析标记并执行生成的例程时,将目标代码汇编到内存中。
近年来,Web 浏览器已成为变异和共生的沃土。现代 Web 开发中的两个核心人物是解释型 JavaScript 和 XML。(XML 本身是各种其他语言的语法,也是混合语言和变异的丰富来源。)在常见的 Ajax 编程模型中,JavaScript 对象可以序列化为 XML 形式,XML 编码可以用于将远程过程调用传递回服务器。在其中一种编码 XML-RPC 中,提供了一个名为 multicall 的标准扩展,供浏览器客户端在单个传输中向服务器发出多个过程调用。此处显示了对方法 x.foo 的单个调用,然后是使用 multicall 对同一方法的一系列调用
x.foo( { bar: 123, baz: 456 } ) ;
system.multicall (
{ methodName: ‘x.foo’,
params: [ { bar: 123, baz: 456 } ] },
{ methodName: ‘x.foo’,
params: [ { bar: 789, baz: 654 } ] },
{ methodName: ‘x.foo’,
params: [ { bar: 222, baz: 333 } ] }
)
在为新系列的存储产品实现 Ajax 用户界面代码时,Sun Fishworks 团队希望开发一种方法来最大程度地减少不必要的客户端-服务器交互。开发的第一个概念是 multicall 调用,其参数是另一个调用的结果。在以下示例中,方法 x.foo 在单个 XML-RPC 交互中对 x.bar 的结果进行调用
system.multicall (
{ methodName: ‘x.foo’, methodParams: [
{ methodName: ‘x.bar’, params: [ 1, 2, 3 ] }
] } ,
...
)
这里的诀窍是,新的结构成员 methodParams 表明,下一个成员不是静态参数,而是更多要递归调用的方法,结果被推入堆栈。一旦堆栈诞生,自然而然地开始从基于堆栈的语言中投入运算符,形成一种全新的解释型语言,该语言本身在 JavaScript 中声明为数据,通过现有的 XML-RPC 序列化发送到服务器,并由我们 XML-RPC 解释器引擎的扩展执行。我们在 Sun 公司实施的一些运算符如下所示
system.multicall (
{ foreach: [ [ 2, 4, 6 ], [
{ methodName: ‘x.foo’, params: [] },
{ push: [ ] },
{ div: [ { pop: [] }, 2 ] }
] ] }
...
)
此示例说明,与 JavaScript 的共生关系本质上允许我们的语言存在,而无需自己的词法分析器或解析器,并且从根本上满足了将性能关键代码从 JavaScript 卸载到我们的服务器并最大程度地减少往返行程的目的。在视频游戏行业,Lua 和 C/C++ 之间也形成了类似的共生关系(没有混合语法)。Lua 脚本语言为在视频游戏引擎中编写非性能关键代码提供了一种流行的形式,并且 Lua 解释器设计使其易于桥接到 C 代码。
一旦两种或多种语言在大型软件系统中交互,自然而然地就会围绕它们涌现出一个工具生态系统(可能包含具有混合语法的小型语言),以简化整个系统的维护、开发和调试。围绕完整软件系统的语言(无论大小,专用还是通用)建立的生态系统越丰富,整个环境就越能蓬勃发展,其组成部分也就越能生存。因此,当我们越来越高地构建我们的软件抽象塔时,我们应该期望看到和了解更多的语言,而不是更少的语言。
MIKE SHAPIRO ([email protected]) 是 Sun Microsystems 的杰出工程师,目前在旧金山领导 Sun 的 Fishworks 高级工程团队。他之前曾在 Sun 内核工程部门工作,在那里他为 Solaris 开发了各种技术,包括 pgrep、pkill、mdb、dumpadm、libproc、CTF、fmd、DTrace D 语言和编译器、smbios 以及与 CPU、内存、I/O 和软件故障处理和诊断相关的各种功能。
最初发表于 Queue 第 7 卷,第 1 期—
在 数字图书馆 中评论本文
Matt Godbolt - C++ 编译器中的优化
在向编译器提供更多信息方面需要权衡:这会使编译速度变慢。链接时优化等技术可以为您提供两全其美的效果。编译器中的优化不断改进,间接调用和虚函数分派方面即将到来的改进可能很快就会带来更快的多态性。
Ulan Degenbaev, Michael Lippautz, Hannes Payer - 作为合资企业的垃圾回收
跨组件跟踪是解决跨组件边界的引用循环问题的一种方法。只要组件可以形成任意对象图,并且 API 边界上的所有权不平凡,就会出现此问题。CCT 的增量版本在 V8 和 Blink 中实现,从而能够以安全的方式有效且高效地回收内存。
David Chisnall - C 不是一种低级语言
在最近的 Meltdown 和 Spectre 漏洞之后,值得花一些时间来研究根本原因。这两种漏洞都涉及处理器推测性地执行超出某种访问检查的指令,并允许攻击者通过侧信道观察结果。导致这些漏洞以及其他几个漏洞的功能的添加,是为了让 C 程序员继续相信他们在用低级语言编程,但这在几十年里早已不是这种情况了。
Tobias Lauinger, Abdelberi Chaabane, Christo Wilson - 你不应该依赖我
大多数网站都使用 JavaScript 库,其中许多库已知是易受攻击的。了解问题的范围,以及库被包含的许多意外方式,只是改进情况的第一步。这里的目标是,本文中包含的信息将有助于为社区提供更好的工具、开发实践和教育工作。