本文原发于《程序员》2014年11月刊,发表时略有修改。 

计算机科学是一门应用科学,几乎所有概念都是为了理解或解决实际问题而生的。协程 (Coroutine) 的出现也不例外。协程的概念,最早可以追溯到写作 COBOL 语言编译器中的技术难题。

从磁带到协程

COBOL 是最早的高级语言之一。编译器则是高级语言必不可少的一部分。现如今,我们对编译器了解,已经到了可以把核心内容浓缩成一本教科书的程度。然而在六十年代,如何写作高效的语言编译器是那个时代绕不过的现实问题。比如,1960 年夏天,D. E. Knuth 就是利用开车横穿美国去加州理工读研究生的时间,对着 Burroughs 205 机器指令集手写 COBOL 编译器。最早提出“协程”概念的 Melvin Conway 的出发点,也是如何写一个只扫描一遍程序 (one-pass) 的 COBOL 编译器。众多的“高手”纷纷投入编译器书写,可见一门新科学发展之初也是筚路蓝缕

以现代眼光来看,高级语言编译器实际上是多个步骤组合而成:词法解析,语法解析,语法树构建,以及优化和目标代码生成等等。编译实质上就是从源程序出发,依次将这些步骤的输出作为下一步的输入,最终输出目标代码。在现代计算机上实现这种管道式的架构毫无困难:只需要依次运行,中间结果存为中间文件或放入内存即可。GCC 和 Clang 编译器,以及 ANTLR 构建的编译器,都遵循这样的设计。

在 Conway 的设计里,词法和语法解析不再是两个独立运行的步骤,而是交织在一起。编译器的控制流在词法和语法解析之间来回切换:当词法模块读入足够多的 token 时,控制流交给语法分析;当语法分析消化完所有 token 后,控制流交给词法分析。词法和语法分别独立维护自身的运行状态。Conway 构建的这种协同工作机制,需要参与者“让出 (yield)”控制流时,记住自身状态,以便在控制流返回时能够从上次让出的位置恢复(resume)执行。简言之,协程的全部精神就在于控制流的主动让出和恢复。我们熟悉的子过程调用可以看作在返回时让出控制流的一种特殊的协程,其内部状态在返回时被丢弃了,因此不存在“恢复”这个操作。

以现在眼光来看,编译器的实现并不必然需要协程。然而,Conway 用协程实现 COBOL 编译器在当时绝不是舍近求远。首先,从原理上来说,因为 COBOL 并不是 LL(1) 型语法,即使现在我们也无法简单构建一个以词法分析为子过程的自动机。其次,当年计算机依赖于磁带存储设备,而磁带存储设备只支持顺序存储(设想一下随机访问带来的频繁的倒带和快进问题)。也就是说,依次执行编译步骤并依靠中间文件通信的设计是不现实的,各步骤必须同步前进。正是这样的现实局限和设计需要,自然催生了协程的概念。

自顶向下,无需协同

虽然协程是伴随着高级语言诞生的,它却没有能像子过程一样成为通用编程语言的基本元素。

从 1963 年首次提出到上个世纪九十年代,我们在 ALOGL, Pascal, C, FORTRAN 等主流的命令式编程语言中都没有看到原生的协程支持。协程只稀疏地出现在 Simula,Modular-2 (Pascal 升级版) 和 Smalltalk 等相对小众的语言中。协程作为一个比子进程更加通用的概念,在实际编程却没有取代子进程,这一点不得不说是出乎意外的。如果我们结合当时的程序设计思想看,这一点又是意料之中的:协程是不符合那个时代所崇尚的“自顶向下”的程序设计思想的,自然也就不会成为当时主流的命令式编程语言 (imperative programming) 的一部分。

正如面向对象的语言是围绕面向对象的开发理念设计一样,命令式编程语言是围绕自顶向下(top-down)的开发理念设计的。在自顶向下的理念指导下,程序被切分为一个主程序和大大小小的子模块,每一个子模块又可能调用更多子模块等等。C 家族语言的 main() 函数就是这种自顶而下思想的体现。在这种理念指导下,各模块形成层次调用关系,而程序设计就是制作这些子过程。在“自顶向下”这种层次化的理念下,具有鲜明层次的子过程调用成为软件系统最自然的组织方式,也是理所当然。相较之下,具有执行中让出和恢复功能的协程在这种架构下无用武之地。可以说,自上而下的设计思想从一开始就排除了对协程的需求。其后的结构化编程(Structural Programming) 思想,更是进一步强化了“子过程调用作为唯一控制结构”的基本假设。在这样的指导思想下,协程一直没有成为当时编程语言的一等公民。

尽管从提出到上世纪 90 年代,协程在编程语言中没有普遍成为一等公民,但作为一种易于理解的控制结构,协程的概念渗入到了软件设计的许多方面。在结构化编程思想一统天下之时, D. Knuth 曾经专门写过一篇 “Structured Programming with GOTO” 来为 GOTO 语句辩护。在他列出的几条 GOTO 可以方便编程且不破坏程序结构的例子中,有一个(例子7b)就是用 GOTO 实现协程控制结构。相比较之下,不用 GOTO 的“结构化”代码反而失去了良好的结构。当然,追求实际结果的工业界对于学界的这场要不要剔除 GOTO 的争论并不感冒。当时许多语言都附带了不建议使用的 GOTO 语句,显得左右逢源。这方面一个最明显的例子就是 Java:其语言本身预留了 goto 关键字,其编译器却没有提供任何的支持,可以说在 goto 这场争论中做足了中间派。

实践中,协程的思想频繁应用于任务调度和流处理上。比如,UNIX 管道就可以看成是众多命令间的协同操作。当然,管道的现代实现都是以 pipe() 系统调用和进程间的通信为基础,而非简单遵循协程的 yield/resume 语法。

许多协同式多任务操作系统,也可以看成协程运行系统。说到协同式多任务系统,一个常见的误区是认为协同式调度比抢占式调度“低级”,因为我们所熟悉的桌面操作系统,都是从协同式调度(如 Windows 3.2, Mac OS 9 等)过渡到抢占式多任务系统的。实际上,调度方式并无高下,完全取决于应用场景。抢占式系统允许操作系统剥夺进程执行权限,抢占控制流,因而天然适合服务器和图形操作系统,因为调度器可以优先保证对用户交互和网络事件的快速响应。当年 Windows 95 刚刚推出的时候,抢占式多任务就被作为一大买点大加宣传。协同式调度则等到进程时间片用完或系统调用时转移执行权限,因此适合实时或分时等等对运行时间有保障的系统。

另外,抢占式系统依赖于 CPU 的硬件支持。 因为调度器需要“剥夺”进程的执行权,就意味着调度器需要运行在比普通进程高的权限上,否则任何“流氓(rogue)”进程都可以去剥夺其他进程了。只有 CPU 支持了执行权限后,抢占式调度才成为可能。x86 系统从 80386 处理器开始引入 Ring 机制支持执行权限,这也是为何 Windows 95 和 Linux 其实只能运行在 80386 之后的 x86 处理器上的原因。而协同式多任务适用于那些没有处理器权限支持的场景,这些场景包含资源受限的嵌入式系统和实时系统。在这些系统中,程序均以协程的方式运行。调度器负责控制流的让出和恢复。通过协程的模型,无需硬件支持,我们就可以在一个“简陋”的处理器上实现一个多任务的系统。我们见到的许多智能设备,如运动手环,基于硬件限制,都是采用协同调度的架构。

协程的复兴和现代形式

编程思想能否普及开来,很大程度上在于应用场景。协程没有能在自顶向下的世界里立足,却在动态语言世界里大放光彩,这里最显著的例子莫过于 Python 的迭代器和生成器。

回想一下在 C 的世界里,循环的标准写法是 for (i = 0; i < n; ++i) { … }。 这行代码包含两个独立的逻辑, for 循环控制了 i 的边界条件, ++i 控制了 i 的自增逻辑。这行代码适用于 C 世界里的数组即内存位移的范式,因此适合大多数访问场景。到了 STL 和复杂数据结构的世界,因为许多数据结构只支持顺序访问,循环往往写成: for (i = A.first(); i.hasNext();i = i.next()) { … }

这种设计抽象出了一个独立于数据结构的迭代器,专门负责数据结构上元素访问顺序。迭代器把访问逻辑从数据结构上分离出来, 是一个常用的设计模式 (GoF 23个设计模式之一).我们在 STL 和 Java Collection 中也常常看到迭代器的身影。

在适当的时候,我们可以更进一步引入一个语法糖(脚注:这里牵涉到一个外部迭代器和内部迭代器的问题。限于篇幅不在此讨论)将循环写成: for i in A.Iterator() {func(i)}。

事实上,许多现代语言都支持类似的语法。这种语法抛弃了以 i 变量作为迭代指针的功能,要求迭代器自身能够记住当前迭代位置,调用时返回下一个元素。读者不难看到,这种架构就是我们在文章开始提到的语法分析器的架构。正因为如此,我们可以从协程的角度来理解迭代器:当控制流转换到迭代器上时,迭代器负责生成和返回下一个元素。一旦下一个元素准备就绪,迭代器就让出控制流。这种特殊的迭代器实现在 Python 中又被成为生成器。以协程的角度切入的的好处是设计大大精简。实际上,在 Python 中,生成器本身就是一个普通的函数,和普通函数的唯一不同是它的返回语句是协程风格的 yield。这里,yield 一语双关,既是让出控制流,也是生成迭代器的返回值。

