结构化、模块化程序设计的思想最早就是由Pascal的创始人Wirth教授提出来的,因此,在多文件编译方面,Pascal是有一定优势的。实际上,Pascal的USES关键字与C语言的#include从功能上讲是比较类似的。不过,从编译器实现的角度而言,却存在一定的差别。
单文件源程序的时代已经渐渐远去了,对于现代编译器而言,处理多文件源程序是一个必备的能力。这里,笔者觉得有必要简单谈谈多文件编译的相关内容。读者在编写多文件程序时,可能并没有仔细考虑过多文件编译的内核。多文件编译的方法很多,最常见的模式有以下两类:合并后编译、编译后合并。
所谓“合并后编译”,就是在编译前或编译过程中某一适当的时刻,将多个文件按需求合并为一个源文件,然后再由编译器编译此文件。此时,多文件形式对于编译过程是完全透明的。读者熟悉的编译预处理就是如此。在正式编译之前,先由预处理器将包含的头文件直接合并入某一主文件中。然后,再由编译器进行统一编译。值得注意的是,这里仅讨论编译预处理,并不表示C编译器默认就是这样完成多文件编译的。当然,程序员可以通过一些特殊的编程技巧,使C编译器使用“合并后编译”的方式进行编译。如果程序员在工程项目中只包含一个主程序文件,而使用#include方式将其他的C文件(不是头文件)包含入主程序文件,在这种情况下,C编译器就会在编译预处理时将所有的C文件合并到主程序文件中,再进行统一编译。编译预处理器是一种比较有效的处理手段,不过,并不是所有编译器都拥有预处理器的。大多数Pascal编译器(包括Neo Pascal)都没有真正意义上的预处理器,因此,源文件合并的工作通常是由编译器完成的。
所谓“编译后合并”,以Windows环境为例,就是将多个源程序分别编译成汇编文件,再由汇编器生成相应的obj文件,最后,由链接器将多个obj文件链接成可执行文件。与前者相比,这种方式的主要优点在于比较容易实现部分编译的需求。虽然这种方式也存在一些缺点,例如,某些源程序的错误可能会被隐藏,直到链接阶段才能发现,但对结构化思想而言,这种方式的提出可能是一种质的飞跃。最终,这种方式就演化形成了库文件的概念。由于库函数几乎不可能以源码形式公开,这种方式是必然之选。库文件的源程序通常经编译、汇编后生成obj文件,然后用户程序根据需要选择使用。随着obj数量的增加,人们开始考虑着手按功能将obj文件进行归类。实际上,就是将obj文件按一定格式组织合并成一个文件,也就是常见的lib文件。大多数C编译器都是采用这种方式进行编译生成的,虽然它们也支持编译预处理。这种方式的优点明显,但是对语言的语法结构是有一定要求的,它要求语言本身支持编写库。虽然Pascal的模块化设计思想堪称经典,但笔者不得不承认其在模块化方面做得不够。避开某些细枝末节不谈,就标准Pascal本身而言,它并不支持编写库文件,这可能就是其先天的不足之处。同时,也是Pascal备受争议的重要原因之一。当然,一些商用Pascal编译器扩展了相关的语法结构,最终实现了这一功能。
虽然Neo Pascal支持多文件编译,但也有一定的限制。Neo Pascal只允许用户在非主文件(不是主程序所在的程序文件)中声明过程、函数,不允许声明全局常量、变量等。这与Delphi等商用编译器有所不同。
关于多文件编译的理论先谈到这里,下面详细分析一下Neo Pascal相关实现的细节。前面,已经了解了Neo Pascal的多文件编译主要是由编译器进行动态合并后进行统一编译的。既然要在编译过程中完成多文件合并,那么就必须将其视为一种语法结构,否则编译器将无法处理。因此,在Neo Pascal语法体系(BNF)中,存在USES声明的描述,而在C语言标准文法中并没有关于#include预处理指令的相关描述。
在Pascal语言中,USES通常只是用于说明包含文件的列表,但它并不是合并文件的插入点,这与#include指令也是不同的。原因非常简单,如果在USES声明处插入文件的实体内容(过程、函数),那么,合并后的程序结构并不是一个合法的Pascal程序。而合法的插入点应该位于过程函数声明部分之前。实际上,语义子程序在分析USES声明结构时,只需记录包含文件的列表,以备后用即可。
程序4-3 semantic.cpp
以上产生式中只有semantic093、semantic094两个语义子程序,而且从这两个语义子程序来看,除了对iIdListFlag栈进行了一次入栈、出栈的操作外,根本没有完成其他任何语义动作。这里,先不必关注iIdListFlag栈的作用,稍后会作详细介绍。读者不难发现,产生式中的“标识符列表”是一个非终结符。也就是说在推导分析的过程中,如果“标识符列表”推导得到的产生式右部含有语义子程序,它们同样会被编译器执行。从Neo Pascal文法来说,“标识符列表”仅有的候选产生式如下:
【文法4-2】
那么,随着推导过程的深入进行,编译器一定会执行semantic012语义子程序。同样地,由于“标识符列表1”也是一个非终结符,它的相关语义子程序也会被调用。“标识符列表l”的候选式有如下两个:
【文法4-3】
如果在实际语法分析过程中,选择第一个产生式推导“标识符列表1”,则其中的semantic012将会被调用。而如果选择第二个产生式推导“标识符列表1”,由于其中没有包含任何语义子程序,编译器将不作任何调用。例如,编译器在分析USES filel,file2;声明结构时,其语义子程序调用顺序必定是semantic093→semantic012→semantic013→semantic014。这里,笔者不打算详细分析产生该调用顺序的过程了,这是一个非常简单的语法分析问题,读者可以自行参考自上而下的语法分析的特点。下面,就来看看semantic012相关源代码及其与semantic093、semantic094的关系。(www.daowen.com)
程序4-4 semantic.cpp
上述的semantic012并不完整,这里仅列出了与USES声明相关的源代码。从整个程序的结构来说,读者不难发现它是以iIdListFlag栈顶元素作为分支条件。因此,笔者有必要对ildListFlag栈作简单介绍。实际上,这与非终结符“标识符列表”有关。仔细观察Neo Pascal的文法,不难发现“标识符列表”这个非终结符在产生式右部多次出现。例如:
【文法4-4】
在选用上述候选式推导时,根据语法制导的特点,编译器同样会去调用semantic012语义子程序。显然,此时semantic012的语义动作与其在“包含文件说明→uses 093标识符列表094;”候选式中的语义动作是完全不同的。实际上,这个问题的矛盾主要是源于非终结符的复用,导致某些语义子程序可能同时需要处理若干个不同的语义情景。当然,要解决这个问题并不复杂,主要有两种处理方式:
(1)避免非终结符的复用。虽然这样可能会导致文法冗余,却是一个有效且可行的办法。实际上,在一个完整的语言文法中,非终结符的复用并不会太频繁。因此,导致的文法冗余的规模是可以接受的。
(2)设置标志辅助分析。实际上,在递归下降语法分析中,编译器可以借助于传参方式实现标志管理,这是一种常见且高效的方法。然而,本书采用的LL(1)是一种非递归的语法分析方法,因此,标志的管理必须借助于用户栈结构实现。这种方式不需要修改文法,只需在复用非终结符的候选式中加入维护标志栈的语义动作即可。而被复用的非终结符的语义子程序则根据标志栈的栈顶元素判断执行相应的语义动作。而iIdListFlag就是一个标志栈。
下面简单分析一下semantic012用于处理包含文件声明的语义动作。
第7行:为了保证模块文件不被重复引用,在处理包含文件声明时,必须加以判断。SearchUseFileTbl是一个简单的查找函数。
第9行:保存包含文件名,以备后用。严格地说,包含文件信息表(UseFileTbl)并不是一张符号表,只是一个辅助数据结构。它的结构非常简单,声明如下:
【声明4-10】
这里简单解释一下m_bFlag标志。实际上,编译器在处理包含文件时,有时难以避免模块文件的循环引用或重复引用的情况,这可能会导致非常严重的后果。因此,编译器通常会设计一个简单的数据结构来避免类似情况发生。虽然编译器的判断可能与用户的设计初衷相悖,但也是无可奈何的。在文件合并时,编译器将利用m_bFlag标志避免上述情况发生。
免责声明:以上内容源自网络,版权归原作者所有,如有侵犯您的原创版权请告知,我们将尽快删除相关内容。