本小节将讨论类型声明部分的语义分析。前面,笔者已经从符号表结构的角度阐述了类型描述的相关问题,读者可能已经感觉有些复杂。确实如此,在编译器设计中,只要和类型扯上关系,通常许多问题可能就变得比较复杂。下面将分析类型声明部分语义分及其实现。
实际上,本小节的讨论目的就是构造一组语义子程序,使之根据输入源程序自动完成类型信息表及其相关符号表的登记、维护工作。当然,这个过程是稍显繁复的。
【文法4-6】
以上是类型声明部分的文法,其中“类型”非终结符是最为复杂的,稍后将详细分类讲述。这里,先来看看semantic006的功能。semantic006的主要功能就是生成类型信息表项,并填写类型名字等属性。
程序4-7 semantic.cpp
第6~10行:这个条件判断就比较复杂了,除了常量信息表、标号信息表、类型信息表之外,类型的名字还可能与枚举常量冲突,因此,必须加以考虑。
第13行:将指向当前类型表项的指针压入iTypePos栈。iTypePos栈的元素类型就是普通的整型,而该元素指向的类型信息表项就是当前正在分析的类型信息。那么,为什么需要使用iTypePos栈呢?下面,笔者就对这个问题稍作解释。
虽然semantic006填写了一部分类型信息表项的属性,并将其加入了类型信息表,但是,这些信息是远远不够的,需要后续语义分析子程序加以补充。读者可能会疑惑,为什么不采用类似semantic004的方式,向前搜索多个单词以获取类型名字呢?实际上,这里所面临的问题是不同的,候选式“常量定义→标识符=常量”中包含的“常量”虽是非终结符,但是它推导得到的单词数量是固定的。而“类型定义→标识符006=类型”中非终结符“类型”所能推导得到的单词数量是无法确定的。因此,试图在分析完毕后回填名字等信息将是比较复杂的。
然而,应用栈结构描述的意图是显而易见的。当然,就是考虑到类型嵌套声明的原因,这是一个很现实的问题。通常,处理这种嵌套问题的最好办法就是递归,但是,LL(1)分析法恰恰将递归的问题转化为非递归的形式了。因此,原本可以借助于递归传参的属性只能通过用户栈来模拟实现了。
下面,将着眼于类型相关的语义分析,这是一个充满挑战的话题。与其他程序设计语言相比,Pascal的类型系统还不算特别复杂,但足以阐述类型描述的相关问题。
1.基本类型
基本类型的语义分析比较简单,基本不需要处理复杂的类型链。下面,先来看看基本类型相关的候选式。
【文法4-7】
这里,semantic015、semantic010与基本类型的语义分析无关,先不作考虑。这两个语义子程序主要用于处理一些复杂类型的分析。
而semantic007涉及的语境比较复杂,它不但需要处理基本类型声明,还需要处理许多复杂的语义。这里,笔者只剖析其中的部分代码,更多的复杂处理,待后续章节详述。
程序4-8 semantic.cpp
第4行:判断iTypePos栈是否为空。
第7行:TokenToEnum是类型系统的一个静态成员函数,它的功能是根据单词值返回实际的类型枚举值,即StoreType的值。详细源代码稍后给出。
第8~10行:基本类型的信息非常简单,只需填写m_eDataType及m_eBaseType两个属性即可。注意,完成后必须将m_iState状态标志置为1,表示这个类型表项已经分析完毕,可以对其引用了。这个标志非常重要,它可以控制类型信息表中哪些类型表项可以被引用,而哪些则不可以。这里的引用,不仅包括变量对其引用,还包括其类型内部对其引用。
下面,再来看看TokenToEnum函数的源代码。
程序4-9 Type.cpp
前面,笔者已经详细分析了整型、实型、字符型、布尔型的相关语义子程序,它们并不是特别复杂。下面,讨论另一种基本类型——枚举类型。与枚举类型有关的候选式如下:
【文法4-8】
较之先前讨论的基本类型,枚举类型可能稍复杂。枚举类型的语义处理不但涉及类型信息表,还需要填写枚举信息表。虽然枚举类型是一种基本类型,但是,从编译器设计的角度而言,它的语义处理可能更接近于复杂类型。仔细分析该候选式,不难发现,候选式中主要是由一对终结符括号、两个语义子程序和一个非终结符“标识符列表”组成。关于非终结符“标识符列表”,曾经在4.3.3节中简单介绍过,它的相关产生式包含了一个语义子程序一一semantic012。因此,枚举类型的语义处理主要就是由semantic011、semantic012、semantic014三个语义子程序共同完成的。下面,就来看看相关源码的实现。
程序4-10 semantic.cpp
第3行:iEnumSize是一个全局变量,用于记录枚举信息表的表长。由于枚举信息表是一张全局符号表,各种枚举类型的枚举常量信息统一顺序存储在枚举信息表内,因此,在分析枚举常量列表之前,保存表长的值也就是记录当前类型的枚举常量列表在枚举信息表中的起始位置。其中,两个要点有必要说明:
(1)枚举类型声明内部是不允许嵌套其他类型声明的,故并不需要考虑应用栈结构来保存起始位置。
(2)保存起始位置的目的在于便于计算该枚举类型的枚举常量个数。由于Pascal规定枚举类型的常量不允许超过256个,因此,语义子程序应该加以判断。
第6~11行:处理枚举类型作为集合类型的基类型。集合类型的类型链构造比较特殊,是由基类型语义子程序处理,而不是集合类型本身的语义子程序处理的。根据Pascal的特点,集合类型的基类型只能是少数的有序类型及枚举类型,因此,为了便于设计,笔者没有使用iTypePos栈加以处理。
第20行:指向枚举信息表尾,因为这个位置就是当前枚举类型的枚举常量列表的开始。
第21行:前面已经提到枚举类型语义处理将涉及“标识符列表”相关语义子程序。然而,在整个文法体系中,由于非终结符“标识符列表”存在多处复用,因此,semantic012语义子程序只能依赖iIdListFlag标志栈,才能保证语义动作的正确执行。
程序4-11 semantic.cpp
第3行:判断iListFlag标志栈。当非终结符“标识符列表”被复用时,标志栈是semantic012得以正确处理各类语义的保证。
第6、7行:填写枚举信息表表项。在处理枚举常量列表时,需注意具体源语言关于枚举常量名字空间的约定,不同的语言可能存在一定的差异。
第14行:判断当前类型的枚举常量的个数是否己超过256。枚举类型的枚举常量个数不超过256是标准Pascal的规范,绝大多数Pascal编译器(包括实验室级、商用级)都遵守这一规范。正因如此,枚举类型变量的物理存储空间通常就是1B。
程序4-12 semantic.cpp
第6~17行:遍历类型的相关枚举常量的表项,并生成相应的常量信息。以枚举常量名作为常量信息表项的名字,以一个由编译器顺序分配的整型值作为常量的值,并加入常量信息表。这样做的优点是显而易见的,便于后续语义子程序的处理。因为后续语义子程序不必再关注枚举常量与普通常量之间的区别,只需检索常量信息表即可获得枚举常量的相关信息,包括实际的取值、枚举类型信息等。这里需说明两点:
(1)常量信息表的m_iEnumIdx属性用于指向该枚举常量所属类型的描述信息。
(2)在Pascal语言中,枚举常量的存储值是由编译器分配的,不允许用户指定。
第18、19行:设置结束标志,并将其加入枚举信息表。由于枚举信息表是顺序存储且全局共享的,所以编译器有必要记录各个枚举类型的枚举值列表在枚举信息表中的起始和结束位置。类型信息表的m_iLink属性已经记录了该类型在枚举信息表中的起始位置,但是编译器却始终无法得到结束位置。当然,在类型信息表中增加一个属性用于记录结束位置是完全可行的。不过,笔者并不主张这种修改方案。比较理想的处理方式就是在枚举信息表中设置结束标志,这样并不需要修改符号表的结构。在Neo Pascal中,将“-1”作为结束标志加入枚举信息表内。这样,编译器可以方便地获得某一类型的枚举值列表,只需从类型的m_iLink属性开始顺序向下读取,直到“1”结束标志终止即可。
至此,笔者已经分析了基本类型声明的相关语义处理。下面,将继续讲解数组类型声明及其语义处理。
2.数组类型
前面,读者应该已经了解了Pascal数组类型的一些基本特点,以及与C或Java的不同之处。下面看看数组类型的相关候选式。
【文法4-9】
与基本类型语义处理不同,数组类型是允许类型嵌套声明的。从文法角度而言,不难发现,非终结符“类型”存在右递归现象。此时,编译器是无法预知递归实际深度的。下面看一个实例分析。
例4-3匿名类型声明。
【声明4-11】
声明4-11并不复杂,先不考虑语义子程序的实现。填写符号表、构造类型链应该并不困难。整个过程可以描述为三步:填写a的类型信息;填写b的类型信息;将b的m_iLink属性指向a的类型表项。参见图4-5。
图4-5 声明4-10的语义分析过程示意
以上过程虽然涉及类型链的构造,但是整个过程并不复杂。由于b类型的基类型a是一个实名类型,编译器只需检索类型信息表并钩链成类型链即可。右递归的处理似乎消失于无形之中了,然而问题并非如此简单。请读者分析如下声明:
【声明4-12】
声明4-12的语义与声明4-11完全相同,只是声明形式略有不同。不过,这样的变化可能会给语义分析带来不少困难。通常,可以将整个分析处理过程描述为三步:先填写a的类型信息;再生成一个匿名类型表项,填写相应的属性,并将其加入符号表;最后将b的m_iLink属性指向a的类型表项。请参见图4-6。
图4-6 声明4-2的语义分析过程示意
以上过程,乍一看与先前的三个步骤比较类似,但是,其差别在于编译器主属类型在符号表中的位置是不同的。在类型复合声明中,两个类型结点之间通常是存在主从关系的。例如,在声明4-11中,b类型就是主属类型,而在b类型的复合声明中,a就是从属类型。值得注意的是,主从关系是针对某一特定的复合声明而言的,并不是基于整个源程序讨论的。通常,在处理某一类型声明时,编译器可能需要反复徘徊于主从类型之间进行分析。在记录类型声明的语义处理时,这种情况尤为突出。根据先前的符号表结构,编译器可以方便地从主属类型信息获取其从属类型的信息。不过,从某一个实际类型信息表项获取其主属类型的详细信息可能就不大容易了。形如声明4-11,编译器很容易地确定主属类型,因为主属类型通常就是类型信息表的最末表项。而形如声明4-12,编译器就无法简单地确定主属类型,因为编译器永远也无法预知主属类型表项后将存在多少从属类型的表项。当然,在语法上作严格限制,确实可以避免匿名类型的产生,同时也大大降低了编译器设计的复杂度。不过,这种处理方案并不是一个好主意。实际上,在很多情况下,人们更倾向于使用语言的匿名机制,包括匿名类型、匿名参数、匿名函数等,它们可以大大方便使用。
那么,可行的解决方法就是利用栈结构保存当前正在分析类型的指针(在Neo Pascal中也就是类型信息表项的位序号),可以将这个栈简称为“当前类型指针栈”。在Neo Pascal中,当前类型指针栈声明如下:
【声明4-13】
栈顶元素即为当前类型的指针,而次元素即为当前类型的主属类型的指针,依此类推。这种解决方案的提出并不仅仅是为解决数组声明的问题,而是旨在应对一切复杂的类型复合声明。虽然语法分析器是非递归实现的,但是,推导文法的过程仍然是一个递归的过程。在递推过程中,语义分析器将类型的指针逐一压栈,而在回归过程中,则将类型的指针逐一恢复出栈。当正确完成一次推导后,iTypePos栈应当仍然为空。
例4-4复合类型声明的处理。
【声明4-14】
本例是一个复合类型声明的实例。这里,笔者先不讨论详细的语义动作实现。只讨论编译器如何确定主属类型的问题,这是一个重要的话题,也是理解后续语义处理源代码的基础。
先前,读者应该已经基本理解了设置iTypePos的意图。下面,就来看看如何利用栈结构保存主属类型指针。请读者参见以下声明:
【声明4-15】
声明4-15在声明4-14的基础了增加了一些箭头。注意,这些箭头只是为了便于笔者讲解而设置的一种标记,与指针或者其他的语法机制无关。假设“J”表示将当前类型信息表项的指针入栈,而“f”表示将栈顶指针弹出。仅此两个语义动作,编译器就可以跟踪推导过程中的类型变化情况。详细的处理过程并不复杂,不再赘述。
当然,仅在源程序中加入入栈、出栈的动作,显然是不够的。下面来看如何在文法的适当位置安插语义子程序,使之在语法制导分析的过程中实现对栈的维护。当然,能使编译器正确完成语义分析工作是必须保证的提前。
要弄清楚源程序与文法之间的联系,归约的方法可能是最有效的,它比较适用于分析此类情况。本书并不打算详解归约的相关理论与技术。这里,仅以示意图形式描述其过程,参见图4-7。
图4-7 类型声明及相关语义动作
在图4-7中,笔者用五条下画线描述了整个归约过程,最终,每条下画线上的单词都将被归约为非终结符“类型”。而下画线的序号则表示归约的顺序。从归约的过程分析,可以得到如下结论:通常,在一组完整的类型声明中,将入栈动作插入声明首部,而将出栈动作插入尾部。换句话说,语义分析器就是在处理一组类型声明之初,执行入栈动作;在结束一组类型声明之际,执行出栈动作。实际上,这种栈操作方式与计算机中断现场保护非常类似。栈结构在计算机软、硬件中都有广泛应用。在后续章节中,还将涉及计算机硬件结构中的系统栈,它对程序设计语言的发展起到了至关重要的作用。
以上,笔者分析了复合类型语义处理的基本思想。从算法本身而言,可能并不是很复杂。有些读者可能会认为这些棘手问题的产生完全是由于自上而下的语法分析机制造成的。关于这个问题,读者的看法并非全无道理。事实上,如果采用自下而上的语法分析机制的确可以回避此类问题。不过,基于归约实现的编译器同样会遇到一些新的问题,而这些问题可能是前者无需考虑的。实践证明,从设计语义分析器的角度而言,两种语法分析机制实现的语义分析器都不是绝对完美的。
下面,继续讨论数组声明的相关语义处理。先来看看semantic016与semantic025的源代码实现,它们是“数组类型”的最外层语义子程序。
程序4-13 semantic.cpp
第3行:设置类型信息表项的m_eDataType属性,而iTypePos栈顶元素即为当前类型指针。
第4行:类型标志压栈,用于标识当前正在分析的类型是数组类型。数组、记录等类型的文法远比基本类型复杂,非终结符、产生式的复用的情况也较为常见。因此,编译器有必要进行标识,以便后续语义子程序处理。
程序4-14 semantic.cpp
第3行:弹出类型标志,与semantic016的压栈动作是对应的。
下面,再来看看“数组界限”及其语义子程序(semantic017、semantic018)的实现。
程序4-15 semantic.cpp
第4行:获取整数常量的表项指针。在词法分析阶段,编译器已经将整数常量加入了常量信息表中,并借助于单词的m_szContent属性将该常量信息表项位序号传递给了语法分析器及后续阶段。
第5行:从常量信息表中,获取数组的下限。
第7行:将数组维度信息表项加入符号表,便于semantic018填写上限(即m_iEnd属性)。注意,数组维度信息列表是一张局部符号表,结构也非常简单。
程序4-16 semantic.cpp
第6行:令tmp指针指向当前正在处理的数组维度信息表项。定位数组维度信息表项的工作主要分为两步:
1)借助于iTypePos栈查找到当前类型信息表项。
2)当前类型信息表项的m_ArrayInfo列表中的最末表项即为当前正在处理的数组维度工作主要分为两步:
第8行:数组界限的语义判断。在Neo_Pascal中,数组声明必须满足下限小于或等于上限的规定,否则报错。在标准Pascal中,数组上、下限是允许为负数的,故Neo_Pascal并没有严格规定上、下限整数的取值范围。
讨论至此,编译器似乎并没有对数组的基类型作任何语义处理。那么,数组的类型链到底是由谁构造的呢?似乎一直忽略了semantic015及semantic010的存在。在分析基本类型声明的语义处理时,笔者有意略过了这两个语义子程序。其原因就是这两个语义子程序是用于处理类型链的,而基本类型声明根本不涉及类型链问题,因此,没有必要深入阐述。然而,处理数组类型声明的过程中,编译器就无法回避类型链构造的问题了。下面,就来看看语义子程序是如何构造类型链的。
程序4-17 semantic.cpp
第3~7行:根据当前类型标志判断是否为集合类型、过程类型、函数类型、函数返回类型,如果满足条件则将当前类型信息表项的位序号压栈。这里,读者先不必深究,稍后将给出详细分析。
第8行:判断当前类型标志是否为数组类型或文件类型。从文法上而言,在类型声明部分,此语义子程序被多次复用。因此,设置iTypeFlag的目的就是用于区别不同类型声明的语境。实际上,semantic015的主要功能就是构造类型链。读者应该知道,不同复合类型的类型链结构是不尽相同的,例如,基本类型并不存在类型链。而记录类型的类型链则是一对多的关系,当然,设置记录类型字段列表的目的也仅仅是为了更好地解决其一对多的存储结构问题。然而,数组类型的类型链则是一对一的关系,即数组类型的基类型是唯一的,不可能同时存在多个基类型。而这一特点与文件类型比较类似。
第11、12行:设置类型名字。从应用的角度而言,semantic015所处理的数组基类型必定是匿名类型。不过,为了便于后续处理,编译器依然会为其分配一个默认且唯一的名字。当然,编译器还必须保证这个名字不与任何合法的用户语法元素(包括类型、变量、常量等等)的命名冲突,即不可占用任何用户的名字空间。这个条件看似比较苛刻,却并不难实现。通常,用户定义的语法元素的名字必须是合法的标识符。那么,编译器自动分配的名字只需破坏这一原则,自然就不可能产生任何冲突了。在Neo Pascal中,自动分配的名字以“noname”开头,后接一个顺序编号即可。由于合法的Pascal标识符是不允许以“”开头的,所以不可能存在冲突。而顺序编号是由编译器自动产生的,以保证不存在重名现象。
第14行:设置m_iState为0,表示当前类型未分析完成。这个属性保证了未完成分析的类型是不能被引用的,即使其类型信息表项已经被加入了符号表。
第17行:将当前类型信息表项的指针压栈,以便后续语义子程序继续完善该类型信息表项。最终,实现类型链的构造。
程序4-18 semantic.cpp
第3行:为了使程序逻辑比较严谨,这里有必要作相应判断。
第6行:修改类型信息表项的m_iState属性,即表示该类型信息可以被引用了。当然,匿名类型是不可能被后续声明显式引用的。
semantic015和semantic010是两个非常重要的语义子程序,在后续章节中,还将继续详细分析semantic015的剩余部分源代码。关于数组声明的处理,暂且讨论至此。实际上,现在读者再来回顾复合类型声明的语义处理可能已经并不是那么复杂了。在此,笔者并不想过多纠缠于源代码实现,更愿意将解决问题的思路与读者分享,以便读者举一反三,构造自己的编译器。
3.记录类型
Pascal的记录类型或者C语言的结构类型都是比较特殊的类型,它们是面向对象语言产生与发展的重要基础。早期的面向对象语言的设计初衷就是在记录或结构的基础上,引入函数成员,即所谓的操作或方法,以便将数据与基于数据的函数视作一个整体,称为“类”。虽然类的思想并不足以囊括面向对象设计的全部,也未必称得上是面向对象思想的精髓,但它是面向对象时代的先行者。下面,就来看看记录类型的相关文法的候选式。
【文法4-10】
(www.daowen.com)
记录类型的文法相对比较复杂。不过,其构造类型链的核心思想与数组声明的处理是非常相似的。笔者先从semantic019和semantic020这一对语义子程序着手开始分析记录类型声明的相关源代码实现。事实上,这两个语义子程序与先前的semantic016、semantic025(数组声明部分的语义子程序)是非常类似的。
程序4-19 semantic.cpp
第6行:在处理记录类型声明时,必然涉及“标识符列表”。根据先前的讨论,由于“标识符列表”存在多处复用,因此,不得不运用iIdListFlag栈加以标识。
Pascal记录类型的字段可以分为两类:固定字段、可变字段。首先,笔者介绍有关固定字段的语义处理。从文法来看,“字段固定部分”并没有设置语义子程序,实际并非如此。由于“标识符列表”、“类型”都是非终结符,因此,“标识符列表”及“类型”相关产生式中包含的语义子程序都有可能在分析过程中被调用。在此过程中,编译器必须协调处理非终结符复用的情况,即通过设置相应的标志以标识分析状态。虽然先前已经讨论了这两个非终结符的相关语义子程序,却并没有完整地给出semantic012、semantic015的源代码。下面,笔者就将讨论这两个语义子程序中与记录类型声明相关的语义动作。
程序4-20 semantic.cpp
第8行:设置m_iState属性。注意,这里的m_iState并不是隶属于类型信息表项的。在讨论符号表结构时,笔者并没有详细讨论该属性的功能。这个属性并不是一个符号表的实义属性,仅仅是为了便于语义子程序登记符号表而设置的一个临时属性。也就是说,当语义分析器完成符号表登记后,这个属性也就失去了实际意义,它不会为后续编译阶段提供任何有价值的信息。至于设置m_iState属性的目的也并不复杂,主要用于处理多个字段隶属于同一类型的情况。例如:
根据符号表的设计,这3个字段的m_iLink将指向同一个类型信息表项。当然,这种声明方式可能会给语义分析带来一些小小的不便。由于编译器从左向右扫描输入源程序,在处理aa、bb两个字段表项时,编译器将无法预知其类型,当然,也无法填写m_iLink指针。当分析到cc字段时,编译器才可以获得其类型表项的指针。注意,至此编译器仍无法预知类型的详情,仅能知道其类型表项指针而已。不过,凭借此信息也足以填写m_iLink指针了。此时,编译器必须将获得的指针信息同时回填到以上3个字段的m_iLink属性中,因此,编译器就必须根据一个特殊的标志属性来判断哪些字段需要回填。设置m_iState属性的目的正在于此。
第11行:这是一个循环判断的过程,主要用于判断字段名是否己被占用。根据Pascal的规定,字段的可见域仅限于其直属记录类型范围内。因此,编译器只需在当前类型的字段列表范围内判断当前符号名是否已被占用即可。
程序4-21 semantic.cpp
第4行:根据iTypeFlag标志栈,完成相应的语义动作。
第7、8行:设置临时表项的m_szName的名字。前面,笔者已经详细分析了匿名类型的处理,这里不再赘述。
第11行:将类型信息表项加入类型信息表中。
第15~23行:循环回填字段的m_iLink属性。这是一个逆向处理的过程,从当前类型的字段信息表表末开始,逐一向上查询至表首。
至此,完成了记录类型固定部分的语义分析及符号表登记工作。下面,继续来讨论可变部分的相关语义子程序。可变部分的文法看似比较繁复,但实际上其语义处理却不复杂。
这里,有必要说明一点,严格地说,Neo Pascal的记录类型与标准Pascal还是存在一定差别的。主要的区别在于可变部分的case结构,标准Pascal并没有明确规定case.….of之间必须是“标识符:类型”结构,通常允许省略“标识符:”。然而,Neo Pascal的文法却不允许省略“标识符:”。下面,就从语义分析的角度来看看其相关的实现。
程序4-22 semantic.cpp
semantic029与semantic012(部分)非常相似,除了第16行外,可以说是完全相同的。semantic029主要就是用于处理位于case...of结构之间的字段(暂且称为“可变部分的标志字段”)。从用户的角度而言,可变部分的标志字段同样被视作普通的固定字段。不过,这个字段同时还承担着标识一个可变字段集的功能。从Neo Pascal符号表的结构而言,实际上,字段列表中的m_szVarFieldFlag属性存储的就是所属case结构的可变区域标识字段名。
第16行:将可变部分的标志字段入栈。根据符号表的设计,该字段所属case结构中的所有字段的m_szVarFieldFlag属性都将填入该字段名。显然,在semantic029中是无法完成的,编译器必须将该字段名暂存,以备后用。至于为什么使用栈结构保存该字段名?原因非常简单,记录类型的可变部分同样存在复杂的类型嵌套。根据先前的讨论,栈结构非常有利于处理这种嵌套引起的递推及回归问题。
semantic030的功能非常简单,主要工作就是将先前压入的可变区域字段名出栈,保证栈的平衡即可。
程序4-23 semantic.cpp
semantic033、semantic034的功能非常单一,主要工作就是处理iIdListFlag标志栈。字段可变部分的字段名的处理与固定部分的字段名的处理动作是不同的,主要体现在两者所需填写的符号表属性不完全一致。通常,都借助于iIdListFlag标志栈以区分不同的语义动作,这种解决方式已经多次使用了,读者应该并不陌生。
程序4-24 semantic.cpp
既然提到了ildListFlag标志栈,就有必要分析一下非终结符“标识符列表”相关语义子程序当ildListFlag栈顶标志为“3”时的具体处理动作。根据前面的经验,非终结符“标识符列表”有关语义子程序就是semantic012。实际上,与先前固定部分的处理类似,可变部分处理的要点同样在于此。下面,就给出semantic012中的相关源代码。
程序4-25 semantic.cpp
实际上,读者不难发现,这部分与先前固定部分相关处理的源代码结构还是比较类似的。下面,笔者只针对其中的差异稍作解释,其余部分的分析请读者参见先前章节。
第10、11行:设置符号表表项的m_szVarFieldConst、m_szVarFieldFlag属性。分别将szVarFieldConst、szVarFieldFlag两个栈的栈顶元素赋给了这两个属性。szVarFieldFlag栈前面已作说明,而szVarFieldConst栈的功能就是用于暂存各case常量,具体的结构与前者是非常类似的。
第20~26行:这个if语句主要是用于判断m_szVarFieldConst、m_szVarFieldFlag属性的值是否有效。根据Pascal语言的规定,同一个可变区域(即case结构)内的常量值是不允许重复的,但不同可变区域内的常量值是允许重复的。
最后看看semantic031、semantic032这两个语义子程序的相关实现。前面,笔者已经分析了semantic029、semantic030,读者应该了解它们的主要功能就是管理szVarFieldFlag栈,但至今未提到szVarFieldConst栈的相关管理。因此,也就不难想到这两个语义子程序可能与szVarFieldConst栈的管理有关。
程序4-26 semantic.cpp
4.指针类型
熟悉C语言的读者对指针类型应该并不陌生,有不少人坚持认为强大、灵活的指针机制可以为程序设计提供不少便利。不过,指针是一把双刃剑,自诞生之日起,就一直饱受争议。有些程序设计语言学家认为指针是一种极不安全的语法结构,一些新型语言(例如,Java、C#、Python等)甚至摒弃了这一机制。关于指针的讨论可谓众说纷纭,在此,笔者也不便多作评说。不过,从研究一门程序设计语言的角度而言,指针机制还是值得深入剖析的,很多经典的论题都是在指针基础上讨论的。下面,就先来看看指针类型声明的相关文法。
【文法4-11】
这里,引入了一个非终结符“简单类型”,其主要原因就是由于标准Pascal不支持将匿名类型作为指针的基类型。例如:
【声明4-16】
【声明4-17】
声明4-16是非法的Pascal声明形式,只有将其改写成声明4-17的形式,才能被编译器接受。这是由于Pascal语言是一门强类型语言,Pascal编译器必须严格检查指针及其所指向元素的类型是否兼容。显然,依据这一规定,基类型为匿名类型的指针将失去任何实用价值。实际上,不同语言对于类型兼容的定义是存在差异的。这里,暂且不必深入讨论该话题,读者只需了解一点,即Pascal处理类型的基本策略是从严处理的,这是与C语言截然不同的。而C程序员不适应Pascal的主要原因也在于其强类型机制。
从Neo Pascal的文法可知,与指针类型声明相关的语义子程序主要有semantic008、semantic009。下面,笔者就详细分析这两个语义子程序。
显而易见,semantic008是用于处理指向用户自定义类型的指针声明,语义子程序就是根据用户自定义类型的名字检索符号表,判断该名字是否有效,如有效则准备构造类型链。
程序4-27 semantic.cpp
第4行:设置类型信息表项的m_szContent属性。读者可能有疑问,为什么需要设置这个属性?为什么在semantic008中没有设置m_iLink属性(即构造类型链)?
在讲解符号表结构时,笔者曾经提到过m_iState属性的作用及其指针类型声明的特殊性。根据先前的分析,读者应该已经知道,指针类型的基类型并不一定是在指针类型之前声明的。这是指针类型的一个非常特殊的性质,是其他复合类型都不具备的。
在分析指针类型声明时,由于指针类型的基类型可能出现在后续某一个位置上,编译器是无法预知的,这种情况与先前讨论的匿名类型是完全不同的。例如,声明形式如下:
【声明4-18】
编译器在分析A类型时,可以确定A是数组类型,并且其基类型是一个匿名类型。虽然编译器无法预知其后的匿名类型的复合层次,但可以知道其在源程序中的位置。因为根据数组类型的文法,如果其基类型为匿名类型,则必须出现在“OF”之后。不过,这一约定却并不适用于指针类型。
在分析指针类型声明时,通常只是将基类型的名字记录下来,待类型声明分析完毕后,重新遍历类型信息表,根据记录下来的名字检索类型信息表,回填m_iLink属性,构造类型链。m_szContent属性就是用于暂存基类型的名字。
第10行:注意这个判断并不是多余的,主要用于避免指针引用自身类型。类似于“A=^A;”的声明形式。虽然这种指针引用形式本身并不存在语义错误,但标准Pascal却并不支持。
那么,编译器又是如何回填m_iLink属性的?根据前面所说的,编译器将在适当的时刻完成此项工作。那么,到底何为适当的时刻呢?实际上,在分析完一个过程或函数的声明部分后,编译器就将调用CSymBolTbl.PtrCheck函数,以完成m_iLink回填的工作。下面,笔者将详细分析CSymBolTbl.PtrCheck函数的实现。先看看回填m_iLink属性的具体过程。
程序4-28 Symbol.cpp
第6、7行:判断该类型信息表项是否为T_POINTER类型且是否需要回填m_iLink。当表项的m_szContent属性不为空时,即表示该表项需要回填m_iLink属性。
第9、10行:检索当前过程的类型信息表。根据Pascal语言名字空间的规定,编译器将优先检索直接所属过程的类型信息表项。
第12行:当检索直接所属过程的类型信息表失败后,编译器将继续检索主程序的类型信息表。当然,由于Neo Pascal不允许过程、函数(除主程序外)的嵌套声明,所以检索工作变得相对容易。如果按照标准Pascal的规定,编译器将由内向外逐级检索。
第20行:当两次检索都失败后,表明该指针指向的用户自定义类型名字并不存在,即表示该指针的声明是非法的。函数必须回传该指针表项的指针(即通过iPos引用参数实现),便于语义子程序报告错误。
程序4-29 semantic.cpp
第6行:与semantic008的处理完全不同,由于指针基类型都是基本类型,所以构造类型链变得非常容易。语义子程序只需为基类型增加一个表项,然后通过m_iLink属性将两者链接起来即可。
第8行:由于基类型是一个基本类型,又是一个匿名类型。编译器并不需要过多理会其rn_iState属性。
5.集合类型
虽然Pascal的集合类型可能是对传统数据类型的一种突破与丰富,但是笔者个人认为Pascal的集合类型的设计并不成功。由于它的限制较多,因此应用领域非常之小,许多Pascal、Delphi程序员很少使用集合类型。即便如此,Pascal集合类型却成为了先驱,是后来程序设计语言完美实现集合类型的奠基石。其中,C++的set模板类就是一个成功的实例。当然,考虑到尽可能兼容标准Pascal语言,所以Neo Pascal依然保留了集合类型。集合类型的相关文法非常简单,只有一条产生式。下面,笔者就简单分析一下集合类型声明的相关源代码实现。
程序4-30 semantic.cpp
集合类型声明的语义处理比较简单,主要是基于iTypeFlag标志栈的操作。在介绍数组声明时,笔者已经详细分析了iTypeFlag标志栈的作用以及构造类型链的过程,这里不再赘述。根据先前的讲述,类型链的构造将在非终结符“类型”的相关推导过程中完成。
在标准Pascal语言中,集合类型的基类型必须满足两个条件:一是有序类型;二是类型的允许取值总数不能超过256。在Pascal语言中,集合类型通常指的是一个有限的集合,即集合所允许的最大元素个数是限定的,这是由Pascal集合类型的实现内核决定的。实际上,Pascal集合类型的实现内核并不复杂,每个集合类型变量都有32B(即256位)的存储空间,每个位表示一个集合元素,使用位与、位或等操作来模拟集合的交、并、差运算。从Pascal集合类型的实现机制分析,不难发现,它是比较低效的,并且又有一定的局限性。即便如此,这种实现机制还一直被Turbo Pascal、Delphi等编译器沿用至今。Neo Pascal为了尽可能兼容标准Pascal,同样采用这种并不高效的实现机制。当然,随着人们的研究深入,不少更为高效的实现方式随之产生。有兴趣的读者可以参考研究C++的相关实现,相信必定能获益良多。
了解了Pascal集合类型的特性后就不难发现,构造集合类型链相关的语义子程序主要包括:semantic015、semantic007、semantic011、semantic023。由于这些语义子程序都存在多重复用,所以必须根据iTypeFlag标志栈的标识执行相应的语义动作。semantic015、semantic011己在数组类型声明章节中详细分析过,不再赘述。下面,主要来看看semantic007的相关语义动作,而semantic023暂且不作讨论。
semantic007主要用于处理基类型是基本类型(枚举类型除外,由semantic011处理)的集合类型声明。
程序4-31 semantic.cpp
第5行:设置表项的m_iLink属性,构造类型链。集合类型的类型链是比较简单的,只可能有两个类型结点,即主属类型结点和基类型结点。与数组声明不同的是集合类型的基类型结点并不是由semantic015产生的,而是由基类型相关的语义子程序直接生成的。例如,这里的semantic007就是基类型的语义子程序,读者只要比较semantic007中针对不同类型标志的语义动作,便可理解其中的差别了。当然,采用与数组声明处理类似的方式也是完全可行的,只是程序逻辑可能复杂一些。
第7~13行:生成一个临时类型结点,将其设置为基类型结点。
第14~17行:用于判断集合类型的基类型是否为合法类型。这里,不必考虑枚举类型的情况,因为semantic007并不需要兼顾枚举类型声明的相关语义处理。枚举类型的语义处理由semantic011完成,先前笔者己作了详细分析。
6.字符串类型
字符串类型是一种常用的数据类型,其应用领域极为广泛。随着计算机的广泛普及,从应用角度而言,字符串处理的重要性可能已经凌驾于传统数值处理之上了,例如,办公服务、Web应用、信息检索等等。实际上,编译技术也与字符串处理有着密切的联系。不过,在20世纪六七十年代,计算机的主要应用还局限在数值计算领域,不少程序设计语言对于字符串处理的支持都不尽理想。当然,一个重要的事实也不可否认,那就是当时的硬件环境也并不允许程序设计语言支持过于复杂的字符串处理操作。
严格地说,无论是C语言的字符指针还是Pascal的字符数组都不属于真正的字符串类型。它们将字符串的逻辑、物理结构完全暴露在用户面前,其唯一目的就是希望由用户为某些复杂的字符串操作负责,至于这些操作的效率与性能更多取决于用户的编程技巧与经验。然而,Delphi、C#等商用编译器在字符串应用方面就做得比较好,可能都是出白天才设计师Anders Hejlsberg之手的原因吧,两者字符串的内核实现机制(这里主要指的是逻辑、物理结构,并不考虑.NET的托管机制)也非常类似,用户可以方便地应用字符串而不必过多关注其内核实现。C++在字符串处理方面同样也是比较出色的,基于Windows平台的VC++编译器更是提供了两种不同的字符串类型,即MFC的CString和STL的string。不论是何种字符串类型,究其本质仍然是字符指针的不同封装形式而已。由此可见,字符串类型已经得到广泛的应用,软、硬件领域都在字符串处理方面作了不少探索。很少有一个现代编译器是不支持字符串类型的,故笔者在Neo Pascal中增加了对字符串类型的支持,这与标准Pascal是有区别的。
字符串类型声明的语义处理是非常简单的,从文法上来看,字符串类型实际上就是一个基本类型,它的语义动作与普通的整型、实型无异。读者可以参考基本类型声明的相关分析。
下面,简单讨论一下Neo Pascal字符串类型的内核实现。Neo Pascal字符串类型的内核就是一个字符指针,这与Delphi的string类型是有区别的。笔者这样设计的主要原因是为了兼容Windows的标准API函数。由于Windows大部分API接口主要兼容C语言,所以其API的参数都是以字符指针类型替代字符串类型的。像Delphi的string类型等是不符合这一标准的,所以在调用API时,不得不进行一些类型转换操作。为了避免这些麻烦,笔者并没有采用Delphi string的实现方案。实际上,Neo Pascal的string更像是Delphi语言的PChar类型,只是在其基础上增加了一些字符串运算操作而已。
不论是哪种内核实现,在不考虑效率的情况下,编译器已经完全有能力将字符串类型封装成一个使用极其方便的基本类型,对于用户而言,可以像处理普通数值类型一样处理字符串类型。但是,字符串处理始终没有走出字符指针的影子。即便是今天,某些字符串的处理已经被直接引入到CPU的指令集中,对于字符串的支持仍然不够完美,在大量涉及字符串操作时,其处理效率仍然不尽如人意,尤其是字符串连接、子串检索等。计算机科学也始终没有中止对字符串的研究与探索,当然,并不仅仅局限于软件、算法领域。因此,有理由相信未来计算机科学在字符串处理领域一定会有所突破的。
7.用户自定义类型
前面,笔者花费了大量的篇幅逐一介绍了各种类型声明的语义处理动作。现在讨论类型声明部分的最后一个话题——用户自定义类型。这种声明形式是常见的,例如:
【声明4-19】
B是一个集合类型,而该集合类型的基类型是另一个用户自定义类型A。在分析B的声明时,必须将其类型结点的m_iLink属性设为A类型信息表项的位序号,以构成B的类型链。不过,处理这种声明形式的语义动作与之前讨论的是不同的。此前,编译器更多关心的是声明右部是匿名类型的情况,即声明右部是一个具体类型声明,而不是一个现已存在的用户自定义类型名。在处理匿名类型时,构造类型链的工作通常会变得比较复杂。主要原因就是编译器无法预知匿名类型的嵌套层次,分析过程是递归向下进行的。虽然从表面上来看,语义分析器并不存在真正的递归调用,它只不过是利用栈模拟递归的一种实现方式而已,其本质还是递归的思想。与匿名类型的处理相比,处理声明右部是用户自定义类型名字的情况就容易得多了,其过程可能更多涉及的是类型信息的检索。
在声明4-19中,分析B类型声明的过程如下:当分析到B的基类型是A时,便检索符号表,如果A是合法的类型名字,则将两者构成类型链,否则报错(指针类型声明除外)。这种语义动作非常简单,并不需要理会任何因模拟递归而设置的临时栈结构。下面,就来看看用户自定义声明形式的文法:
【文法4-12】
其中semantic015、semantic010已作分析,这里,主要关注semantic023的相关源代码实现。
程序4-32 semantic.cpp
第3行:获得用户自定义类型的名字。用户自定义类型的名字可以直接从单词流中获得。
第4~9行:两次检索类型信息表,判断该用户自定义类型的名字是否合法。程序设计语言关于符号的可见域、作用域都有严格的规定,而且有时可能是比较复杂的。例如,C语言允许声明代码块级的变量,而标准Pascal允许过程嵌套声明,这些情况将使可见域、作用域的分析变得复杂。由于Neo Pascal不支持过程嵌套声明,所以符号的可见域、作用域问题就变得相对简单。
第11行:这是一个重要的标志判断。处理集合类型声明时,由于集合类型的基类型是一个用户自定义类型,所以编译器需要对其作一定的合法性判断。例如:
【声明4-20】
根据Pascal语言的规定,这样的声明也是不正确的。编译器并不能因为声明形式的改变,而忽略合法性检查。
第13~24行:获得基类型的实际类型(非用户自定义类型)结点。任何用户自定义类型最终还将链接到实际类型结点。这个循环的目的就是过滤所有用户自定义类型的结点,直接获得实际类型的结点,便于后续合法性判断。
第26~40行:判定集合类型的基类型是否合法,若合法则构造类型链,否则报错。
第43、44行:处理其他引用用户自定义类型的声明,即生成类型结点,构造类型链。
应该注意的是,这个语义子程序并不适用于指针类型基类型的处理,根据前面所分析的例程,指针类型基类型的语义处理通常有一定的特殊性,即基类型名字的合法性是待事后判断的。
8.更多话题
至此,除了函数类型之外,笔者已经详细介绍了Neo Pascal各种类型的声明形式及其语义处理动作。在阅读源代码的过程中,读者难免会感到有点疑惑,这也并不足为奇。类型系统是高级程序设计语言有别于低级程序设计语言的重要标志之一,因此,它对高级程序设计语言的发展有着极其深远的影响。对于有志于学习程序设计语言及编译技术的读者而言,类型系统还是值得深入研究的。
学习本小节的关键在于理解类型链的概念及其构造方法。类型链并不是Neo Pascal所特有的产物,也不是笔者凭空想象出来的。类型链是一种比较通用的类型描述方法,得到了编译器设计者的普遍认可。当然,不同编译器的具体实现可能存在一定的差异,但是理解其概念与设计思想对于学习分析其他编译器是有一定帮助的。实际上,从访问效率及存储的角度而言,Neo Pascal的类型链、符号表的设计并不完美。例如,进一步可以为符号表设计一个索引,那样将大大提高检索的效率。有兴趣的读者完全可以自行对符号表结构及其操作稍加修改,实现这种索引机制。
还需指出一点,本书所讨论的类型仅仅是类型系统的具体实现而已。实际上,这里讨论的类型系统与计算机科学中的类型理论是有一定区别的。类型理论研究是程序设计语言领域的一个重要课题,它侧重于应用数学的方法从抽象层次上研究类型系统的设计、推理与实现。由于类型理论已经超出了本书讨论的范围,因此,不再深入讨论,笔者推荐两部著作供读者学习。在类型理论领域,Benjamin C.Pierce撰写的《Types and Programming Languages》和《Advanced Topics in Types and Programming Languages》是公认的经典著作,前者有中文译本《类型与程序设计语言》,而后者目前暂时没有中文译本。有兴趣的读者可以参考阅读。
免责声明:以上内容源自网络,版权归原作者所有,如有侵犯您的原创版权请告知,我们将尽快删除相关内容。