以上我们仅仅讨论了生成器的最基本的特性。实际上,生成器的强大之处在于我们可以像 UNIX 管道一样串联起来,组成所谓的生成器表达式。如果我们有一个可以生成 1,2,3 … 的生成器 N,则 square = (i **2 for i in N) 就是一个生成平方数的生成器表达式。注意这里圆括号语法和 list comprehension 方括号语法的区别,square = [i **2 for i in N] 是生成一个具体的列表。我们可以串联这些生成器表达式,最终的控制流会在这些串联的部分间转换,无需我们写作复杂的嵌套调用。当然,yield 只是冰山的一角,现代的 Python 语言还充分利用了 yield 关键字构建了 yield from 语句,(yield) 语法等等,使得我们无困难的将协程的思想融入到 Python 编程中去。限于篇幅这里不再展开。

我们前面说过,协程的思想本质上就是控制流的主动让出和恢复机制。在现代语言里,可以实现协程思想的方法很多,这些实现间并无高下之分,所区别的就是是否适合应用场景。理解这一点,我们对于各种协程的分类,如半对称/对称协程,有栈与无栈协程等具体实现就能提纲挈领,无需在实现细节上纠结。
协程在实践中的实现方式千差万别,一个简单的原因,是协程本身可以通过许多基本元素构建。基本元素的选取方式不一样,构建出来的协程抽象也就有差别。比如, Lua 语言选取了 create, resume 和 yield 作为基本构建元素, 从调度器层面构建出所谓的“非对程”协程系统。而 Julia 语言绕过调度器,通过在协程内调用 yieldto 函数完成了同样的功能,构建出了一个所谓的对称协程系统。尽管这两个语言使用了同样的 setjmp 库,构造出来的原语却不一样。又比如,许多 C 语言的协程库都使用了 ucontext 库实现,这是因为 POSIX 本身提供了 ucontext 库,不少协程实现是以 ucontext 为蓝本实现的。这些实现,都不可避免地带上了 ucontext 系统的一些基本假设,比如协程间是平等的,一般带有调度器来协调协程等等(比如 libtask 实现,以及云风的 coroutine 库)。Go 语言的一个鲜明特色就是通道(channel)作为一级对象。因此,resume 和 yield 等在其他语言里的原语在 go 里都以通道方式构建。我们还可以举出许多同样的例子。这些风格的差异往往和语言的历史,演化路径,和要解决的问题相关,我们不必苛求他们的协程模型一定要如此这般。

总的来说,协程为协同任务提供了一种运行时抽象。这种抽象非常适合于协同多任务调度和数据流处理。在现代操作系统和编程语言中,因为用户态线程切换代价比内核态线程小,协程成为了一种轻量级的多任务模型。我们无法预测未来,但是可以看到,协程已经成为许多擅长数据处理的语言的一级对象。随着计算机并行性能的提升,用户态任务调度已经成为一种标准的多任务模型。在这样的大趋势下,协程这个简单且有效的模型就显得更加引人注目。

本文原发于《程序员》2014年6月刊,发表时略有修改。 

首位 ACM 图灵奖得主  Alan Perlis 曾说过:“如果一门编程语言不能影响你的思维,就没有学的必要’。尽管能通过这个严苛测试的语言稀稀朗朗,在我看来,PostScript 在这个测试中至少得 A。作为一个着重于平面出版应用的领域特定语言(DSL),PostScript 彻底地改变了桌面出版行业。除此之外,PostScript 还是一个设计简单但功能强大的编程语言,含有许多至今仍可以借鉴的珠玑。

PostScript 的领域对象和操作

作为针对桌面出版的文档描述语言,PostScript 的设计者力图要解决的核心问题,是如何设计一个灵活高效的语言,以操控桌面出版里各种各样的图形对象,并保证设备无关性。我们不妨戴上语言设计者的眼镜,来模拟一下这个过程。

我们面临的首要问题是如何来描述桌面出版里的种种复杂对象和操作。尽管任何平面出版物最终都是二维像素点的集合,我们并不希望这个语言局限于描述像素点的颜色。这个语言最好能够直接描述文字,线条,形状等设计师熟悉的对象。因为从根本上讲,如果我们要设计的描述语言没有足够的表达能力,不能够精简高效地表达图片,字体,形状,颜色等桌面出版领域的业务对象,这个语言将不可避免地“难用”。一般来说,把领域特定语言设计得“好用”,需要深厚的领域知识 (domain knowledge)。所幸的是, PostScript 的设计者们,原先在施乐 PARC 从事激光打印机控制语言设计,对于桌面出版可算驾轻就熟。因此,他们毫不费力地选取了  Bézier 曲线,矢量字体,绘图路径(Path) 等作为整个绘图系统的基本结构。在对这些对象的操作上,PostScript 选取了平移,旋转,放缩等仿射变换,加上路径操作和字体控制,构成了一个强大但规整的绘图系统。

PostScript 绘图系统的设计深刻影响了后来的许多矢量图形系统。举例说,如今计算机使用的矢量字体均采用 Bézier  曲线描述,即起源于 PostScript;如今几乎所有的矢量绘图语言都支持的“路径”,也起源于 PostScript。我们不在此详细展开这些领域对象选取背后的原因。对 PostScript 感兴趣的读者可以阅读 PostScript Language Tutorial & Cookbook (也称 Bluebook) 以了解 PostScript 的一些基本概念。

 

PostScript 的语言设计

基本领域对象确定后,我们就可以换上计算机语言设计者的帽子,力求设计出一个“灵活高效”和“设备无关”的语言来控制这些领域对象。设计目标落实为具体需求,包含以下三个。第一,语言本身要能够表达曲线,字体,图片,形状等等领域对象;如颜色,分页以及这些对象的平移旋转等操作,在语言里最好也都是一等公民,能够直接表达。第二,语言的表达能力要足够强大,最好是图灵完全的,以支持现实中灵活的需求。第三,语言要与设备无关,也就是说,语言将运行在一个虚拟机或解释器上,而非直接编译为二进制代码。考虑到我们要设计的语言是针对桌面出版的,最终还要加上一条:这个语言的语法和结构要足够简单,使得非编程专业人士也能使用。

有了需求的指导,我们不难理解 PostScript 所采取的设计:以一个易用的,图灵完全的语言作为蓝本,加入众多针对桌面出版的对象操作,并实现一个轻量的,与设备无关的解释器。事实上,PostScript 是以 FORTH 语言作为蓝本设计的。选取 FORTH 的主要原因,是因为它是一个轻量级的,基于栈虚拟机的语言。FORTH 的表达能力和易用性当时已经被实践所证明,因此借用它的基本控制语法就是一个很自然的选择。

 

逆波兰表示法和度量单位

逆波兰表示法是 FORTH 和 PostScript 等基于栈的语言的一个鲜明特点。在 ALGOL 家族语言中,3乘以4的一般写法是 3 * 4,即运算符中缀。PostScript 将运算符后缀,写作 “3 4 mul”。意思是将 3, 4 分别推入栈中,然后将乘法(multiply) 操作运用于两个栈顶元素(弹出),并将乘积结果入栈。FORTH 仍然采用 + * 等数学符号。PostScript 规范化了所有的操作符,一致采用 add, mul 等单词操作符来代替 +, * 等传统的中缀操作符。我们将稍后阐明规整化的优点。这里我们只需要了解一点: PostScript 程序本质上是一个后缀表达式。PostScript  没有所谓的语法,只有栈操作。如果非要说有语法,那就是逆波兰表示法。这一点非常类似于 LISP:所谓的语法,就是 S 表达式。

PostScript 允许以闭包定义新操作符,其中,闭包是放在 {} 中的后缀表达式。比如,“乘以3”这个操作可定义为: /mul3 { 3 mul } def。这里,/mul3 表示取 “mul3” 的符号值。{ 3 mul } 是一个闭包,而 def 将 mul3 这个符号,映射到 { 3 mul } 闭包。据此,4 mul3 即为 4 3 mul。

其实,从语法上来看,/mul3 { 3 mul } def 和 3 4 mul 并没有明显的不同:都是前两个操作元入栈,最后一个操作符进行运算。也就是说,PostScript 的栈是异构的,符号,数字和闭包都可以放入栈中。许多操作符如 if ,也依赖于栈上有一个布尔值和一个闭包。这种不在栈中区分代码和数据的设计,允许我们重写栈上的闭包。实际上我们可以证明这个特性等价于 LISP 里的宏 (Macro) 的表达能力,限于篇幅我们不仔细展开。

现在,我们从这个 mul3 这个平淡无奇的例子出发,定义一个英寸 (inch) 的操作符: /inch {72 mul} def。一眼看去,{72 mul} 是闭包,而 inch 是长度单位,两者毫不相干,为何强拉在一起? 原来,PostScript 的基本长度单位是 1/72 英寸,因此 5 inch 即为展开为 5 72 mul, 或者说 360 个基本单位。Inch 的定义使得我们可以书写 1.2 inch 2.3 inch moveto 这样直观的程序。

