第十八章 编译
如果你到这里来是为了找一个 Perl 的编译器,你可能很奇怪地发现你已经有一个了
——你的perl 程序(通常是 /usr/bin/perl)已经包含一个 Perl 编译器。这个
东西可能不是你想要的,如果不是你想象的东西,你可能会很开心地得知我们还提供
代码生成器(也就是那些要求意义严格的人所谓的“编译器”),我们将在本章讨论
那些东西。但是首先我们想讲讲我们眼中的编译器是什么。本章不可避免地要讲一些
底层的细节,而有些人会喜欢这些内容,有些人则不。如果你发现自己并不喜欢这些
内容,那就把它当作一个提高你阅读速度的练习吧。(呵呵,不能不看,但是可以
不用全明白。)
假设你是一个指挥家,任务是给一个大管弦乐队排练乐谱。当乐谱到货的时候,你会
发现有一些小册子,管弦乐队成员人手一册,每人只有自己需要演奏的部分乐章。但
奇怪的是,你的主乐谱里面什么东西也没有。而更奇怪的是,那些有部分乐章的乐谱
都是用纯英语写的,而不是用音乐符号写的。在你开始准备一个演奏的节目之前,或者
甚至把乐谱给你的乐队演奏之前,你首先得把这样的散文翻译成普通的音符和小节线。
然后你要把所有独立的部分编辑成一个完整的乐谱,这样你才能对整个节目有一个
完整的印象。
与之类似,当你把你的 Perl 脚本的源程序交给 perl 执行的时候,对计算机而言,
它就象用英语写的交响乐对音乐家一样毫无用处。在你的程序开始运行之前,Perl
需要把这些看着象英文似的东西编译(注:或曰翻译,或转换,或改变或变形)为
一种特殊符号表现形式。不过你的程序仍然不会运行,因为编译器只是编译。就象
指挥的乐谱一样,就算你的程序已经转换成一种容易解释的指令格式,它仍然需要
一个活跃的对象来解释那些指令。
18.1. Perl 程序的生命周期
你可以把一个 Perl 程序分裂为四个独立的阶段,每个阶段都有自己的独立的子阶段。
第一个和最后一个是最让人感兴趣的两个,而中间的两个是可选的。这些阶段在
图 18-1 里描绘。
- 编译阶段
在第一阶段:编译阶段里,Perl 编译器把你的程序转换成一个叫分析树的 数据结构。除了使用标准的分析技术以外,Perl 还使用了一种更强大的分析技术:它使用 BEGIN 块引导编译进行得更深入。BEGIN 块一完成分析就转交给解释器运行,结果是它们以 FIFO (先进先出)顺序运行。这样处理的包括 use 和 no 声明;它们实际上是伪装的 BEGIN 块。任何 CHECK,INIT,和 END 块都由编译器安排推迟的执行。
词法声明也做上记号,但是还不执行给它们的赋值。所有 eval BLOCKS,s///e 构造,和非代换的规则表达式都在这里编译,并且常量表达式都预先计算。现在编译器完成工作,除非稍后它又被再次调用进行服务。在这个阶段的结尾,再次调用解释器,以 LIFO 顺序(后进先出)执行任何安排好了的CHECK 块。是否有 CHECK 块出现决定了我们下一步是进入第二阶段还是直接进入第四阶段。
- 代码生成阶段(可选)
CHECK 块是由代码生成器装配的,因此这个阶段是在你明确地使用一个代码生成器(稍后在“代码生成器”里描述)的时候发生的。这时候把编译完成的(但还没运行的)程序转换成 C 源程序或者串行的 Perl 字节码——一个代表内部 Perl 指令的数值序列。如果你选择生成 C 源程序,它最后可以生成一个称做可执行影象的本机机器语言。(注: 你最初的脚本也是一个可执行文件,但它不是机器语言,因此我们不把它称做影象。之所以称其为影象文件是因为它是一个你的 CPU 用于直接执行的机器编码的逐字拷贝。)
这时候,你的程序暂时休眠。如果你制作了一个可执行影象,那么你可以直接进入阶段 4;否则,你需要在阶段三里重新组成冻干的字节码。
- 分析树重构阶段(可选)
要复活你的程序,它的分析树必须重新构造。这个阶段只有在发生了代码生成并且你选择生成字节码的情况下存在。Perl 必须首先从字节码序列中重新构造它的分析树,然后才能运行程序。Perl 并不直接从字节码运行程序, 因为那样会比较慢。
- 执行阶段
最后,就是你等待的时刻:运行你的程序。因此,这个阶段也称做运行阶段。解释器拿来分析树(可能是直接从编译器来的或者间接从代码生成阶段以及随后的分析树重构阶段过来的)并且运行之。(或者,如果你生成了一个可执行影象文件,它可以当作一个独立的程序运行,因为它包含一个内嵌的Perl 解释器。)
这个阶段的开始,在你的主程序运行之前,所有安排好了的 INIT 块以 FIFO
顺序执行。然后你的主程序运行。解释器在碰到一个 eval STRING,一个 do FILE或者 require 语句,一个 s///ee 构造,或者一个代换变量里含有合法代码断言的模式匹配的时候会根据需要回过头调用编译器。当你的主程序结束之后,最后执行任何推迟的 END 块,这回是以 LIFO 顺序。最早的一个最后执行,然后你的程序结束。(END 块只有在你的 exec 或者你的进程被一个未捕获的灾难性错误摧毁后才能忽略。普通的例外都不认为是灾难性的。)
下面我们将以非常详细的方式讨论这些阶段,而且是以不同的顺序。
18.2 编译你的代码
Perl 总是处于两种操作模式之一:要么它在编译你的程序,要么是在执行它——从来
不会同时处于两种模式。在我们这本书里,我们把某些事件称做在编译时发生,或者
我们说“Perl 编译器做这做那”。在其他地方,我们说某些东西在运行时发生,或者
说“Perl 的解释器做这做那”。尽管你可以认为“Perl”就是编译器和解释器的合体
,但是把这 Perl 在不同场合所扮演的两个角色之一区分清楚还是非常重要的,这样
你才能理解许多事情发生的原因。(也可能有其他角色;perl 还是一个优化器和代码
生成器。有时候,它还是一个骗子——不过都是善意的玩笑。)
同时,区分编译阶段和编译时,以及运行阶段和运行时之间的区别也非常重要。一个
典型的 Perl 程序只有一个编译阶段,然后只有一个运行阶段。“阶段”是一个大
范围的概念。但是编译时和运行时是小范围的概念。一个给定的编译阶段大多数时间
做的是编译时工作,但是也通过 BEGIN 块做一些运行时工作。一个给定的运行阶段
大多数时间做的是运行时工作,但是它也可能通过类似 eval STRING 这样的操作符
做编译时任务。
在典型的事件过程中,Perl 先阅读你的整个程序然后才开始执行。这就是 Perl 分析
声明,语句和表达式,确保它们语法上是正确的时候。(注:不,这个过程中没有
正式的象 BNF 那样的语法图表,不过我们欢迎你细读 Perl 源程序树里的 perly.y
文件,里面包含 Perl 用的 yacc(1) 语法。我们建议你离词法远一些,因为它让
小白鼠产生进食紊乱的症状了。译注:呵呵,象大话里的唐僧。)如果它发现语法
错误,编译器会试图从错误中恢复过来,这样它才能汇报任何后面的源程序的错误。
有时候可以这样恢复,但是有时候不行;语法错误有一个很讨厌的问题是会出发一
系列错误警告。Perl 在汇报近 10 个错误后暂时退出。
除了处理 BEGIN 块的解释器以外,编译器默许三个概念上的过程处理你的程序。词法
分析器(lexer)扫描你的程序里的每一个最小的单元。这些东西有时候称为“词位”
(lexemes),但你在讲述编程语言的文章里看到的可能更多的是“记号”
(token)。词法分析器有时候被称做标记器或扫描器,而它干的工作有时候被称做
是词法分析或记号分析。然后分析器(parser)以 Perl 语言的语法为基础,试图
通过把这些记号组合成更大的构造,比如表达式和语句,来获取合适的意义,优化器
(optimizer)对这些词法的组合进行重新排列并且把它们归减成更有效的序列。
优化器仔细地选择最优的方法,它不会在边缘优化上花费时间,因为 Perl 编译器用
做即时编译器时必须运行得极快。
这些过程并不是在相互独立的阶段进行的,而是同时发生,并且相互之间有大量交互。
词法分析器偶尔需要来自 parser 的提示,这样它才能够知道它需要注意哪几种记号
类型。(很奇怪的是,词法范围就是词法分析器不能理解的事物之一,因为那是
“词法”的其他含义。)优化器同时还需要跟踪分析器的处理,因为有些优化在
分析器到达某一点之前是无法进行的,比如完成一个表达式,语句,块,或者
子过程。
你可能会奇怪,为什么 Perl 编译器同时做这些事情,而不是一件一件做呢?因为
这个混乱的过程就是当你在听取或者读取自然语言的时候,你即时地理解它们的过程。
你用不着直到读到本章的结束才理解第一句话的含义。你可以想象下面的对应关系:
计算机语言 | 自然语言 |
字符 | 字母 |
记号 | 词素 |
术语 | 词 |
表达式 | 短语 |
语句 | 句子 |
块 | 段落 |
文件 | 章节 |
程序 | 故事 |
如果分析过程进展顺利,编译器就认为你输入了一则合法的故事,哦,是程序。如果
你运行程序的时候使用了 -c 开关,那么编译器会打印一条“syntax OK”消息然后
退出。否则,编译器会把它自己的成果转交给其他过程。这些“成果”是以分析树的
形式表示的。在分析树上的每个“果实”——或者称做节点——代表一个 Perl 内部
的操作码,而树上的分支代表树的历史增长模式。最后,这些节点都会线性地一个接
一个地串在一起,以表示运行时系统的访问这些节点的执行顺序。
每个操作码都是 Perl 认为的最小的可执行单元。你可能见过一条象
$a = -($b + $c) 这样的语句,但是 Perl 认为它是六个独立的操作码。如果让
我们用一种简单的格式来表示,上面那个表达式的分析树看起来象图 18-2。黑圆点
里的数字代表 Perl 运行时系统将遵循的访问顺序。
Perl 不是有些人想象的那种单回合编译器。(单回合编译器是那种把事情做得对
计算机简单而对程序员复杂的东西。)Perl 编译器是那种多回合的,优化的编译器,
它是由至少三种不同的,相互交错的逻辑回合组成的。回合 1 和 2 在编译器在分析
树上上窜下跳构造执行流的时候轮流运行,而回合 3 在一个子过程或者文件完全分析
完的时候运行。下面是那些回合:
- 回合 1:自底向上分析
在这个回合里,分析树是由 yacc(1) 分析器建造的,用的记号是从下层的词法分析器处理出来的(那个过程也可以认为是另外一个逻辑回合)。自底向上只是意味着该分析器先知道树叶后知道树支和树根。它并不是象在图 18-2 里那样从底向上处理各种元素,因为我们是在顶部画树根的,这是
计算机科学家(和语言学家)的特殊的传统。
在构造每个操作码节点的时候同时对每个操作码进行完成性检查,以校验语意是否正确,比如是否使用了正确数量和类型的参数来调用内建函数。在完成一条分析树的分支以后,优化器就参与进来看看现在它能否对整个子树做某些转换。比如,一旦它知道我们给某个接收一定数量参数的函数一列数值,那么它就可以把记录可变长参数函数的参数个数的操作码仍掉。还有一个更重要的优化,我们称之为常量消除,将在本节稍后讲述。
这个回合同时还构造稍后执行的时候要用的节点访问顺序,这个处理也几乎完全是戏法,因为第一个 要访问的地方几乎从来都不是顶端节点。编译器把操作数作成一个临时的循环,把顶端节点指向第一个要访问的操作码。如果顶端操作码无法和更大的操作码匹配,那么这个操作码环就破裂了,于是更大的操作码就成为新的顶端节点。最后是这个环因为进入其他结构里(比如子过程描述符)而合理破裂。尽管其分析树会一直延伸到树的底部(象图18-2里那样)子过程调用者仍然可以找到第一个操作码。而且这里不需要解释器回朔到分析树里寻找从哪里开始。
- 回合 2: 自顶向下优化
如果你在阅读一小段 Perl 代码(或者英文文章),那么如果你不检查上下文的词法元素的话,你就无法判断环境。有时候你在获取更多的消息之前无法判断真正将要发生什么事情。不过,不必害怕,因为你并不孤独:编译器也
一样。在这个回合里,在它刚创建的子树上向回退,以便进行局部优化,最需要注意的事情是环境传播。编译器用当前节点产生的恰当的环境(空,标量,列表,引用或者左值等)标记到相邻的下层节点上。不需要的操作码被清空但并不删除,因为现在重新构造执行顺序已经太晚了。我们将依赖第三回合把他们从第一回合决定了的临时执行顺序中删除。 - 回合 3:窥探孔优化器
有些代码单元有自己的存储空间,它们在里面保存词法范围的变量。(在 Perl 的说法里,这样的空间称为便条簿(scratchpad))。这样的单元包括 eval STRING,子过程和整个文件。从优化器的角度来说,更重要的是它们 1. 有自己的进入点,这就意味着尽管我们知道从这里开始的执行顺序,我们也不知道以前发生过什么,因为这个构造可能是从其他什么地方调用的。因此如果一个这样的单元被分析器分析完成,Perl 就在那段代码上运行一个窥探孔优化器。和前面两个回合不同的是,前面两个回合在分析树的各个分支上运行,而这个回合是以线性执行顺序横跨代码,因为这里基本上是我们采取这个步骤的最后的机会了,然后我们就要从分析器上砍断操作码列表了。大多数优化已经在头两个回合里完成了,但是有些不行。
最后的分类优化在这个阶段发生,包括把最后的执行顺序缝合在一起,忽略清空了的操作码,以及识别什么时候可以把各种操作码缩减成更简单的东西。识别字串的链接就是一个非常重要的优化,因为你肯定不想每次向字串结尾加一点东西的时候就要到处拷贝字串。这个回合不仅仅做优化;它还做大量“实际”的工作:捕获光字,在有问题的构造上生成警告信息,检查那些可能无法抵达的代码,分析伪哈希键字,以及在子过程的原型被编译前寻找它们。
- 回合 4:代码生成
这个回合是可选的;在普通的情况下并不出现这个回合。但是,如果调用了任何三个代码生成器之一——B::Bytecode,B::C,和 B::CC,那么就会最后再访问一次分析树。代码生成器要么发出用于稍后重新构造分析树的串行的 Perl 字节码,要么是代表编译时分析树状态的文本 C 代码。
C 代码的生成源自两种不同的风格。B::C 简单地重新构造分析树,然后用Perl 在执行的时候自己用的普通的 runops() 循环运行之。B::CC 生成一个线性化了并且优化过的运行时代码路径(组成一个巨大的跳转表)的 C 等效物,并且执行之。
在编译的时候,Perl 用许多方法优化你的代码。它重新排列代码好让它在运行时更有效。它删除那些在执行时可能永远不会到达的代码,比如一个 if(0) 块,或者在if (1) 块里的 elsif 和 else。如果你使用用 my ClassName? $var 或
our ClassName? $var 声明的词法类型,而且 ClassName? 包是用 use fields 用法设置的,那么对下层的伪哈希的常量域的访问会在编译时进行拼写检查并且转换成一个数组访问。如果你给 sort 操作符一个足够简单的比较路径,比如{$a $b} 或者 {$b cmp $a},那么它会被一个编译好的 C 代码代替。
Perl 里最富戏剧性的优化可能就是它尽可能快地解析常量表达式的方法。比如,让我们看看图 18-2 的分析树。如果节点 1 和 2 都有文本和常量函数,节点 1 到4 将已经被那些计算代替了,就象图 18-3 的分析树:
图 18-3 (略。。。)
这就叫常量消除。常量消除并不仅限于象把 2**10 转成 1024 这么简单的场合。它
还解析函数调用——包括内建的和用户定义的子过程,只要它们符合第六章,子过程,
的“内联常量函数”的标准。回想一下 FORTRAN 编译器对它们内在函数臭名昭著的
知识,Perl 在编译的过程中也知道要调用它的哪些内建函数。这就是为什么如果你
试着做 log(0.0) 或者 sqrt (求平方根)一个负数的时候,会导致一个编译错误,
而不是一个运行时错误,并且解释器根本没有运行。(注:实际上,我们在这里实在
是简化的太厉害了。解释器实际上是运行了,因为那就是常量消除器实现的方法。
不过它是在编译时立即运行的,类似 BEGIN 块执行的方式。)
甚至任意复杂的表达式都是提前解析的,有时候导致整个块的删除,象下面这个:
if (2* sin(1)/cos(1) < 3 && somefn() ) { whatever() }
那些永不计算的东西不会生成任何代码。因为第一部分总是假,所以 somefn 和
whatever 都不会调用。(所以不必期待那个语句块里会有 goto 标签,因为它甚至
都不会在运行时出现。)如果 somefn 是一个可以内联的常量函数,那么即使你把
上面的顺序换成下面这样:
if ( somefn() && 2*sin(1)/cos(1) <3 )) { whatever() }
也不会改变输出,因为整个表达式仍然在编译时解析。如果 whatever 可以内联,
那么它在运行时不会被调用,甚至在编译的时候也不会;它的值会被当作一个文本
常量那样嵌入程序中。然后你会收到一个警告
“Useless use of a constant in void context”。如果你没有意识到它是常量,
你可能会觉得奇怪。不过,如果 whatever 是一个在非空的环境中(就象由优化器
决定的那样)计算的最后一条语句,那么你就看不到警告。
你可以用 perl -Dx 看到在所有优化阶段完成之后构造的分析树的最终结果。(-D
要求你用的是特殊的,制作时打开调试的 Perl)。还可以看看后面描述的
B::Deparse 节。
总而言之,Perl 编译器为优化代码工作得很努力(不过不是特别努力),然后来的
就是运行时整体的执行速度提高了。现在就是让你的程序运行的时候了,所以让我们
讨论它吧。
18.3 执行你的代码
打个比方,Sparc 程序只能运行在 Sparc 机器上,Intel 程序只能运行在 Intel
的机器上,而 Perl 程序只能运行在 Perl 机器上。Perl 机器处理那些 Perl 程序
认为在一台计算机里完美的属性:内存是自动分配和释放的,基本数据类型是动态的
字串,数组和哈希,而且没有尺寸限制,并且系统表现得都非常相象。Perl 解释器的
工作就是把它所运行的任何计算机都搞得象那种理想的 Perl 机器一样。
这样的假想的机器就好象是一种特别设计来运行 Perl 程序的机器一样。编译器生成
的每个操作码都是这种假想的指令集中的一个基本命令。Perl 里没有使用硬件程序
计数器,而是由解释器跟踪当前要执行的操作数。Perl 里也没有硬件堆栈指针,
解释器有它自己的虚拟堆栈。这个堆栈非常重要,因为 Perl 虚拟机(我们拒绝称之
为 PVM )是一个基于堆栈的机器。Perl 操作码在内部称做 PP 代码(“压栈-出栈
代码”(“push-pop codes"))因为它们操作解释器的虚拟堆栈以寻找所有操作数,
处理临时数值,还有存储所有结果。
如果你曾经用 Forth 或者 PostScript? 写过程序,或者用 RPN(“反转润色符号”
"Reverse Polish Notation")一个 HP 的科学计算器记录,你就知道堆栈机器是
如何运行的了。甚至如果你没有用过这些东西,它的概念也是简单的:要把 3 和 4
相加,你按照 3 4 + 这样的顺序做处理而不是习惯的 3 + 4。从堆栈的角度来看,
这里的意思是你把 3 然后是 4,压入堆栈,而 + 则把两个参数弹出堆栈,把它们
相加,然后把 7 压回堆栈,然后 7 就会留在那里直到你对它进行其他处理。
与 Perl 的编译器相比,Perl 的解释器是非常直接的,直接得几乎让人厌倦的程序。
它所做的一切就是走过那些编译出来的操作码,每次一个,然后把它们发配给 Perl
运行时环境,也就是 Perl 虚拟机。它只不过是一小堆 C 代码,对吧?
实际上,它一点也不乏味。Perl 的虚拟机替你跟踪一堆动态环境,这样你就不用跟踪
了。Perl 维护不少堆栈,你用不着理解它们,但是我们会在这里列出来好加深你的
印象:
- 操作数堆栈(operand stack)
这个堆栈我们已经讲过了。
- 保存堆栈(save stack)
在这里存放等待恢复的局部数值。许多内部过程也有许多你不知道的局部值。
- 范围堆栈(scope stack)
轻量的动态环境,它控制何时保存堆栈应该”弹出“。
- 环境堆栈(context stack)
重量级的动态环境;是谁调用了谁最后把你放到了你现在所处的位置。 caller 函数遍历这个堆栈。循环控制函数扫描这个堆栈以找出需要控制哪个循环。如果你从环境堆栈剥出,那么范围堆栈也相应剥出,这样就从你的保存堆栈里恢复所有局部变量,甚至你用一些极端的方法,比如抛出例外或者longjmp(3) 出去等也是如此。
- 环境跳转堆栈(jumpenv stack)
longjmp(3) 环境的堆栈,它允许你抛出错误或者迅速退出。
- 返回堆栈(return stack)
我们进入这个子过程时的来路。
- 标记堆栈(mark stack)
在操作数堆栈里的列出的当前的杂参数的起点。
- 递归词法填充堆栈(recursive lexical pad stacks)
当子过程被递归地调用时词法变量和其他“原始寄存器”存放的地方。
当然,还有存放所有 C 变量的 C 堆栈。Perl 实际上力图避免依赖 C 的堆栈存储
保存的数值,因为 longjmp(3) 忽略了这样的数值的合理的恢复。
所有的这些就是说,我们对解释器的通常的看法:一个解释另外一个程序的程序,是
非常不足以描述其内部的真正情况的。的确,它的内部是一些 C 的代码实现了一些
操作码,但当我们提到“解释器”的时候,我们所说的含义要比上面这句话更多些,
就好象我们谈到“音乐家”时,我们说的含义可不仅仅是一个可以把符号转换成声音的
DNA 指令集。音乐家是活生生的“有状态”的有机组织。解释器也一样。
具体来说,所有这些动态和词法范围,加上全局符号表,带上分析树,最后加上一个
执行的线索,就是我们所谓的一个解释器。从执行的环境来看,解释器实际上在
编译器开始运行之前就存在了,并且甚至是可以在编译器正在制作它的环境的时候
解释器就开始进行初步的运行了。实际上,这就是当编译器调用解释器执行 BEGIN 块
等的时候发生的事情。而解释器可以回过头来使用编译器进一步制作自己的运行环境。
每次你定义另外一个子过程或者装载另外一个模块,那么我们称之为解释器的特定的
虚拟 Perl 机器实际上就在重新定义自身。你实际上不能说是编译器还是解释器在
控制这一切,因为它们合作控制我们通常称之为“运行 Perl 脚本”的引导过程。
就象启动一个孩子的大脑。是 DNA 还是蛋白质在做处理?我们认为两个都在起作用,
并且还有一些来自外部程序员的输入。
我们可以在一个进程里运行多个解释器;它们可以或者不可以共享分析树,取决于
它们是通过克隆一个现有解释器生成的还是通过从头开始制作一个新的解释器生成的。
我们也可能在一个解释器里运行多个线程,这种情况下我们不仅共享分析树而且还
共享全局符号——参阅第十七章,线程。
不过大多数 Perl 程序只使用一个 Perl 解释器执行它们编译好了的代码。并且尽管
你可以在一个进程里运行多个独立的 Perl 解释器,目前可以实现这个目的的 API 只
能从 C 里访问。(注:到目前为止只有一个例外:Perl 5.6.0 可以在 Microsoft
Windows 的仿真 fork 的支持下实现克隆解释器。到你阅读到此处时,可能也有一个
实现 "ithread" 的 Perl API 。)每个独立的 Perl 解释器起到一个完全独立的
进程的作用,但是并不象创建一个完全新的进程那样开销巨大。这就是为什么
Apache 的 mod_perl 扩展的性能如此突出的原因:当你在 mod_perl 里启动一个
CGI 脚本时,那个脚本已经被编译成 Perl 的操作码了,消除了重新编译的需要——
但是更重要的是,消除了启动一个新进程的需要,这才是真正的瓶颈。Apache 在一个
现存的进程里初始化一个新的 Perl 解释器然后把前面编译完了的代码交给它执行。
当然,这里头的东西要远比我们说的多——一直是这样的。更多关于 mod_perl 的
东西,请参考 Writing Apache Modules with Perl and C(O'Reilly, 1999)。
许多其他应用都可以内嵌 Perl 解释器,比如 nvi,vim 和 innd;我们可不指望在
这里把它们都列出来。而且还有许多甚至都不敢宣传它们有内嵌的 Perl 引擎的商业
产品。它们只是在内部使用它,因为它能按照他们的风格实现他们的程序。
18.4编译器后端
所以,如果 Apache 可以现在编译一个 Perl 程序而稍后才执行它,你为什么不行?
Apache 和其他包含内嵌 Perl 解释器的程序做得非常简单——它们从来不把分析树
存到一个外部文件中。如果你对这样的做法表示满意,而且不介意使用 C API 获得
这样的特性,那么你可以做一样的事情。参阅第二十一章,内部和外部,里的
“嵌入 Perl”一节,获取如何从一个闭合的 C 框架里访问 Perl 的信息。
如果你不想走这条路或者有其他需要,那么还有几个选择可用。你可以不让来自 Perl
编译器的输出立即输入 Perl 解释器,而是调用任意可用的后端。这些后端可以把
编译好的操作码串行化和存储到任何外部文件中,甚至可以把它们转换成几种不同
风格的 C 代码。
请注意那些代码生成器都是非常试验性的工具,在生产环境中不可靠。实际上,你甚至
都不能指望它们在非生产环境里面能用——除了极为稀有的情况以外。现在我们已经
把你的期望值降得足够低了,这样任何成功都可以比较容易超过它们,这时候我们
才能放心地告诉你后端是如何运行的。
有些后端模块是代码生成器,比如 B::Bytecode,B::C,和 B::CC。其他的实际上
都是代码分析和调试工具,比如 B::Deparse,B::Lint,和 B::Xref。除了这些
后端以外,标准版还包括几种其他的底层模块,那些潜在的 Perl 代码开发工具的
作者可能对它们感兴趣。其他的后端模块可以在 CPAN 找到,包括(到我们写这些
为止)B::Fathom,B::Graph,B::JVM::Jasmin,和 B::Size。
如果你除了给解释器提供输入以外还有其他地方使用 Perl 编译器,那么 O 模块(也
就是 O.pm 文件)位于编译器和你分配的后端模块之间。你并不直接调用该后端;
相反,你调用中间层,然后由它调用你指定的后端。因此如果你有一个模块调用
B::Backend,你可以在一个脚本里这样来调用:
%perl -MO=Backend SCRIPTNAME
有些后端需要选项,用下面的方法声明:
%perl -MO=Backend, OPTS SCRIPTNAME
有些后端已经有调用它们的中间层的前端了,所以你不必费心记忆它们的 M.O。尤其
是 perlcc(1) 调用那个代码生成器,而代码生成器启动起来可能比较麻烦。
18.5 代码生成器
目前的三种把 Perl 操作码转换成其他格式的后端都是处于实验阶段的。(没错,
我们前面说过这些,但是我们不想你忘记这点。)甚至就算它门生成的输出碰巧能
正确运行,生成的程序也可能比平常需要更多的磁盘空间,更多的存储器,和更多的
CPU 时间。这是一个正在进行的研究可开发领域。不过一切都会慢慢好起来的。
18.5.1 字节码生成器
B::Bytecode 模块将分析树的操作码以平台无关的编码写出。你可以把一个 Perl
脚本编译成字节码然后把它们拷贝到另外一台安装了 Perl 的机器上跑。
perlcc 命令知道怎么把一个 Perl 源程序转换成一个编译成字节码的 Perl 程序。
这个命令是标准的,不过仍然处于实验阶段。你要做的事情只是:
%perlcc -b -o pbyscript srcscript
然后你就应该能直接“执行”所生成的 pbyscript。该文件的开头看起来象下面这样:
#!/usr/bin/perl use ByteLoader 0.03; ^C^@^E^A^C^@^@^@^A^F^@^C^@^@^@^B^F^@^C^@^@^@^C^F^@^C^@^@^@ B^@^@^@^H9^A8M-^?M-^?M-^?M-^?7M-^?M-^?M-^?M-^?6^@^@^@^A6^@ ^G^D^D^@^@^@^KR^@^@^@^HS^@^@^@^HV^@M-2W<^FU^@^@^@^@X^Y@Z^@ ...
你会看到一小段脚本头后面跟着一堆纯二进制数据。这些东西看起来非常神秘,不过
其实不过是一个技巧而已。ByteLoader 模块使用一种叫做“源码过滤器”的技巧在
Perl 能够看到源程序之前修改它们。源码过滤器是一种预处理器,它接收当前文件中
在它后面的所有内容。与类似 cpp(1) 和 m4(1) 这样的宏预处理器不同,它们只能
做简单的转换,而源码过滤器没有限制。源码过滤器已经用于检查 Perl 的语法,
用于压缩或加密源代码,甚至用 Latin. E perlibus unicode;
cogito,ergo substr;挑剔的 dbm,等写 Perl 程序;
ByteLoader? 模块是源码过滤器,它知道如何把 B::Bytecode 生成的串行的字节码
分解并重新组合成原来的分析树。这样重新组合的 Perl 代码被接合进入当前分析树,
不需要通过编译器。当解释器拿到这些操作码,它就开始执行它们,就好象它们早就
在那里一样。
18.5.2. C 代码生成器
剩下的代码生成器,B::C 和 B::C 都生成 C 代码,而不是串行化的 Perl 操作码。
它们生成的代码非常难读,如果你想试着读他们那你就傻了。它可不是那种转换好了
的 Perl 到 C 的代码片段,可以插入到一个更大的 C 程序里。关于那方面的内容,
请参阅第二十一章。
B::C 模块只是把创建整个 Perl 运行时环境所需要的 C 数据结构写出来。你得到
一个专用的解释器,它的所有由编译器制作的数据结构都已经初始化好了。从某种意义
上来说,所生成的代码类似 B::Bytecode 生成的东西。它们都是编译器制作的操作码
树的直接翻译,只不过 B::Bytecodes 把他们以符号的形式输出,这些符号稍后可以
重建操作码树并且插入到一个运行着的 Perl 解释器,而 B::C 把那些操作码输出为
C 代码。当你用你的 C 编译器编译这些 C 程序并且把它们和 Perl 库链接,生成的
程序就不需要在目标系统安装 Perl 解释器就可以运行。(不过,它可能需要一些
共享库——如果你没有把所有的东西都静态链接的话。)不过,这个程序和那些运行
你的脚本的普通的 Perl 解释器没有什么根本的区别。它只不过是预先编译成一个
独立的可执行影象而已。
不过,B::CC 模块试图做得更多。它生成的 C 源文件的开头看起来很象 B::C 生成的
东西,(不过,当你犯傻的时候当然什么东西看起来都一样。我们难道没有告诉你别看
吗?)不过,最终所有相似都会消失。在 B::C 生成的代码里,有一个 C 程序的很大
的操作码表,它的作用就象解释器自己做的处理,而 B::CC 生成的 C 代码是以你的
程序对应的运行时顺序为布局输出的。它甚至还有 C 函数对应你的程序的每个函数。
它做了一些基于变量类型的优化;有些速度测试可以比在标准的解释器里快一倍。这是
目前的代码生成器中最具野心的一个,也是对未来做出了最多承诺的一个。不过同时
也是最不稳定的一个。
那些为毕业设计找主题的计算机科学系的学生可以在这里仔细找找。这里有大量还未
琢磨的钻石等待你们发掘。