编程珠玑番外篇-P PostScript 语言里的珠玑

本文原发于《程序员》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) ,会让这种情况重新出现。

文章来源:

Author:4G Spaces
link:http://localhost:4000/2014/06/10/postscript/