用闭包定义常用度量单位在 PostScript 中并不少见。对于从未接触过这种定义方法的读者来说,相信 inch 这个例子让人印象深刻,因为它昭示了度量单位的实质:度量单位是后缀闭包。比如我们说 10 美元的时候,已经在自觉或不自觉地将“美元”单位替换成 {汇率 mul} 闭包,换算成 60 人民币等。实际上,任何度量单位之所以能够被我们感知,都是因为我们脑中的一个潜在后缀闭包的作用。在摄氏度体系下的人对华式温度没有感觉,或者仅接触一定数量级范围内的人对大数字不敏感,都是一个原因:我们尚未建立一个将不熟悉的单位或数量级转化为可感知的单位或数量级的闭包。

 

PostScript 的运行时字典栈

除了基本控制语法外,PostScript 引入了对于图形处理很重要的两个基本数据结构:字典和数组。可以想像,存有一系列点的数组可以表达一个字符的轮廓,而字典可以很好地表达一套字体。不仅如此,通过字典栈这个概念,PostScript 具有了 FORTH 和其他栈语言所完全不具有的动态特性。我们仍然以一个例子说明。

我们定义一个求直角三角形斜边长度的操作 hyp,即 /hyp { dup mul exch dup mul add sqrt } def (这里 dup 表示重复栈顶元素,exch 表示交换栈顶两元素,sqrt为平方根,读者可以自行验证这个函数的正确性)。 这里, 3 4 hyp 得到 5。

对于解释器来说,我们新定义的 hyp 与 mul 并没有本质的不同(后缀表达式和规则化带来的便利)。解释器处理这些操作符时,无论是语言预先定义的还是用户定义的,不可避免的需要进行符号表查找。可能的区别仅是到不同的符号表里查找。进一步说,一个叫 inch 的符号在没有进行符号表查找之前,我们根本不能确定这究竟是一个变量,还是一个闭包。

为了一致地处理符号表的查找操作, PostScript 引入了字典栈 (dictionary stack) 的概念。字典栈是一个由解释器维护的栈,而栈中的元素则是作为符号表的字典。解释器启动后,系统字典 systemdict 中含有所有预定义操作符和变量,如 add, mul 等。用户字典 userdict 将涵盖自定义的操作符和变量。用户也可以随时建立新的字典插入字典栈中。

以字典方式存储符号表是容易理解的,可是为什么需要把这些字典加入“栈”中呢?原来,PostScript 是按栈的顺序在字典中寻找操作符的。假如定义 “/mul {add round} def”,则当前字典里的 mul 会被优先使用,而系统定义的 mul 不再可见。乍一看之下,这和面向对象语言里提到的运算符重载概念类似。实质上,PostScript 的设计灵活许多。

首先,因为字典栈的存在,每个运算符都自动有了作用域(预定义的运算符因为存在于 systemdict 中从而有全局作用域)。通过字典栈,我们可以实现其他语言中的 lambda 表达式或者 Java 中的匿名内部类。PostScript 的运算符本质上是动态作用域的,但因为字典栈的存在,我们可以轻松实现词法作用域,方法即是在作用域中临时定义一个字典,在字典中定义新的操作符,并将字典推入字典栈。这样,只要在作用域结束时弹出临时字典,操作符定义也随之撤销。许多 PostScript 程序都采用这种方法构建用户自定义操作符:用户可以局部重定义分页操作符 showpage 以进行页面计数,局部重定义错误处理操作符 handleerror 处理异常等等。

其次,字典栈巧妙地支持了局部变量。和闭包一样,局部变量的本质是有作用域的值。基于栈的语言对函数局部变量是不友好的,因为局部变量本身是对处理器寄存器的抽象,访问局部变量也是采取随机存取而非按栈顺序存取的方式。而栈机器本身不直接支持寄存器抽象。熟悉 JVM 的读者都知道,JVM 的 {a,i,l,f,d}{load,store} 系列指令,非常繁冗地支持局部变量数组和栈之间的转存。在字典栈中,局部变量有了优雅的解决方法:通过建立临时字典,我们可以在不引入复杂的转存操作下随机存取随机变量,而且局部变量的作用域得到了保障。比如,以下程序定义了一个叫做 localvariable 的局部变量,作用域仅限于 /sampleproc。而将 something 换成 {something} 闭包,即是一个局部的操作符定义。

 

/sample_proc

 { 1 dict begin % 定义一个大小为1的临时字典

/local_variable something def

    end   % begin end 之间为字典元素

    …   % 具体的函数定义

 } def

PostScript 和语言的 Annotation

因为 .ps 文件本质是一个程序而非文档,打印 PostScript 文件的过程实质上是调用 PostScript 解释器执行程序的过程。因为 PostScript 的图灵完全性,在 PostScript 程序执行完之前我们对文档的结构信息,比如一共多少页,文档有没有彩色元素等等结构化的信息一无所知。PostScript 设计于在桌面出版业未起步时,因此仅仅关心绘制控制,并未考虑到如何表示这些结构信息,这样的缺憾是可以理解的。HTML 语言也经过了这样的道路:早期引入 FONT BIG 这种纯展示标签,而如今最佳实践是将结构信息放入 HTML,而将格式信息交给 CSS。

因为 PostScript 的成功,越来越多的人希望作为桌面出版标准格式的 PostScript 能够包含文档结构信息。比方说,如果打印管理系统能够在将 PostScript 任务交给打印机之前知道文档的页数,就可以更好的调度打印任务,或者按页面收取费用等。这些关于文档的结构信息并不影响页面的展示,却是文档不可或缺的一部分。

为解决这个问题,PostScript 用户自发地定义了一种通过注释表示文档结构信息的方法。比如,在一个10页的文档开头加入 %%Pages: 10,每一页的开始加入 %% Page N 等等。因为是注释,PostScript 解释器可以选择忽略它,而其他程序则可以据此管理文档。许多桌面出版软件也采取这样的方法写入作者,创建日期等信息。在强大的需求和既定行业标准的驱动下,Adobe 终于决定标准化这些用来表征文档结构的注释,发布了一系列的“文档结构约定(Document Structuring Conventions)”。之所以叫约定,因为木已成舟,无法强行要求每个 PS 文档管理器或打印机都遵守标准。

DSC 使得静态结构检查变得可能。前文提到,PostScript 语法就一种——后缀表达式,静态语法检查并没有意义,而正确性检查却又非常难。引入文档结构约定后,我们就有条件检查一些约束,比如在宣称的描述一页的区块之内没有非法的分页操作等。DSC 不影响现有语言逻辑,却引入了新的语义正确性约束。

DSC 这种引入新的元信息以静态检查程序的语义正确性的思想非常有前瞻性。可惜的是,因为了解 PostScript 的人较少,这样的思想没能在其他语言中实现。Java 5.0 才正式引入了 annotation 的概念,用 @override 这样的标记帮助编译器检查方法多态。Python 2.2 引入 classmethod, instancemethod 等 decorator 以检查方法的定义,而 C++ 最近才正式支持 annotation。这些比程序本身要抽象的元信息,越来越多地成为了自动分析工具的帮手。在 Google,我们采用一套线程安全的标记以帮助编译器静态检查代码的线程安全性。所有的这些,都成了提升开发效率的好帮手。以文档标记等方式记录元信息的思想可以追溯到 D.E. Knuth 的文学编程 (Literate Programming),而 PostScript,是我所知的第一个以元信息约束程序语义的编程语言。

 

其他一些有趣的历史

PostScript 语言的历史很有趣也很能给人启发,限于篇幅我仅录几则。首先,PostScript 其实和 Smalltalk 很相似。因为同样出自于施乐 PARC 的研究,PostScript 语言风格受到 Smalltalk 影响很大。比如闭包的设计,if 和 repeat 语法的设计,几乎就是 Smalltalk 的翻版,仅在运算符顺序上有区别。

Adobe 的几位创始人从 PARC 独立出来后,最初力图开发一套打印机控制语言。熟悉这几位创始人的 Steve Jobs 认为,这个语言最重要的任务不是控制打印机,而是制作高品质文档。在 Jobs 的推动下, Adobe 开发了一套可以支持 Apple 当时正在开发的 LaserWriter 激光打印机产出高品质文档的语言: PostScript。从此,Adobe 这家毫不起眼的小公司一举成为桌面出版革命最大的受益者。

因为 PostScript 语言灵活复杂,解释 PostScript 语言需要强大的微处理器。为此,Apple LaserWriter 携带了一颗 12 MHz Motorola 68000 处理器。而同时期的,与之相连的 Machintosh 携带的是一颗 8MHz 的 Motorola 68000。打印机处理器比主机的强大,用现在的眼光看是不可思议。桌面出版的革命来得如此之快,需要的计算能力如此之大,是个人计算机行业所没有预见的。或许,未来的 3D 打印技术或量子传输技术 (Star Trek transporter) ,会让这种情况重新出现。

本文原发于《程序员》2014年3月刊

导言

编程语言的发展历史,总的来说,是一个从抽象机器操作逐步进化为抽象人的思维的过程。机器操作和人的思维如一枚硬币的两面,而语言编译器就像是个双面胶,将这两面粘在一起,保证编程语言源程序和机器代码在行为上等价。当然,人本身并不是一个完美的编译器,不能无错的将思维表达为高级语言程序,这种偏差,即Bug。因为编译器的帮助,我们可以脱离机器细节,只关心表达思维和程序行为这一面。

编程语言的发展日新月异。特别是随着对问题的深入理解,新的设计思想,语法构建和新的领域相关语言(DSL)层出不穷。而硬币的另一面似乎一直波澜不惊。这是自然的——无需关心底层架构的变化,或者目标代码生成优化等技术的进化,正是编译器带给我们的好处,因为这些细节和要解决的问题往往关系不大。

尽管所受的关注度不高,这些底层的技术一直在持续地进步。特别是这十年来,一场大的变革正在悄悄发生。这场变革,就是中间语言和虚拟机几乎成为了编程语言的标配——编译器不再以机器的CPU指令集作为编译目标,而是生成针对某种中间语言或虚拟机指令集的目标代码。这场变化是深刻的,它意味着编程语言的设计者自此完全脱离了具体硬件平台的束缚,语言如何设计和如何执行成为了两个完全正交的系统。这个变革大幅度降低了创造一个新语言的成本,一下子把我们推入了一个语言井喷的时代。

从抽象语法树到中间语言

熟悉编译器设计的读者都知道,编译的第一步是构建一个叫抽象语法树(AST)的数据结构 (脚注: 语法树这个概念来源于 LISP)。有了这样的数据结构后,解释器和编译器在此分野。以 AST为起点,解释器完全可以遍历语法树,递归执行每个子结点。IEEE POSIX (或称标准UNIX) 规定的 AWK 语言,其经典实现就是一个生成和遍历语法树的过程:


syminit();
compile_time = 1;
Node *winner ; /* root of parse tree */
yyparse(); /* generate parse tree */

if (errorflag == 0) {
compile_time = 0; /* switch to execution */
run(winner); /* execution of parse tree starts here */
}

Awk 这样的传统解释器的优点在于结构简单,开发便利。事实上许多领域专用语言都采取这种方式实现,如 PostScript, Matlab, R 等。

解释执行的缺点也是显而易见的。首要的一点就是每次执行都需要重新生成语法树。领域专用语言或许可以忍受每次零点几秒的重复解释过程,而对于可以开发大型应用的通用编程语言来说,这一点是致命的。每次重新生成语法树也意味着这样的语言难以用于资源受限系统,因为
语言本身语法结构复杂,布置一个解释模块的代价往往非常高昂。为了避免解释执行的这些弊端,传统的编译器致力于只解释一次,将通用语言的语法树,直接转变为目标机器的 CPU 指令。传统的 FORTRAN 和 C 编译器就是如此设计的。有些编程构建,如 C 语言中的 i++, 甚至是直接受 CPU 指令影响的产物。

上个世纪 80 年代后期,随着对程序效率优化和 LISP 机器的研究,研究者们认识到,其实传统的编译和解释并不是对立的概念。特别的,编程语言的语法树转变可以为一种中间指令格式。这种中间指令格式贴近机器指令,可以进行运行效率优化。传统的以生成目标指令的编译器,可以将中间语言简单转为机器指令。而解释器,也省却了多次的语法树生成,而直接解释相对简单的中间语言。

较早在中间语言上进行探索的是 MIT 的 LISP 机器。如 Thomas Knight,他的研究集中在如何在硬件上实现一个高效的 LISP 环境。显然,没有一个硅片可以直接运行 mapcar,但设计一个支持 mapcar 的中间语言并不困难,只需要支持一些基本的列表操作即可。这种设计思想影响了很多后来的系统。流行的 GCC 编译器,从结构上来说分前端和代码生成端两部分。连接两者的中间语言 RTL 的基本一些指令,都可以追溯到 LIPS 机器的指令集。

中间语言和虚拟机

中间语言可用于程序优化的原因是显而易见的:这种中间格式既贴近机器代码,又保存了原有程序的结构。程序优化并不是一门魔术。像循环展开,死代码消除等技术,都依赖于程序控制结构,而中间语言可以保持这样的控制结构。事实上,目前我们所知的编译优化技术,无一不是建立在结构分析之上。中间语言的出现让程序优化成为了一个独立的问题。原本单列的 C 程序优化, FORTRAN 程序优化如今统一归结为 RTL 程序优化。编译器前端可以千差万别支持许多语言,但负责优化和翻译为目标代码的后端均归为一个,就此一点,就大大简化了语言编译器的设计门槛。现如今,几乎没有一个语言设计者需要考虑如何生成高效目标代码了。

当然,中间语言的作用并不仅限于目标代码优化。如果我们把中间语言也当作一种语言的话,不难发现中间语言甚至比原语言更加普及。 比如,Java 虚拟机(JVM) 语言实际上是一个比 Java 语言成功许多倍的产品。JVM 存在于众多Java 语言不存在的地方。像 Jython, Scala 和 JRuby 这样的语言,均依赖于 JVM, 而非 Java 语言本身。

语言的虚拟机的本质,是一个可以运行中间语言的机器。 在实际硬件上,程序和数据是两个截然不同的概念;而对于虚拟机来讲,中间语言程序,只是虚拟机程序的输入数据罢了。这种将程序当作数据的处理方式,带来了我们熟知的许多虚拟机的优点,如跨平台特性,安全性等等。 因为程序即是数据,为虚拟机读取中间语言程序方便,其指令往往都是以字节为单位,故称为字节码 (bytecode)。 相比之下,计算机的 CPU 指令则可长度不一,也不一定占据整数个字节。

程序是数据这个特性使得虚拟机可以做到跨平台和沙箱安全;反过来,数据是程序又使得虚拟机可以用在一些意想不到的地方,使数据更加灵活。 目前通行的轮廓字体描述语言 TrueType 就是成功运用虚拟机来更加灵活地处理字体的一个例子。

TrueType 是一种采用数学函数描述字体的矢量字体。 矢量字体在理论上可以自由缩放。而实践中,因为显示器本质上是点阵的,所有的矢量字形都要经过栅格化 (rasterization) , 将矢量轮廓近似转化为像素点的透明度。 然而,这种近似并不是随意的。 以汉字 “中” 为例,为保证其对称美观,我们必须约束栅格化程序,保证任何时候左右两个竖线与中间一竖的距离相等,哪怕为此不惜将此字缩减或放宽一两个像素。 这类约束又被称作提示 (hinting)。 它对于字体至关重要—缺少提示的矢量字体在字形较小时不可避免地会出现失真,变形和锯齿等现象。 不难理解,本质上“提示”是一个以字体轮廓和字形大小为输入,以栅格数据为输出的程序。 因为此,TrueType 包含了一套虚拟机指令,方便字体设计者表达这种提示。 可以想象,如果没有这个虚拟机的存在,设计灵活的矢量字体是不可能完成的任务。 实际上,我们所见到的几乎所有的矢量字体文件,都是一个数据和程序的混合物。 从另一方面来说,每个字形都需要一个专门的“提示”,也从一个侧面说明了设计高质量的中文字体之难度。

基于栈,还是基于寄存器

凡提到虚拟机,绕不过去的第一个问题就是这个虚拟机是基于栈的,还是基于寄存器的(有些虚拟机,如 LISP 机器,可以同时有栈和寄存器)? 尽管这里“寄存器”和“栈”,都不一定直接对应到机器CPU的寄存器或者内存里的栈。这个问题之所以重要,因为它直接决定了虚拟机的应用场景。一般说来,基于栈的虚拟机结构相对简单,且更加适合资源受限系统。 比如上文我们说的 TrueType 虚拟机,结构简单,功能专一,就是基于栈的。

尽管所有的计算机的存储模型都是构建在图灵机的无穷纸带模型上,实践中所有语言都或多或少依赖于栈模型。特别的,函数调用就等价于栈的推入和弹入操作,其他操作均可抽象为对栈顶元素进行。相比之下,寄存器模型虽然贴近真实机器,却并不够直接:很少有高级语言直接制定寄存器如何分配的,因此编译器的作者需要考量寄存器分配问题。而基于栈的虚拟机的所有指令都可默认为对栈顶元素操作,结构简单,且暂时绕开了寄存器分配难题。

基于栈的虚拟机更加适合内存和 CPU 处理速度等方面有限的系统。同样的源程序,在目标代码的体积上,面向栈虚拟机上生成的代码更加小。这是容易理解的:基于栈的虚拟机的指令默认对栈顶元素操作,因此指令只需为 OP 格式,无需 OP Reg1, Reg2, Reg3 等额外指定寄存器。这个设计也绕开了指令解码问题。平均上说,基于寄存器的虚拟机生成的指令的体积比基于栈的要大。我们见到的许多基于栈的虚拟机,都是为资源受限系统设计的。JVM 的初衷是一个运行在电视机顶盒中的小系统,后来精简版本的 JVM 甚至可以放到智能卡上;Forth 语言的虚拟机是要用在计算机固件(Open Firmware),航空系统和嵌入式系统中;控制打印的 Postscript 是用于高品质打印机中。很显然,机顶盒,引导固件和打印机都是资源受限的系统,这些系统中的虚拟机,不约而同都是基于栈的。值得一提的是,因为实现简单,许多并非用于受限系统的通用语言的虚拟机也是基于栈的,如 Python, Ruby, .NET 的 CLR 等。

基于寄存器的虚拟机,是为性能所生。引入寄存器假设固然关上了用于资源受限系统的门,却也打开了一扇通向进一步性能优化的窗。栈虚拟机的一大缺点就是要不停地将操作数在堆和栈之间来回拷贝。比方说一个简单的三个参数的函数调用,在传递参数上就需要至少三次入栈和出栈操作,而在寄存器上只要指定三个寄存器即可。现代处理器提供的通用寄存器支持,本身就是为了减少这类值的来回拷贝。尽管有 Hotspot 这样的技术能够将一段栈虚拟机指令转化为基于寄存器的机器指令,可毕竟没有直接从支持寄存器的中间语言翻译直接。前面说过,保持程序的结构是优化的先决条件。失去了“指定三个值”这样的结构的栈虚拟机,需要运行时间接的推断这个操作。而直接指定这些访问结构,将值直接映射到 CPU 的寄存器,正是这类虚拟机运行效率高的要点所在。Android 的 Dalvik, Perl 的 Parrot 都是基于寄存器的虚拟机,而 LLVM 则是基于寄存器假设的中间语言。其中,为了让 Android 程序更加快的运行,Google 不惜放弃 JVM 的指令集,而选择将 JVM 指令转化为基于有限个寄存器的 Dalvik 指令集。 Parrot 和 LLVM 则更加自由一些,假设了无穷多个寄存器。无论是有限还是无限个寄存器,省却不必要的值拷贝是这类中间语言的最大优点。

JIT 和直接执行

JIT (Just-in-time) 是运行时的动态编译技术。不难看出,JIT 是针对中间语言的——将原语言的编译推迟到运行时并无意义,将中间语言的解释,部分转化为编译后的机器代码,则可以优化运行效率。JIT 之所以可行,一个基本假设是程序大多存在热点。D. E. Knuth 三十年前观察到的一个现象: 一段 FORTRAN 程序中不到 4% 的部分往往占用超过 50%的运行时间。因此,在运行时识别这样的热点并优化,可以事半功倍地提高执行效率。

按照 Jython 作者 Jim Hugunin 的观测, JIT 技术出现后,同样功能的程序,运行于 Java 虚拟机上的字节码和直接编译成二进制代码的 C 程序几乎一样快,有的甚至比 C 快。乍一看虚拟机比原生代码快,理论上是不可能的。而实践中,因为 JIT 编译器可以识别运行时热点做出特别优化。相比之下,静态编译器的代码优化并不能完全推断出运行时热点。而且,有些优化技术,如将虚函数调用静态化,只有在运行时才能做到。在对热点深度优化的情况下,JIT 比直接生成的机器代码执行效率高并不是一件神奇的事情。引入了 JIT 的,以 Python 书写的 Python 执行器 pypy, 运行速度要比以 C 实现的 CPython 解释器快一到五倍,就是 JIT 技术魅力的一个明证。

尽管 JIT 技术看上去很炫,实践中也能够做到几乎和原生二进制代码速度相近,我们必须承认,这只是一种补救相对慢的中间语言解释的一种措施罢了。设计语言平台时,设计者可能因为这样那样的原因而选择中间语言/虚拟机解决方案,或因为针对嵌入式系统(Java),或因为跨平台要求(Android Dalvik),或者仅仅因为设计者想偷懒不愿写一个从语言到CPU指令的编译器(Python/Ruby)。无论原因为何,当最初的原因已经不存在或不重要,而性能又成为重要考量的话,采用中间语言就显得舍近求远。JavaScript 引擎的进化就是一个生动的例子。

JavaScript 语言最初只是一种协助 HTML 完成动态客户端内容的小语言。Netscape 浏览器中的JS 引擎,最初只是一个简单的解释器。自2004 年 Google 发布 Gmail 之后, Ajax 技术的发展对 JS 引擎的速度提出了更高的挑战。JavaScript 引擎的速度被当成一个浏览器是否领先于对手的关键指标。在此情况下,众多浏览器厂商纷纷卷入了一轮 JS 引擎速度的军备竞赛。

最先挑起这场战争的是 Firefox, 目标是当时占据90%市场的 IE。Firefox 3 于2008年6月登场,其 JS 引擎 TraceMonkey 在栈虚拟机的基础上首次采用了 JIT 技术,在当时众多标准评测中超越了IE7。就在当月,WebKit 开发小组宣布了基于寄存器的 Squirrelfish 引擎,殊途同归,也是基于中间语言,尽管两者互相不兼容。

到9月,Google 发布了第一个版本的 Chrome 浏览器以及新的 JS 引擎: V8。V8一反使用中间语言的设计套路,力求将 JS 直接编译到本地代码。Google 毫不掩饰 V8 在标准评测上比其他浏览器快的结果,因此造成了 Firefox 和Safari 开发者对各自 JS 引擎速度评测的一场恶战。到了9月的时候,Firefox 和 Safari 各自的引擎都比6月份的结果快到 20%到60% 不等。 而 V8 也赢得了许多眼球,催生了之后的 Node.js 项目。

这场军备竞赛的一个结果,就是 V8 以外的引擎,也开始探索绕过中间语言从 JavaScript 直接生成二进制的可能性。SquirrelFish Extreme 就是自 Squirrelfish 衍生出来的一步本地代码的引擎。值得注意的是,尽管都是生成本地代码,V8 和 SquirrelFish Extreme 这样的编译器,并不是退回到传统的编译器技术上,因为他们已经吸收了许多对 JIT 编译器性能的研究成果。

就在我写这篇文章的时候,Google 正在将 Android 执行环境,从原来的 Dalvik 虚拟机,换成可以直接生成机器代码的 ART 架构。ART 负责在 App 安装后一次将跨平台的字节码分发格式,编译成原生机器代码。20 多年前,为了跨平台,Java 采取了虚拟机的设计方案。如今,中间语言的跨平台的部分依然保留,但作为已经不直接参与执行了。硬件的进步带来的中间语言和虚拟机设计的进化,是当时的设计者如何也想不到的事情了。

人们常说,是药三分毒。我们感性地知道药物是有毒性的,但轮到生病的时候,多数人还是会寻求药物治疗,而把“是药三分毒”放在了一边,或者“相信”药物的效用超过了毒性。这种相信,大多数时候并不是出于我们的理性的判断,而是依赖于医生的判断,以往的经验,或者药物本身的说明书。

 

其实,作为一个现代人,我们完全有能力,也负有对自己和自己爱的人的责任,去了解药物的具体毒性,以理性地,正确地选择药物,从而走出感性地“是药三分毒”的认识,切实地了解药物的毒性。研究药物作用机理和毒性的知识的学科,叫做药理学(Pharmacology)。

 

药理学关心的是药物的结构,作用机理,代谢方式,毒性等等的知识。这些知识看上去名词一大堆,牵涉到化学,分子生物等若干领域,其实只要掌握了一些基本的概念,一个受过高中教育的人完全可以理解。事实上,在美国,药理学是护士培训的必修课。护士并不比大多数读者具有更多的生物,化学或者科学知识。

 

以一个很日常的例子说明一下药理学的作用。几乎所有人都用过的退烧药。常用的退烧药包含阿斯匹林 (Aspirin),布洛芬 (Ibuprofen),扑热息痛 (Paracetamol,国内又叫百服宁,必理通或者泰诺) 三种。现在请听两道题题:1)这三种药一天一次还是一天多次,如果多次,间隔多长为好?2)如果你同时有胃溃疡,或者肝功能不好,该选择哪种药物?读者要说了,我又不是医生,我哪儿知道?其实,如果你有一些基本的药理学知识,回答以上问题轻而易举。

 

药一天吃几次的问题,其实就是人体多长时间将药代谢掉的问题。在药理学里,我们用“半衰期(half life)”来衡量药物的代谢速度。如果你到英文维基百科上,分别输入 Asprin, Ibuprofen 和 Paracetamol,在右边的框里,很容易看到这三个药物的半衰期(300mg, 3.1-3.2小时,1.8-2小时和1-4小时)。人体对药物的代谢是非线性的,但从半衰期我们可以大致估计到在症状持续的情况下,这些药不可能在体内持续作用 24 小时。所以如果症状持续,我们可以一日多次服用。像扑热息痛,因为半衰期已经长达 4 小时,所以服药间隔最好要在 4 小时之上,这都是有药理学知识的人一眼看出的常识。再比如说,同样是抗生素,两颗阿红霉素(Azithromycin) 的半衰期长达68小时,而阿莫西林(Amoxicillin) 只有一个小时。所以你去药房拿药的时候,药剂师会给你几颗阿红霉素,或者一盒子阿莫西林。掌握药理学常识可以帮助我们理解这些药物之间的差别,并记得按时服药。

 

药理学还研究一个药物的代谢途径,比如,是通过肝脏,肾脏还是其他,具体的代谢通道是什么。有了一些基本的药理学知识,我们就知道如果一个人肝功能有损伤,则要避免通过肝代谢的药物。家里养猫的人可能都知道,Paracetamol 对猫有剧毒,原因就是 Paracetamol 是肝代谢的,而猫的肝恰恰没有代谢 Paracetamol 所需要的酶。又比如说,阿司匹林 和布洛芬在药理学上都属于NSAID 药物。NSAID 药物的一大副作用是能够导致消化道溃疡,所以有消化道溃疡前史的患者最好谨慎服用。总之,虽然这些药都是属于“退烧药”,通过药理学,我们可以详细探究这些药的异同。如果我们知道了一个药的代谢渠道,在通过简单的基因测序如 23andme 知道我们自身有无相应的酶来代谢这些药物,在选择药物的时候,就不会像猫一样盲目地把 Paracetamol 往嘴里送,也不会因为无知而莫名其妙地把所爱的人推向危险的境地。

 

言而总之,药理学知识可以帮助我们了解药物。现代药的说明书说白了是很严谨的药理学试验报告,而药理学知识就是读懂这个报告的钥匙。我相信药理学可以帮助我们更加科学和透明地选择和服用药物,让我们和我们所爱的人的生活更加美好。

 

这个世界上有很多所谓的“传统医学”,而且信徒还不少。不管这些传统医学多么博大精深,多么神奇,如果这些传统医学给我或者我所爱的人开的药没有药理学支持,那么我们作为一个现代的,理性的人,唯一可做的,而且是最负责的,就是拒绝这些药物。把这些药物往嘴里送,就是接受了“是药三分毒”,而且还不知道是哪三分。

PS: Coursera 上有  Fundamentals of Pharmacology 的公开课。我上过,受益无穷。

(标题是标题党)

王垠最近的一篇文章中,提出了很多有趣的观点。其中最核心的一点,就是 *NIX 系统的设计哲学非常糟糕,而 Windows 系统才是真正为开发者设计的系统。凡是涉及到哲学层面的争论,最后都是以谁也说服不了谁收场。我相信王垠有足够的理由来证明 UNIX 设计哲学的糟糕,但遗憾的是,他的文章并没有表现出这一点。我摘抄一些论点并作答复。

Unix 的 shell,命令,配置方式,图形界面,都是非常糟糕的。每一个新版本的 Ubuntu 都会在图形界面的设计上出现新的错误,让你感觉历史怎么会倒退。但是这只是表面现象。Linux 的图形界面(X window)在本质上几乎是不可治愈的恶疾。

以现在的眼光看,X Windows 是一个设计过于繁复的系统。实际上,20年前出版的 The Unix Haters Handbook 里,就有专门的一章论述为什么 X Window 的 client-server 架构是糟糕的。可是,这和 Ubuntu 的设计演化之间似乎没有太多关联。Ubuntu 所谓的图形界面设计的错误(以 unity 为例),都是桌面环境层面的问题。一个设计师在这个层面犯错误,就像一个画家因为构图不够工整就去怪画布不行一样,之间还相差不少逻辑链条。

X Window 和其他操作系统上的 GUI 系统最大的不同,是它和宿主操作系统的松耦合。因为这种松耦合的存在,在不需要图形界面的地方,操作系统可以不带 X Window。很多云服务的服务器,都是没有 X 的。当下如日中天的移动操作系统如 Android 和 iOS 都是 UNIX 家族操作系统,而这两者都没有用 X Window 提供 GUI 支撑,而是另外开发了一套专门适合触摸式移动设备的图形界面系统。图形系统和操作系统间的松耦合,使得操作系统可以从头搭建适合具体设备的图形界面交互(如 Cocoa Touch),并且快速的迭代(如 Project Butter)。

在 GUI 和内核的耦合关系上,架构的确决定了产品的形状。我们都知道,微软 NT 内核和 Widnows UI 系统是绑定在一起的。Windows Phone 8 要和 Windows 8 共享内核和其他组件的结果,就是它们都必须兼顾桌面和移动平台。为此,微软做出了两个可以做榔头也能做螺丝刀的东西。一个是 Windows 8,支持触摸屏,Modern UI 界面长得像手机界面;一个是 Windows Phone 8,界面很适合触摸设备,却又同时支持移植来的桌面程序,造成有的程序界面长得像桌面。无论你认为哪个操作系统的图形界面漂亮,哪条路更加有光明的未来,微软的这些系统之间的关系之繁复,开发迭代的周期如此之长,都是客观事实。

UNIX 系统的Unix 的 shell,命令,配置方式的确有不少的问题,在痛恨者手册里也有详细的论述,我以前也写过,就不一一列举了。

Unix 依靠自己的“宗教”和“哲学”,“战胜”了别的系统在设计上的先进,统治了程序员的世界。胜者为王,可是 Unix 其实是一个暴君,它不允许你批评它的错误。它利用其它程序员的舆论压力,让每一个系统设计上的错误,都被说成是用户自己的失误。其它系统里面某些优秀的系统设计,也许就要被历史掩埋……

一个操作系统,是不可能凭着“宗教”和“哲学”就能统治程序员的世界的。程序员不是天主教徒,UNIX 也不是程序员世界的教皇。实际上,所谓的 UNIX 系统,不是一个特定的系统,而是一个家族的系统。这个家族的系统包罗万象。不喜欢微内核的做了宏内核,不喜欢一切还不都是文件的做了 Plan 9, 不喜欢 X Window 慢如蜗牛的做了 XGL 加速。优秀的设计不断地加入这个系统,改造这个系统。UNIX 来源于 Bell 实验室,X Window 却是 MIT 的,BSD 来自于 Berkeley, Solaris 来自于 SUN, Mac OS 来自于 Apple。如果说这里面有宗教的话,这一定是世界上最诡异的宗教,里面的教徒还天天打架。

在 UNIX 系统中,所有的设计,都在开放的环境下竞争。我们可以说 UNIX 不是一个设计良好的系统,但是它的设计哲学在竞争中获胜的原因,不是因为它是“暴君”,控制了程序员的思想,而恰恰是因为它的开放,所以最终汇总了很多优秀的东西。至于 UNIX 这种不怎么好的系统为何最终获胜,20年前的一篇文章也讲得很清楚了。

因为 TeX 的语言是非常糟糕的设计。它的设计者几乎完全不明白程序语言设计的基本原则,不明白什么叫做“抽象”。

而这些源于 Unix 的工具却像是“魔鬼棋”或者“三国杀”,有太多的,无聊的,人造的规则。有些人鄙视图形界面,鄙视 IDE,鄙视含有垃圾回收的语言(比如 Java),鄙视一切“容易”的东西。他们却不知道,把自己沉浸在别人设计的繁复的规则中,是始终无法成为大师的。就像一个人,他有能力学会各种“魔鬼棋”的规则,却始终无法达到象棋大师的高度。

这里,王垠把两个不相关的东西放到了一起。一个是工具的设计哲学,一个是我们如何学习知识。魔鬼棋本身是一种工具设计哲学,和成为大师无关。

军刀工具一文中我提过,面向特定领域的软件工具之所以让人觉得复杂,是因为这个问题本身复杂。我们把解决特定领域问题而所需的知识叫做”领域模型“(domain model)。如果我们不了解领域模型,就不能理解为什么 Photoshop 比系统自带的 Paint 复杂几千倍, 或者为什么我们需要正则表达式这种诡异的东西。我们讲的复杂与简单,都是工具设计哲学层面的。

以王垠说的 TeX 为例。写出《计算机程序设计艺术》的 Knuth 到底知不知道程序语言设计的基本原则我们可以不加讨论。了解一点字体设计和排版的都知道,计算机排版问题是个复杂的问题。的确,软件工具的设计目标,是把复杂的问题简化。然而,大多数人不知道的是,简化问题是一个两步过程。第一步,我们需要把现实的问题映射到一个领域模型。第二步,是把这个模型简化到我们人可以处理的地步。很多时候这两步合并起来了,让我们觉得这两步好像是一步,并且认为所有的设计,都应该朝简化的方向走。这是一个对设计的错误认识。

举个非计算机领域的例子:用电饭锅煮饭非常简单,加米加水再按个按钮就行了。电饭锅的设计者的设计目标是操作简单且能完美地煮米。作为工具的设计者,它一方面需要了解大米是怎么煮熟的,另一方面需要提供给用户一个简单的按钮。TeX 作者,从一开始就不是设计一个电饭锅,而是一个精确的温控炉子。有了这个精确的温控炉子,想烧饭的可以把它封装成电饭锅,想做蛋糕的可以把它封装成蛋糕烤箱。设计电饭锅的人的设计,并不比设计精确的温控炉子的人好,或者差。设计者的初衷决定了产品的形状。 Kunth 的初衷,正是设计一个可以让他人排版出任何想排版的东西的系统。也就是说,做出一个最终非常简单的,只有一个按钮的排版系统不是他的设计目标。做出一个可以高度定制的系统才是他的目标。

其实,TeX 本身也是一个由繁到简的软件系统。它把所有排版中的问题,都提炼成了一些控制原语。有了这些控制原语,针对特定领域做优化就不是问题了。现在通用的科学出版排版工具 LaTeX, 正是这样的一种优化。这是有心设计的结果。我们抱怨 TeX 复杂,其实是抱怨排版本身复杂。Windows 系统上有许多排版软件,可以毫不客气地说,没有一个可以达到 TeX 所能到达的精确控制。以此责怪 UNIX 下的软件工具是魔鬼棋,就类似于责怪 Photoshop 为啥不象 Paint 那样简单一样,有选择性地忽视了两者所要解决地问题不一样。

我欢迎所有的 UNIX 使用者加入痛恨者阵营,因为我也是 UNIX 痛恨者。只有成为了 UNIX 痛恨者,你才是一个真正的 UNIX 使用者。至于 Windows, 我们对它没有感情,无所谓爱恨。

  • “沙” 是佛教里很值得一提的物件,很多佛教艺术都和沙有关。

日本禅宗的“枯山水”就是一种依赖于沙的园林艺术。枯山水在西方很有名,以至于在西方直接被称为禅花园(Zen Garden)。一般的园林都会通过假山和水构景,通过人造的山和水,来引起观者自然的联想,从而联系人与自然。而枯山水园林却反其道而行,不采用真正的山,也不摆放真实的树,竹等物。枯山水中的水,是细细耙制的静态的白色砂石,模拟水的动态形态;枯山水中的植物也极少或者没有。这种美学上的差异,其实是和中日两国禅宗在修行上的侧重不同有关的。

中国禅宗传统上就是位于名山大川,因此在修行时秉承了一种自然主义的路线,让修行者在见山是山,见水是水的自然景观或者园林中修行。日本的禅宗主要继承了曹洞和临济两家。曹洞侧重于打坐和冥想,即“只管打坐”(Shikantaza)。临济强调”心即是佛”,也是促使人向内求索。在这种思想指导下,园林就不再坚持自然主义,而转为为修行着提供安心之法。枯山水正是这种哲学的体现。一个枯山水园林一旦建成,一年四季景色完全相同,恒久不变,宁静无碍。在枯山水中修行,心不会四处乱走。

枯山水是用静态景观来展现这个大千世界动态的一瞬间,而藏传佛教里的坛城沙画 (Sand Mandala),则是用动态的构建,来展现万事无常这个恒久不变的佛理。构建坛城沙画的目的,不是为了让它成为一项艺术品,而是为了最后摧毁它。这种最终什么也得不到的艺术创作,正是说明了佛教里一切无常的道理。

佛教的艺术家很早就意识到沙是彩色的。《大方广佛华严经》就提到过四条不同的大河流出不同颜色的细砂:“恒伽河口流出银沙,私陀河口流出金刚沙,信度河口流出金沙,缚刍河口流出琉璃沙”。结合华严经所说的恒河沙中的世界,用彩色的沙构建大千世界这种佛教艺术的出现是自然的。

为了构建坛城沙画,喇嘛们需要用尺子和圆规仔细的规划沙画的基本结构和几何形态。同时,所有的彩色沙都是仔细遴选甚至磨制而成。这些彩色的细沙被装入一个小口沙漏,然后由有经验的喇嘛,将这些细沙细细铺出。构建沙画是费时费力的,往往需要众多喇嘛们连续好天的工作。而不管花多少力气,多么美丽的沙画,只要沙盘倾覆,都一瞬间化为无形。从这个意义上来说,要揭示世事无常的道理,没有沙更加贴切的道具了。

坛城沙画

坛城沙画一旦建立,它的唯一命运就是被摧毁。高级别的喇嘛会先诵经一段,然后以一定的规则将沙画摧毁,将各色细沙汇作一堆,装入瓶中,撒入江河。就像熵只能增加一样,美丽的沙画的每一次破坏都是不可逆的。这种摧毁美丽艺术品的悲剧感和无力感,正是佛教想要说明的道理:诸行无常,是生灭法,生灭灭已,寂灭为乐。

佛经里对沙的数量,沙的色彩和沙的无常感都有论述。有趣的是,尽管大多数人认为“一花一世界,一沙一凡尘”出自于佛经,实际上佛经中从来没有明确的提出过这个论题。这个很有佛学意味的句子,实际上来自于英国诗人William Blake 的《纯真预言》:

To see a world in a grain of sand,
And a heaven in a wild flower,
Hold infinity in the palm of your hand,
And eternity in an hour.

今天,美国一家私人公司 SpaceX 成功的从美国佛罗里达州肯尼迪航天中心火箭的空军发射场发射了一颗火箭,这颗火箭的终点,是国际空间站(ISS)。这是继上次成功发射并回收卫星后,一家私人公司首次将载荷送到国际空间站附近。自美国的航天飞机退役后,能够将载荷运输到国际空间站的方法只有一个–俄罗斯的联盟号飞船。而现在,私人航空已经离这个目标不远了。

SpaceX 的成功是美国社会一直引以为豪的企业家精神和创新精神的成功。2002 年,eBay 以 15 亿美元的价格购买了 PayPal,一家从事电子支付的公司。很多 PayPal 的员工一夜之间成为亿万富翁、PayPal 的共同创始人 Elon Musk 极具冒险精神,眼看 PayPal 已近逐渐成熟,便在 2002 年 eBay 收购还没定型之前,马不停蹄的创立了一家叫做 SpaceX 的公司,致力于民用航天探索。

和 Jobs 用自己的钱投资 NeXT 和 Pixar 一样,Musk 将自己口袋里的 1亿美元投给了 SpaceX。当时,Musk 同时主持两家创业公司,一家是 SpaceX,从事空间探索,另一家是 TelsaMotor,从事电动汽车生产。这两家公司都不是传统的软件或互联网公司,因为空间探索和生产汽车的成本,无需多说,公司的烧钱的速度远远快于硅谷的其他创业公司。可以想象,这两家公司在发展过程中屡屡不绝的资金不足的问题。最后问题严重到需要 Musk 将自己口袋里仅存的 70 万美元拿出来发工资的地步,这在投资人遍地的硅谷是非常少见的事情。

如果换到其他创业者,到了这种地步,可能唯一的路就是缴械投降了。和 Steve Jobs 一样, Musk 的过人之处之一,就在他不但毫不畏惧风险,而且还能说服别人和他一起冒险。通过与 PayPal 一帮一夜暴富的富翁之间的关系,SpaceX 从一家创业基金拿到了一笔钱,暂时保证了公司不会关门大吉。同时,他加快了说服军方和NASA与 SpaceX 签订合同的谈判。2005 年,在 SpaceX 最紧要的关头,美国空军与 SpaceX 签下了价值一亿美元的发射合同。随后,2006年,NASA 与 SpaceX 又签订了价值10亿美元的向国际空间站运送补给的合同。这些合同带来的巨额项目投资,使SpaceX 终于摆脱了市场和资金的两大问题,成为民用航天的一家标杆,此后就是大家熟知的火箭发射,卫星回收等历史了。而 Musk 在 TeslaMotor 一侧的成功也非常耀眼。该公司是第一家量产锂电池汽车的公司,并在 2010 年成功上市。就像当年 Jobs 同时运营两家公司最后让 Pixar 上市一样。

SpaceX 用很少的钱,在极短的时间里完成了某些号称体制优越的国家举国都不能完成的事情(我说朝鲜呢,不许联想),最重要的当然是 Musk 的远见,无数的工程师杰出贡献。这里,美国政府和 NASA, 也起到了锦上添花的作用。美国政府不敢擅自夸耀自己,但公平的说,SpaceX 的成功离不开美国的航天政策调整。

美国政府在空间探索上的策略虽然一个总统一变,但因为 NASA 具有较大自主性,因此空间政策大体上还是很有政策的延续性的。因为航天飞机的退役日程,小布什政府一直在规划如何在航天飞机退役后继续维护国际空间站。当时小布什政府的设想是一箭双雕,既要国际空间站,又要重返月球。这样,NASA 就提交了一个双火箭的星座计划。因为该计划预算巨大,而美国政府又面对巨大的预算压力,因此联邦政府要求 NASA 同时也寻找其他替代方案,而 SpaceX 的火箭和龙舱,即在当时被  NASA 选中。

奥巴马政府的空间政策比小布什政府更有远见。因为预算问题,奥巴马政府取消了星座计划的登月部分。NASA 内部的一些专家早就预言过,现在已经不是冷战时期,美国公众根本不想和中国比赛登月。如果美国想再次登月,就要消减国际空间站。而且即使美国登月,中国不会因为美国再次登月了就放弃登月,所以美国和中国竞赛登月连政治意义都没有,美国就应该放弃登月。奥巴马政府采纳了这样的建议,让NASA跳过月球,除国际空间站外,把眼光放在 James Webb 太空望远镜等太阳系尺度的空间探索上。因为这样的政策调整,NASA 得以缓出人员和精力从事更加深度的空间探索,而将给国际空间站运输货物这样“技术含量较低”的项目,交给 SpaceX 这样的私人公司完成。NASA 和美国政府都不顾及所谓的面子问题,在航天飞机退役后,委托俄罗斯用联盟号飞船运送美国宇航员和货物。从短时间来看美国好像丢了人,而长时间来看,是省了NASA的预算,并给了 SpaceX 等私人航天巨大的成长空间(美国私人航天领域公司很多,不限于 SpaceX,SpaceX 是领跑者)。

按照 NASA 的计划,SpaceX 需要先将龙舱发射到国际空间站附近,伴随飞行。然后下一步设计出能够于国际空间站对接的龙舱,最终完成飞行,对接,分离的三步目标。随着 SpaceX 的成功发射,未来已经离我们不远,航天历史正在翻开新的一页。

 

我们通过工具的设计者和使用者的视角观察工具,讨论工具背后的设计哲学和对应的学习曲线。

设计者和使用者

从某种意义上来说,工具是个联系使用者和设计者的桥梁。工具的设计者为解决一类问题而设计工具,工具的使用者也是为了解决一类问题而使用工具的。这两者对于工具都有自己的理解。工具的设计者通常会预测此工具会被用来解决怎样的问题,在设计工具时预见使用者的偏好,而工具的使用者则根据所面临的问题选择合适的工具。

在理想情况下,设计者和使用者对问题的理解 (Vision) 是类似或者重叠的。这时候,工具的设计者能够完全理解使用者的需求,而使用者完全理解这个工具的长处和短处。成语“得心应手”说的就是这样的一种情况:掌握了一样工具,因此使用起来非常顺利。

在现实中,设计者和使用者对问题的理解可能是有差异的,随之对工具的使用方法的理解也有所不同。有时候,工具的使用者能够将工具用到远超出设计者想象的地步。有两个著名的例子可以说明这个问题。第一个例子是出自 Bell 实验室的 AWK 语言。从 AWK 的三位作者的角度看,这是一个与 sed 互补的,处理文本记录的语言。而AWK在贝尔实验室内部推广开来后,三位作者发现其他团队用 AWK 来写作大的数据管理系统,而三位作者本人从来没有写过超过一百行的 AWK 脚本。第二个例子是 C++ 的模版 (template)。模版的设计者的初衷是让 C++ 在处理对象时更加灵活,而自从C++模版的图灵完全特性被发现后,写作一些通用的库成为可能。如C++的Boost库,即重度依赖于模版。设计模版的人未必想到 Boost 库那样对模版的奇妙运用。这两个例子,都充分说明了工具的设计者未必能够充分体验到自己设计的工具的强大之处。

另一种情况是工具的设计者是解决问题的专家,对问题有深刻的理解,而工具的使用者则没有能够完全体验到工具的强大。工具的使用者因此需要学习如何使用工具,学会像工具的设计者那样思考,从而领略到工具的强大之处,达到得心应手的地步。对于完成学校教育后的成人来说,大多数学习都是在学习工具的使用。那么,如何学习软件工具呢?

在我看来,虽然工具千差万别,学习工具的用法也大相径庭,不过如果细细总结,工具的设计者所奉行的套路无非就两种,所对应的学习方法也有两种。我把这两种设计讨论形象地称为瑞士军刀工具链。这两种设计哲学背后的假设,以及相应的学习曲线是不同的。

设计者的哲学一:瑞士军刀

瑞士军刀以方便著称,一把军刀包含了很多小部件,配合使用能够解决很多野外生存问题。不少计算机工具借用了这个类比。在Google 上搜索 “The swiss army knife of” 会发现很多软件工具号称某领域的瑞士军刀(见表一)

工具名称 用途领域 广告词
BusyBox 嵌入式 Linux 命令集 The Swiss Army Knife of Embedded Linux
Perian Apple QuickTime 组件管理 Perian – The swiss-army knife of QuickTime® components
Sox 音频文件编辑 The Swiss Army knife of sound processing programs
Netcat TCP/IP 数据包分析 TCP/IP Swiss Army Knife
FFMpeg 视频和视频流文件编/解码 The swiss army knife of Internet Streaming
Kanif 计算机集群管理 Cluster management and administration Swiss Army knife

从表一我们不难发现,这些宣称为“瑞士军刀”的工具有一个共同特点:都是针对某个特定领域的问题而设计的。这一点和瑞士军刀是相似的。瑞士军刀的多功能,并不是指这是一把砍树修桥盖房子的万能刀,而是说在野外生存这个领域内,军刀能够解决许多问题。回到表一,用过这些工具的读者一定有所体会:这些工具解决所在领域的复杂问题的能力是非常强的。比如,FFMpeg 内置多种视频编、解码器,众多格式能够相互转换,支持的视频编码格式比任何商业软件都要多。这些优势让 FFMpeg 几乎能解决所有现实中遇到的视频处理问题。因为这些工具针对特殊领域,且功能强大,用瑞士军刀做比喻是很形象的。

除了自称为“军刀”的这些工具外,还有许多耳熟能详的工具属于此类。如平面设计软件 Photoshop,是融许多复杂图像算法和插件于一体的图像处理之瑞士军刀;GNU 的 GCC 编译器,是集语法分析,代码优化,代码生成等功能于一体的专门负责程序编译的瑞士军刀。这些软件包的内部结构,都不是一个单一的程序,单一的部件,而是一揽子的部件,一箩筐的功能模块。

瑞士军刀巧妙地将众多小工具紧凑地组合到了一起。同样,我们说的军刀工具的一大优雅之处也是类似的,即通过一个统一的控制界面,将这一揽子的部件有机地组合在了一起。设计模式里,这种组织功能的方式称作为 Facade 模式,具体指以一个命令或者一个统一的界面抽象内部复杂的操作。如 FFMpeg 和 GCC 这样几乎无所不能的软件包,都可以通过一个单一的命令 (ffmpeg 或 gcc) 加不同的参数调用。在 GUI 程序的世界,所有的 Photoshop 插件都可以通过 Photoshop 的图形界面菜单调用。Facade 模式隔开了用户和“军刀”工具复杂的内部逻辑,让用户从一个”抽象”的层面理解工具。正如我们开车并不一定要了解引擎是怎么工作一样,工具的使用者不需要关系这些工具的内部是如何运转的。

总结这类工具的特点,瑞士军刀是一种把各种工具组合到一起,一起解决一个复杂问题的设计哲学。核心是。设计模式里的facet 模式,一个领域的问题,而不是一个特定的工具。这里实际上是一类工具,而非一个单独的工具。这一类工具被精巧地安排在一起,对外提供一个一致的服务接口,让用户以简单的控制方式完成相对复杂的任务。

领域知识假设和学习曲线

“瑞士军刀“工具都是面向特定领域的。无论是图片,视频处理,还是查看TCP包信息,这些工具都假设了使用者知道图片处理,视频处理或者TCP包的结构等知识。心理学上把这些为了解决特定领域问题而所需的知识叫做”领域模型“(domain model)。工具的设计者是不负责教授这些知识的。因此,作为工具的使用者,掌握这些特定领域的知识就成了使用工具的前提。一般的软件都是基于领域模型的。比如,平面图形处理工具 Photoshop 假设图片是个多层次的物体,如果我们不了解这个模型,就完全不知道为啥这个软件为啥比系统自带的 Paint 复杂几千倍。

因为军刀背后蕴含着领域知识假设,学习军刀工具就不仅仅是学习工具的使用本身,而是学习背后的领域知识。如果不了解背后的领域知识,即使我们会操作这样工具,依然谈不上是个熟练的使用者。相信诸位读者都属于“懂计算机”的一类人。现实中,我们这类人常常因为“懂计算机”,而被家人和朋友要求编辑图像,做个视频或者做个网页等等。有些要求还特别专业,让只会写程序的人非常吃力。究其原因,是因为程序员群体掌握的,只是编程的领域知识。如图像,视频处理所需要的领域知识,均是编程之外的了。即使像 Eclipse 这样专门给程序员用的软件,也隐含假设了使用者需要重构,设计模式等等的知识。不掌握这些知识的程序员用 Eclipse 也仅仅是将其当成一个花哨的编辑器使用而已。

因为需要领域知识做铺垫,军刀工具的学习曲线是因人而异的。对于有的人来说,如果本来就知道工具背后的假设,学习计算机操作就毫不费力。以我爸爸为例,本身用计算机并不熟练,但他学过画画,作图感一流,若干年前从书店随便买了一本 Photoshop 入门后就把 Photoshop 玩得很熟。而我知道这个软件每个选项的意义,却处理不出什么像样的图。对于我爸来说,他的学习曲线就很平缓,而对于我来说,就很陡峭。

学习曲线不一样这一点在计算机语言的学习上尤为明显(计算机语言是众多编程特性的大糅合,是典型的军刀设计)。像 Google 这样的公司面试时完全不考察语言细节,很多人入职的时候都不会 Java。因为我们相信任何合格的程序员都能很快学会 Java 这个语言。道理很简单,只要考察一个人掌握了Java 语言背后的领域知识,即面向对象的设计,数据结构,控制结构等等,就有充分理由相信这个人学 Java 毫不费力。但是,话说回来,每个语言背后的领域假设都有细微的差别。比如 Lua 只支持哈希表,Awk 支持哈希表也支持记录处理,Vim 里一切都是文本对象等等。真的领会这些领域模型并且将这些语言用得纯熟,没有长时间的投入是不可能的,这就是为何 Peter Norvig 强调“十年学会程序设计”。这两种对待编程语言的态度是不矛盾的。引申出来,对于语言或者工具的初学者来说,要分情况学习军刀工具。在不熟悉领域知识的情况下,应该找一本非常简单的书熟悉领域模型,获得一个 Big picture,而非上来就想着要解决手头的问题。而对于已经熟悉领域模型的人,比如会了一个 MVC 框架再学另一个的人,随便捡起一本 In Action 或者 Cookbook 也能事半功倍;有人甚至书都不需要买,看看文档就可以了。不清楚内情的可能会把这类人叫做强人,其实了解了背后的底细后,所有人都可以如此。

最后谈一下军刀工具的设计策略。我们说了,军刀工具背后蕴含的是领域知识。因此,设计军刀工具的人,必然需要是对问题有深刻理解的领域专家。这一点很好理解,因为很难想象一个不懂图像处理的人会指导 Photoshop 的设计,或者一个不懂统计的人会设计 R。不过,即使是最资深的专家,也未必能设计出一个能覆盖到所有问题的工具。这时候,工具的扩展性就成了一个重要的考量。可以这么说,几乎所有成熟的军刀工具,最后一定会进化出一个插件系统,用以扩展功能。插件系统的好与坏是考验设计者功力的时候。像 Firefox 和 Photoshop 这样的工具,若不是第三方插件,至多是一个普通的软件。加入插件系统后,这些工具成为了一个平台,从而衍生出了无数的新用例。即使像功能小巧和 Emacs 格格不入的 Vim, 最终也进化出了插件系统。这是一样工具成熟的体现。

总结一下军刀工具的特点如下:

  •  面向特定领域,覆盖该领域大部分问题
  • 由领域专家设计,使用者需要了解该领域的基本知识才能熟练运用工具
  • 内部由众多小模块组成,对外提供一致,简单的控制界面
  • 常常具有强大的插件系统,以方便用户扩展现有系统