首页资源分类编程语言其他 > 编译原理及实践

编译原理及实践

已有 456766个资源

下载专区

上传者其他资源

文档信息举报收藏

标    签: 编译原理编译原理及实践中文

分    享:

文档简介

编译原理及实践 中文版

文档预览

第1章 概 论 • 为什么要用编译器 • 与编译器相关的程序 • 翻译步骤 • 编译器中的主要数据结构 本章要点 • 编译器结构中的其他问题 • 自举与移植 • TINY样本语言与编译器 • C-Minus:编译器项目的一种语言 编译器是将一种语言翻译为另一种语言的计算机程序。编译器将源程序( source language) 编写的程序作为输入,而产生用目标语言( target language)编写的等价程序。通常地,源程 序为高级语言( high-level language ),如 C或C++,而目标语言则是目标机器的目标代码 (object code,有时也称作机器代码( machine code)),也就是写在计算机机器指令中的用于运 行的代码。这一过程可以用下图表示: 源程序 → 编译器 → 目标程序 编译器是一种相当复杂的程序,其代码的长度可从 10 000行到1 000 000行不等。编写甚至 读懂这样的一个程序都非易事,大多数的计算机科学家和专业人员也从来没有编写过一个完整 的编译器。但是,几乎所有形式的计算均要用到编译器,而且任何一个与计算机打交道的专业 人员都应掌握编译器的基本结构和操作。除此之外,计算机应用程序中经常遇到的一个任务就 是命令解释程序和界面程序的开发,这比编译器要小,但使用的却是相同的技术。因此,掌握 这一技术具有非常大的实际意义。 也正因为这一点,本书不仅仅要讲解基础知识,还为读者提供了所有必要的工具和设计编 写真正的编译器的实践。要做到这些,就必须学习各项理论知识,而这主要应从自动机原理 (它使编译器结构合理)着手。在讲述时我们假设读者并不了解自动机原理。当然,此处的观 点与标准的自动机原理论著有所不同,这些论著特别强调编译过程;但是,学过自动机原理的 读者就会发现对这些理论材料很熟悉,这部分阅读起来也十分迅速。特别是对于那些十分了解 自动机原理背景的读者来说,对 2.2节、2.3节、2.4节和3.2节就不必细读了。无论怎样,读者都 应知道基本的数据结构和离散数学。机器结构和汇编语言的相关知识也很重要,在第 8章“代 码生成”中尤为如此。 实际编码技术的研究本身就要求认真规划,这是因为即使有很好的理论基础,编码的细节 也可能会复杂得令人不知如何操作。本书包括了有关程序设计语言结构的一系列简单示例,并 利用它们针对该项技术进行详细描述,讨论中使用到的语言被称作 TINY。此外,附录A还提供 了一个更广泛的示例,它包括了一个小小的但却非常复杂的适用于分类项目的 C子集(称作 CMinus)。本书还有大量的练习,这其中包括简单的笔头训练、文本中的代码扩充,以及更多的 相关编码练习。 总之,在编译器结构和被编译的程序设计语言的设计之间存在着一个很重要的交互。在本 书中,只是附带着讲解了一下语言设计问题,而是着重于程序设计语言的概念和设计问题(参 2 编译原理及实践 见本章最后的“注意与参考”部分)。 首先将简要地介绍编译器的历史及其存在目的与理由,以及与编译器相关的程序描述。接 着讲解编译器的结构、各种翻译过程和相关的数据结构,并联系一个简单的具体示例来示范这 个结构。最后,再概括地讲述一下编译器结构的其他问题,这包括自举和移植,以及本书后面 用到的主要语言的描述。 1.1 为什么要用编译器 在本世纪 40年代,由于冯·诺伊曼在存储 -程序计算机方面的先锋作用,编写一串代码或 程序已成必要,这样计算机就可以执行所需的计算。开始时,这些程序都是用机器语言 (machine language)编写的。机器语言就是表示机器实际操作的数字代码,例如: C7 06 0000 0002 表示在IBM PC 上使用的Intel 8x86处理器将数字2移至地址0000(16进制)的指令。当然,编写 这样的代码是十分费时和乏味的,这种代码形式很快就被汇编语言( assembly language)代替 了。在汇编语言中,都是以符号形式给出指令和存储地址的。例如,汇编语言指令 MOV X, 2 就与前面的机器指令等价(假设符号存储地址 X是0000)。汇编程序( assembler)将汇编语言 的符号代码和存储地址翻译成与机器语言相对应的数字代码。 汇编语言大大提高了编程的速度和准确度,人们至今仍在使用着它,在编码需要极快的速 度和极高的简洁程度时尤为如此。但是,汇编语言也有许多缺点:编写起来也不容易,阅读和 理解很难;而且汇编语言的编写严格依赖于特定的机器,所以为一台计算机编写的代码在应用 于另一台计算机时必须完全重写。很明显,发展编程技术的下一个重要步骤就是以一个更类似 于数学定义或自然语言的简洁形式来编写程序的操作,它应与任何机器都无关,而且也可由一 个程序翻译为可执行的代码。例如,前面的汇编语言代码可以写成一个简洁的与机器无关的形式 x=2 起初人们担心这是不可能的,或者即使可能,目标代码也会因效率不高而没有多大用处。 在1954年至1957年期间,IBM的John Backus带领的一个研究小组对 FORTRAN语言及其编 译器的开发,使得上面的担忧不必要了。但是,由于当时处理中所涉及到的大多数程序设计语 言的翻译并不为人所掌握,所以这个项目的成功也伴随着巨大的辛劳。 几乎与此同时,人们也在开发着第一个编译器, Noam Chomsky开始了他的自然语言结 构的研究。他的发现最终使得编译器结构异常简单,甚至还带有了一些自动化。 Chomsky的 研究导致了根据语言文法( grammar,指定其结构的规则)的难易程度以及识别它们所需的 算法来为语言分类。正如现在所称的 — 与乔姆斯基分类结构( Chomsky hierarchy)一样 — 包括了文法的 4个层次: 0型、1型、2型和3型文法,且其中的每一个都是其前者的专门化。 2 型(或上下文无关文法( context-free grammar ))被证明是程序设计语言中最有用的,而且 今天它已代表着程序设计语言结构的标准方式。分析问题( parsing problem,用于限定上下 文无关语言的识别的有效算法)的研究是在 60年代和 70年代,它相当完善地解决了这一问题, 现在它已是编译理论的一个标准部分。本书的第 3、4和5章将研究上下文无关的语言和分析 算法。 有穷自动机( finite automata)和正则表达式( regular expression)同上下文无关文法紧密 相关,它们与乔姆斯基的 3型文法相对应。对它们的研究与乔姆斯基的研究几乎同时开始,并 且引出了表示程序设计语言的单词(或称为记号)的符号方式。第 2章将讲述有穷自动机和正 3 第 1章 概 论 则表达式。 人们接着又深化了生成有效的目标代码的方法,这就是最初的编译器,它们被一直使用至 今。人们通常将其误称为优化技术( optimization technique),但因其从未真正地得到过被优化 了的目标代码而仅仅改进了它的有效性,因此实际上应称作代码改进技术( code improvement technique)。第8章将讲述该技术的基础知识。 当分析问题变得好懂起来时,人们就在开发程序上花费了很大的功夫来研究这一部分的编 译器的自动构造。这些程序最初被称为编译程序 -编译器,但更确切地应称为分析程序生成器 (parser generator),这是因为它们仅仅能够自动处理编译的一部分。这些程序中最著名的是 Yacc(yet another compiler-compiler),它是由Steve Johnson在1975年为Unix系统编写的,我们将 在第 5章中再次谈到它。类似地,有穷自动机的研究也发展了另一种称为扫描程序生成器 (scanner generator)的工具,Lex(与Yacc同时,由 Mike Lesk为Unix系统开发的)是这其中的 佼佼者。读者将在第 2章中学到 Lex。 在70年代后期和80年代早期,大量的项目都关注于编译器其他部分的生成自动化,这其中 就包括了代码生成。这些尝试并未取得多少成功,这大概是因为操作太复杂而人们又对其不甚 了解,本书也就不详细谈它了。 编译器设计最近的发展包括:首先,编译器包括了更为复杂的算法的应用程序,它用于推 断和 / 或简化程序中的信息;这又与更为复杂的程序设计语言(可允许此类分析)的发展结合 在一起。其中典型的有用于函数语言编译的 Hindley-Milner类型检查的统一算法。其次,编译 器已越来越成为基于窗口的交互开发环境( interactive development environment,IDE)的一部 分,它包括了编辑器、链接程序、调试程序以及项目管理程序。这样的 IDE的标准并没有多少, 但是已沿着这一方向对标准的窗口环境进行开发了。这一专题的研究超出了本书的范围(但是 读者可以参阅下一节中有关 IDE部件的内容)。读者可以参阅本章末尾的“注意与参考”中的 文献内容。尽管近年来对此进行了大量的研究,但是基本的编译器设计在近 20年中都没有多大 的改变,而且它们正迅速地成为计算机科学课程中的中心一环。 1.2 与编译器相关的程序 本节主要描述与编译器有关或专编译器一同使用的其他程序,以及那些在一个完整的语言 开发环境中与编译器一同使用的程序(有一些已在前面提到过)。 (1) 解释程序(interpreter) 解释程序是如同编译器的一种语言翻译程序。它与编译器的不同之处在于:它立即执行源 程序而不是生成在翻译完成之后才执行的目标代码。从原理上讲,任何程序设计语言都可被解 释或被编译,但是根据所使用的语言和翻译情况,很可能会选用解释程序而不用编译器。例如, 我们经常解释BASIC语言而不是去编译它。类似地,诸如 LISP的函数语言也常常是被解释的。 解释程序也经常用于教育和软件的开发,此处的程序很有可能被翻译若干次。而另一方面,当 执行的速度是最为重要的因素时就使用编译器,这是因为被编译的目标代码比被解释的源代码 要快得多,有时要快 10倍或更多。但是,解释程序具有许多与编译器共享的操作,而两者之间 也有一些混合之处。本书后面也将会提到解释程序,但重点仍是编译。 (2) 汇编程序(assembler) 汇编程序是用于特定计算机上的汇编语言的翻译程序。正如前面所提到的,汇编语言是计 算机的机器语言的符号形式,它极易翻译。有时,编译器会生成汇编语言以作为其目标语言, 然后再由一个汇编程序将它翻译成目标代码。 4 编译原理及实践 (3) 连接程序(linker) 编译器和汇编程序都经常依赖于连接程序,它将分别在不同的目标文件中编译或汇编的代 码收集到一个可直接执行的文件中。在这种情况下,目标代码,即还未被连接的机器代码,与 可执行的机器代码之间就有了区别。连接程序还连接目标程序和用于标准库函数的代码,以及 连接目标程序和由计算机的操作系统提供的资源(例如,存储分配程序及输入与输出设备)。 有趣的是,连接程序现在正在完成编译器最早的一个主要活动(这也是“编译”一词的用法, 即通过收集不同的来源来构造)。因为连接过程对操作系统和处理器有极大的依赖性,本书也 就不研究它了。我们也对不细分连接的目标代码和可执行的代码,这是因为对于编译技术而言, 这个区别并不重要。 (4) 装入程序(loader) 编译器、汇编程序或连接程序生成的代码经常还不完全适用或不能执行,但是它们的主要 存储器访问却可以在存储器的任何位置中且与一个不确定的起始位置相关。这样的代码被称为 是可重定位的( relocatable),而装入程序可处理所有的与指定的基地址或起始地址有关的可重 定位的地址。装入程序使得可执行代码更加灵活,但是装入处理通常是在后台(作为操作环境 的一部分)或与连接相联合时才发生,装入程序极少会是实际的独立程序。 (5) 预处理器(preprocessor) 预处理器是在真正的翻译开始之前由编译器调用的独立程序。预处理器可以删除注释、包 含其他文件以及执行宏(宏 macro是一段重复文字的简短描写)替代。预处理器可由语言(如 C)要求或以后作为提供额外功能(诸如为 FORTRAN提供Ratfor预处理器)的附加软件。 (6) 编辑器(editor) 编译器通常接受由任何生成标准文件(例如 ASCII文件)的编辑器编写的源程序。最近, 编译器已与另一个编辑器和其他程序捆绑进一个交互的开发环境—— IDE中。此时,尽管编辑 器仍然生成标准文件,但会转向正被讨论的程序设计语言的格式或结构。这样的编辑器称为基 于结构的( structure based),且它早已包括了编译器的某些操作;因此,程序员就会在程序的 编写时而不是在编译时就得知错误了。从编辑器中也可调用编译器以及与它共用的程序,这样 程序员无需离开编辑器就可执行程序。 (7) 调试程序(debugger) 调试程序是可在被编译了的程序中判定执行错误的程序,它也经常与编译器一起放在 IDE 中。运行一个带有调试程序的程序与直接执行不同,这是因为调试程序保存着所有的或大多数 源代码信息(诸如行数、变量名和过程)。它还可以在预先指定的位置(称为断点(breakpoint)) 暂停执行,并提供有关已调用的函数以及变量的当前值的信息。为了执行这些函数,编译器必 须为调试程序提供恰当的符号信息,而这有时却相当困难,尤其是在一个要优化目标代码的编 译器中。因此,调试又变成了一个编译问题,本书的内容就不涉及它了。 (8) 描述器(profiler) 描述器是在执行中搜集目标程序行为统计的程序。程序员特别感兴趣的统计是每一个过程 的调用次数和每一个过程执行时间所占的百分比。这样的统计对于帮助程序员提高程序的执 行速度极为有用。有时编译器也甚至无需程序员的干涉就可利用描述器的输出来自动改进目 标代码。 (9) 项目管理程序(project manager) 现在的软件项目通常大到需要由一组程序员来完成,这时对那些由不同人员操作的文件进 行整理就非常重要了,而这正是项目管理程序的任务。例如,项目管理程序应将由不同的程序 5 第 1章 概 论 员制作的文件的各个独立版本整理在一起,它还应保存一组文件的更改历史,这样就能维持一 个正在开发的程序的连贯版本了(这对那些由单个程序员管理的项目也很有用)。项目管理程 序的编写可与语言无关,但当其与编译器捆绑在一起时,它就可以保持有关特定的编译器和建 立一个完整的可执行程序的链接程序操作的信息。在 Unix系统中有两个流行的项目管理程序: sccs(source code control system)和rcs(revision control system)。 1.3 翻译步骤 编译器内部包括 了许多步骤或称 为阶段 源代码 (phase),它们执行不同的逻辑操作。将这些阶段 设想为编译器中一个个单独的片断是很有用的, 尽管在应用中它们是经常组合在一起的,但它们 扫描程序 确实是作为单独的代码操作来编写的。图 1-1是编 记号 译器中的阶段和与以下阶段(文字表、符号表和 错误处理器)或其中的一部分交互的 3个辅助部件。 这里只是简要地描述一下每个阶段,今后大家还 会更详细地学到它们(文字表和符号表在 1.4节中, 错误处理器在1.5节)。 语法分 析程序 语法树 (1) 扫描程序(scanner) 在这个阶段编译器实际阅读源程序(通常以 语义分 析程序 文字表 字 符 流 的 形 式 表 示 )。 扫 描 程 序 执 行 词 法 分 析 (Lexical analysis):它将字符序列收集到称作记号 (token)的有意义单元中,记号同自然语言,如英 语中的字词相似。因此可以认为扫描程序执行与 注释树 源代码 优化程序 符号表 错误处 理器 拼写相似的任务。 中间 代码 例如在下面的代码行(它可以是 C程序的一部 分)中: a [index] = 4 + 2 这个代码包括了 12个非空字符,但只有 8个 记号: a 标识符 [ 左括号 代码 生成器 目标 代码 目标代码 优化程序 index 标识符 ] 右括号 = 赋值 目标代码 4 数字 图1-1 编译器的阶段 + 加号 2 数字 每一个记号均由一个或多个字符组成,在进一步处理之前它已被收集在一个单元中。 扫描程序还可完成与识别记号一起执行的其他操作。例如,它可将标识符输入到符号表中, 将文字(literal)输入到文字表中(文字包括诸如 3.1415926535的数字常量,以及诸如“ Hello, w o r l d !”的引用字符串)。 (2) 语法分析程序(parser) 6 编译原理及实践 语法分析程序从扫描程序中获取记号形式的源代码,并完成定义程序结构的语法分析 (syntax analysis),这与自然语言中句子的语法分析类似。语法分析定义了程序的结构元素及 其关系。通常将语法分析的结果表示为分析树( parse tree)或语法树(syntax tree)。 例如,还是那行 C代码,它表示一个称为表达式的结构元素,该表达式是一个由左边为 下标表达式、右边为整型表达式的赋值表达式组成。这个结构可按下面的形式表示为一个分 析树: 请注意,分析树的内部节点均由其表示的结构名标示出,而分析树的叶子则表示输入中的 记号序列(结构名以不同字体表示以示与记号的区别)。 分析树对于显示程序的语法或程序元素很有帮助,但是对于表示该结构却显得力不从心了。 分析程序更趋向于生成语法树,语法树是分析树中所含信息的浓缩(有时因为语法树表示从分 析树中的进一步抽取,所以也被称为抽象的语法树( abstract syntax tree))。下面是一个 C赋值 语句的抽象语法树的例子: 请注意,在语法树中,许多节点(包括记号节点在内)已经消失。例如,如果知道表达式 是一个下标运算,则不再需要用括号“ [”和“]”来表示该操作是在原始输入中。 (3) 语义分析程序(semantic analyzer) 程序的语义就是它的“意思”,它与语法或结构不同。程序的语义确定程序的运行,但是 大多数的程序设计语言都具有在执行之前被确定而不易由语法表示和由分析程序分析的特征。 这些特征被称作静态语义( static semantic),而语义分析程序的任务就是分析这样的语义(程 序的“动态”语义具有只有在程序执行时才能确定的特性,由于编译器不能执行程序,所以它 不能由编译器来确定)。一般的程序设计语言的典型静态语义包括声明和类型检查。由语义分 析程序计算的额外信息(诸如数据类型)被称为属性( attribute),它们通常是作为注释或“装 饰”增加到树中(还可将属性添加到符号表中)。 在正运行的 C表达式 7 第 1章 概 论 a [index] = 4 + 2 中,该行分析之前收集的典型类型信息可能是: a是一个整型值的数组,它带有来自整型子范 围的下标; index则是一个整型变量。接着,语义分析程序将用所有的子表达式类型来标注语 法树,并检查赋值是否使这些类型有意义了,如若没有,则声明一个类型匹配错误。在上例中, 所有的类型均有意义,有关语法树的语义分析结果可用以下注释了的树来表示: (4) 源代码优化程序(source code optimizer) 编译器通常包括许多代码改进或优化步骤。绝大多数最早的优化步骤是在语义分析之后完 成的,而此时代码改进可能只依赖于源代码。这种可能性是通过将这一操作提供为编译过程中 的单独阶段指出的。每个编译器不论在已完成的优化种类方面还是在优化阶段的定位中都有很 大的差异。 在上例中,我们包括了一个源代码层次的优化机会,也就是:表达式 4+2可由编译器计算 先得到结果 6(这种优化称为常量合并( constant folding))。当然,还会有更复杂的情况(有 些将在第 8章中提到)。还是在上例中,通过将根节点右面的子树合并为它的常量值,这个优化 就可以直接在(注释)语法树上完成: 尽管许多优化可以直接在树上完成,但是在很多情况下,优化接近于汇编代码线性化形式 的树更为简便。这样节点的变形有许多,但是三元式代码( three-address code)(之所以这样称 呼是因为它在存储器中包含了 3个(或3个以上)位置的地址)却是标准选择。另一个常见的选 择是P-代码( P-code),它常用于 Pascal编译器中。 在前面的例子中,原先的 C表达式的三元式代码应是: t=4+2 a [ index] = t (请注意,这里利用了一个额外的临时变量 t 存放加法的中间值)。这样,优化程序就将这个代 码改进为两步。首先计算加法的结果: t=6 a [index] = t 8 编译原理及实践 接着,将t替换为该值以得到三元语句 a [index] = 6 图1-1已经指出源代码优化程序可能通过将其输出称为中间代码( intermediate code)来使 用三元式代码。中间代码一直是指一种位于源代码和目标代码(例如三元式代码或类似的线性 表示)之间的代码表示形式。但是,我们可以更概括地认为它是编译器使用的源代码的任何一 个内部表示。此时,也可将语法树称作中间代码,源代码优化程序则确实能继续在其输出中使 用这个表示。有时,这个中间代码也称作中间表示( intermediate representation, IR)。 (5) 代码生成器(code generator) 代码生成器得到中间代码( IR),并生成目标机器的代码。尽管大多数编译器直接生成目 标代码,但是为了便于理解,本书用汇编语言来编写目标代码。正是在编译的这个阶段中,目 标机器的特性成为了主要因素。当它存在于目标机器时,使用指令不仅是必须的而且数据的形 式表示也起着重要的作用。例如,整型数据类型的变量和浮点数据类型的变量在存储器中所占 的字节数或字数也很重要。 在上面的示例中,现在必须决定怎样存储整型数来为数组索引生成代码。例如,下面是所 给表达式的一个可能的样本代码序列(在假设的汇编语言中): MOV R0, index ;; value of index -> R0 MUL R0, 2 ;; double value in R0 MOV R1, &a ;; address of a -> R1 ADD R1, R0 ;; add R0 to R1 MOV *R1, 6 ;; constant 6 -> address in R1 在以上代码中,为编址模式使用了一个类似 C的协定,因此 &a是a的地址(也就是数组的 基地址),*R1则意味着间接寄存器地址(因此最后一条指令将值 6存放在 R1包含的地址中)。 这个代码还假设机器执行字节编址,并且整型数占据存储器的两个字节(所以在第 2条指令中 用2作为乘数)。 (6) 目标代码优化程序(target code optimizer) 在这个阶段中,编译器尝试着改进由代码生成器生成的目标代码。这种改进包括选择编址 模式以提高性能、将速度慢的指令更换成速度快的,以及删除多余的操作。 在上面给出的样本目标代码中,还可以做许多更改:在第 2条指令中,利用移位指令替代 乘法(通常地,乘法很费时间),还可以使用更有效的编址模式(例如用索引地址来执行数组 存储)。使用了这两种优化后,目标代码就变成: MOV R0, index ;; value of index -> R0 SHL R0 ;; double value in R0 MOV &a[R0], 6 ;; constant 6 -> address a + R0 到这里,对编译器阶段的简要描述就结束了,但我们还应特别强调这些讲述仅仅是示意性 的,也无需表示出正在工作中的编译器的实际结构。编译器在其结构细节上确实差别很大,然 而,上面讲到的阶段总会出现在几乎所有的编译器的某个形式上。 我们还谈到了用于维持每一个阶段所需信息的数据结构,例如语法树、中间代码(假设它 们并不相同)、文字表和符号表。下一节是编译器中的主要数据结构的概览。 1.4 编译器中的主要数据结构 当然,由编译器的阶段使用的算法与支持这些阶段的数据结构之间的交互是非常强大的。 编译器的编写者尽可能有效实施这些方法且不引起复杂性。理想的情况是:与程序大小成线性 9 第 1章 概 论 比例的时间内编译器,换言之就是,在 0(n)时间内, n是程序大小的度量(通常是字符数)。 本节将讲述一些主要的数据结构,它们是其操作部分阶段所需要的,并用来在阶段中交流信 息。 (1) 记号(token) 当扫描程序将字符收集到一个记号中时,它通常是以符号表示这个记号;这也就是说,作 为一个枚举数据类型的值来表示源程序的记号集。有时还必须保留字符串本身或由此派生出的 其他信息(例如:与标识符记号相关的名字或数字记号值)。在大多数语言中,扫描程序一次 只需要生成一个记号(这称为单符号先行( single symbol lookahead))。在这种情况下,可以用 全程变量放置记号信息;而在别的情况(最为明显的是 FORTRAN)下,则可能会需要一个记 号数组。 (2) 语法树(syntax tree) 如果分析程序确实生成了语法树,它的构造通常为基于指针的标准结构,在进行分析时动 态分配该结构,则整棵树可作为一个指向根节点的单个变量保存。结构中的每一个节点都是一 个记录,它的域表示由分析程序和之后的语义分析程序收集的信息。例如,一个表达式的数据 类型可作为表达式的语法树节点中的域。有时为了节省空间,这些域也是动态分配或存放在诸 如符号表的其他数据结构中,这样就可以有选择地进行分配和释放。实际上,根据它所表示的 语言结构的类型(例如:表达式节点对于语句节点或声明节点都有不同的要求),每一个语法 树节点本身都可能要求存储不同的属性。在这种情况下,可由不同的记录表示语法树中的每个 节点,每个节点类型只包含与本身相关的信息。 (3) 符号表(symbol table) 这个数据结构中的信息与标识符有关:函数、变量、常量以及数据类型。符号表几乎与编 译器的所有阶段交互:扫描程序、分析程序或将标识符输入到表格中的语义分析程序;语义分 析程序将增加数据类型和其他信息;优化阶段和代码生成阶段也将利用由符号表提供的信息选 出恰当的代码。因为对符号表的访问如此频繁,所以插入、删除和访问操作都必须比常规操作 更有效。尽管可以使用各种树的结构,但杂凑表却是达到这一要求的标准数据结构。有时在一 个列表或栈中可使用若干个表格。 (4) 常数表(literal table) 常数表的功能是存放在程序中用到的常量和字符串,因此快速插入和查找在常数表中也十 分重要。但是,在其中却无需删除,这是因为它的数据全程应用于程序而且常量或字符串在该 表中只出现一次。通过允许重复使用常量和字符串,常数表对于缩小程序在存储器中的大小显 得非常重要。在代码生成器中也需要常数表来构造用于常数和在目标代码文件中输入数据定义 的符号地址。 (5) 中间代码(intermediate code) 根据中间代码的类型(例如三元式代码和P-代码)和优化的类型,该代码可以是文本串 的数组、临时文本文件或是结构的连接列表。对于进行复杂优化的编译器,应特别注意选择允 许简单重组的表示。 (6) 临时文件(temporary file) 计算机过去一直未能在编译器时将整个程序保留在存储器中。这一问题已经通过使用临时 文件来保存翻译时中间步骤的结果或通过“匆忙地”编译(也就是只保留源程序早期部分的足 够信息用以处理翻译)解决了。存储器的限制现在也只是一个小问题了,现在可以将整个编译 单元放在存储器之中,特别是在可以分别编译的语言中时。但是偶尔还是会发现需要在某些运 10 编译原理及实践 行步骤中生成中间文件。其中典型的是代码生成时需要反填( backpatch)地址。例如,当翻译 如下的条件语句时 if x = 0 then ... else ... 在知道else部分代码的位置之前必须由文本跳到 else部分: CMP X, 0 JNE NEXT ;; location of NEXT not yet known < code for then-part > NEXT: < code for else-part > 通常,必须为 NEXT的值留出一个空格,一旦知道该值后就会将该空格填上,利用临时文 件可以很容易地做到这一点。 1.5 编译器结构中的其他问题 可从许多不同的角度来观察编译器的结构。 1.3节已讲述了它的阶段——用来表示编译器的 逻辑结构。此外,还有其他一些可能的观点:编译器的物理结构、操作的顺序等等。由于编译 器的结构对其可靠性、有效性、可用性以及可维护性都有很大的影响,所以编译器的编写者应 熟悉尽可能多的有关编译器结构的观点。本节将考虑编译器结构的其他方面以及每一个观点是 如何应用的。 源 代码 前端 中间 代码 后端 目标 代码 (1) 分析和综合 在这个观点中,已将分析源程序以计算其特性的编译器操作归为编译器的分析( analysis) 部分,而将生成翻译代码时所涉及到的操作称作编译器的综合( synthesis)部分。当然,词法 分析、语法分析和语义分析均属于分析部分,而代码生成却是综合部分。在优化步骤中,分析 和综合都有。分析正趋向于易懂和更具有数学性,而综合则要求更深的专业技术。因此,将分 析步骤和综合步骤两者区分开来以便发生变化时互不影响是很有用的。 (2) 前端和后端 本观点认为,将编译器分成了只依赖于源语言(前端( front end))的操作和只依赖于目 标语言(后端( back end))的操作两部分。这与将其分成分析和综合两部分是类似的:扫描 程序、分析程序和语义分析程序是前端,代码生成器是后端。但是一些优化分析可以依赖于目 标语言,这样就是属于后端了,然而中间代码的综合却经常与目标语言无关,因此也就属于前 端了。在理想情况下,编译器被严格地分成这两部分,而中间表示则作为其间的交流媒介。 这一结构对于编译器的可移植性( portability)十分重要,此时设计的编译器既能改变源 代码(它涉及到重写前端),又能改变目标代码(它还涉及到重写后端)。在实际中,这是很难 做到的,而且称作可移植的编译器仍旧依赖于源语言和目标语言。其部分原因是程序设计语言 和机器构造的快速发展以及根本性的变化,但是有效地保持移植一个新的目标语言所需的信息 或使数据结构普遍地适合改变为一个新的源语言所需的信息却十分困难。然而人们不断分离前 端和后端的努力会带来更方便的可移植性。 (3) 遍 编译器发现,在生成代码之前多次处理整个源程序很方便。这些重复就是遍( pass)。首 遍是从源中构造一个语法树或中间代码,在它之后的遍是由处理中间表示、向它增加信息、更 11 第 1章 概 论 换结构或生成不同的表示组成。遍可以和阶段相应,也可无关——遍中通常含有若干个阶段。 实际上,根据语言的不同,编译器可以是一遍( one pass)——所有的阶段由一遍完成,其结 果是编译得很好,但(通常)代码却不太有效。 Pascal语言和 C 语言均允许单遍编译。 (M o d u l a - 2语言的结构则要求编译器至少有两遍)。大多数带有优化的编译器都需要超过一遍: 典型的安排是将一遍用于扫描和分析,将另一遍用于语义分析和源代码层优化,第 3遍用于代 码生成和目标层的优化。更深层的优化则可能需要更多的遍: 5遍、6遍、甚至8遍都是可能的。 (4) 语言定义和编译器 我们注意到在 1.1节中,程序设计语言的词法和语法结构通常用形式的术语指定,并使用 正则表达式和上下文无关文法。但是,程序设计语言的语义通常仍然是由英语(或其他的自 然语言)描述的。这些描述(与形式的词法及语法结构一起)一般是集中在一个语言参考手 册(language reference manual)或语言定义( language definition)之中。因为编译器的编写 者掌握的技术对于语言的定义有很大的影响,所以在使用了一种新的语言之后,语言的定义 和编译器同时也能够得到开发。类似地,一种语言的定义对于构造编译器所需的技术也有很 大的关系。 编译器的编写者更经常遇到的情况是:正在实现的语言是众所周知的并已有了语言定义。 有时这个语言定义已达到了某个语言标准( language standard)的层次,语言标准是指得到诸 如美国国家标准协会( American National Standards Institute,ANSI)或国际标准化组织 (International Organization for Standardization,ISO)的官方标准组织批准的标准。FORTRAN、 Pascal和C语言就具有ANSI标准,Ada有一个通过了美国政府批准的标准。在这种情况下,编 译器的编写者必须解释语言的定义并执行符合语言定义的编译器。通常做到这一点并不容易, 但是有时由于有了标准测试程序集(测试组( test suite)),就能够测试编译器( Ada有这样一 个测试组),这又变得简单起来了。文本中使用的 TINY示范语言有其词法、语法和语义结构, 在2.5节、3.7节和6.5节中将分别谈到这些。附录 A包括了用于 C-Minus编译器项目语言的一个 最小的语言参考手册。 有时候,一种语言可从数学术语的形式定义( formal definition)中得到它的语义。现在人 们已经使用了许多方法,尽管一个称作表示语义( denotational semantics)的方法已经成为较 为常用的方法,在函数编程共同体中尤为如此,但现在仍然没有一种可成为标准的方法。当语 言有一个形式定义时,那么在理论上就有可能给出编译器与该定义一致的数学证明,但是由于 这太难了而几乎从未有人做过。无论怎样,该技术已超出了本书的范围,本书也不会涉及到形 式语义方面的知识。 运行时环境的结构和行为是尤其受到语言定义影响的编译器构造的一个方面。运行时环境 将在第7章中学习。尽管此时它没有多大用处,但程序设计语言所允许的数据结构(尤其是被 许可的函数调用和返回值的类型)对于运行时系统的复杂程度具有决定性意义。以下是运行时 环境的3个基本类型(按难易程度排列): 首先是 FORTRAN77 ,它没有指针或动态分配,也没有递归函数调用,但它允许有一个完 整的静态运行时环境。在这个环境中,所有存储器的分配都在执行之前进行。因为无需生成代 码来维护环境,编译器的编写者的分配工作也就容易许多了。其次是 Pascal、C和其他类似 Algol的语言,它们允许有限动态分配以及递归函数的调用,并且要求“半动态”或带有额外 的动态结构(称为堆,由此程序员可安排动态分配)的基于栈的运行时环境。最后是面向对象 的函数语言,如 LISP和Smalltalk,它们要求“完全动态”的环境,在其中所有的分配都是由编 译器的生成代码自动完成的。因为它要求也能够自动释放存储器,而这又相应地要求复杂的 12 编译原理及实践 “垃圾回收”算法,所以它很复杂。在学习运行时环境时将会讨论到它,但是更为复杂的内容 就超出本书的范围了。 (5) 编译器的选项和界面 编译器结构的一个重要方面是包含了一种机制与操作系统相连接,并为了满足用户的各种 目的而提供选择。界面机制的例子是提供对目标机器的文件系统的访问以及输入和输出功能。 用户的选项可包括列表特征(长度、出错信息和相互对照表)的说明和代码优化选项(只是某 个优化的执行)。选项和界面共称为编译器的语用学( pragmatics)。有时一种语言的定义指出 必须提供的语用学。例如, Pascal和C语言均指出一定的输入 /输出过程(在 Pascal中,它们是 语言特性中的一部分;而在 C语言中,它们是标准库说明的一个部分)。在Ada中,许多编译器 的指示(称为( pragmas))则是语言定义的一部分。例如: Ada语句 pragma LIST(ON); ... pragma LIST(OFF); 为包含在pragmas中的程序部分生成了一个编译器列表。在这个文本中,我们发现编译器指 示仅存在于用作编译器调试的生成列表信息的上下文中;另外,也不会在输入 /输出和操作系统 界面中处理问题,这是因为它们涉及到了大量的细节,而且随操作系统的不同而有很大差异。 (6) 出错处理 编译器的一个最为重要的功能是其对源程序中错误的反应。几乎在编译的每一个阶段中都 可以诊察出错误来。这些静态(或称为编译时( compile-time))的错误( static error)必须由 编译器来报告,而编译器能够生成有意义的出错信息并在每一个错误之后恢复编译是非常重要 的。编译器的每一个阶段都需要一个类型略为不同的出错处理,因此错误处理器( error handler)必须包括不同的操作,每个操作都给出指定的阶段和结构。因此,读者将在相应的章 节中学到每一个阶段的出错处理技术。 语言定义经常要求编译器不仅能够找到静态错误,而且还能找到执行错误。这就需要编译 器生成额外的代码,该代码将执行恰当的运行时测试,以保证所有这样的错误都将在执行时引 起一个合适的事件。在此类事件中,最简单的就是中止程序的执行。但这经常是不合适的,而 且语言的定义可能要求存在异常处理( exception handling)机制。这将使运行时系统的管理变 得非常复杂,当程序可能由错误发生处继续执行时尤其如此。本书并不涉及到这样一个机制的 执行情况,但会提到编译器如何生成测试代码,以保证指定的运行时错误引起执行中止。 1.6 自举与移植 前面已经讨论过源语言和目标语言在编译器结构中的决定因素,以及将源语言和目标语言 分为前端和后端的作用,但是却未提到过编译器构造过程中涉及到的另一个语言:编写编译器 本身使用的语言。为了使编译器能立即执行,这个执行(或宿主 (host))语言只能是机器语言。 当时并没有编译器,因此这确实是最初的编译器编写所用的语言。现在更为合理的方法是用另 一种语言来编写编译器,而使用该种语言的编译器早已存在了。如果现存的编译器已经在目标 机器上运行了,则只需利用现存的编译器编译出新的编译器以得到可运行的程序: 用语言B编写 语言A的编译器 语言B已存 在的编译器 语言A正运 行的编译器 当语言B的现存编译器没有运行在目标机器上时,情况会更复杂一些。编译将产生一个交叉编 13 第 1章 概 论 译器(cross compiler),也就是一个为不同于它在运行之上的机器生成目标代码的编译器。这 种以及其他更为复杂的情况最好通过将编译器画成一个 T型图(T-diagram)(以其形状来命名) 来描述。用语言H(代表宿主语言)编写的编译器将语言 S(代表源语言)翻译为语言 T(代表 目标语言)可画成以下的 T型图: 请注意,这与表示编译器是在“机器” H上运行是等价的(如果 H不是机器代码,则可认 为其是一个假定机器的可执行代码)。我们一般都希望 H与T相同(也就是编译器为与之运行之 上一样的机器生成代码),但是也并不是必须这样做。 可以用两种方法组合 T型图。一种是,如果在一台机器 H上运行有两个编译器,其中一个 编译器将语言A翻译为语言B,另一个将语言 B翻译为语言 C,就可按照将第 1个的输出作为第 2 个的输入来组合。其所得结果就是一个在机器 H上的由A到C的编译。将该过程表示为: 另一种是,利用由“机器” H到“机器”K的编译器来翻译由 H到K的其他编译器的执行语 言。表示如下: 在上面的描述中,第 1个假定是,在机器 H上利用语言 B现存的编译器将语言 A翻译为用B 编写的语言 H。它是前面所讲的特例,如下所示: 第2个假定是,当语言 B的编译器运行在另一台机器上时,就会引出语言 A的交叉编译器。 如下图所示: 以将要编译的相同语言编写一个编译器是很普通的: 14 编译原理及实践 用语言A自身 编写的编译器 编译器的最终版本 用机器语言写的“虽快但不佳”的编译器 a) 目标重定位为K 的编译器源代码 原始编译器 a) 交叉编译器 用语言A自身 编写的编译器 编译器的最终版本 正运行但效率低的编译器(从第一步得来作) b) 图1-2 自举进程 a) 自举进程中的第1个步骤 b) 自举进程中的第2个步骤 目标重定位为K 的编译器源代码 交叉编译器 目标重定位后的编译器 b) 图1-3 移植一个在其自身源代码中编写的编译器 a) 步骤1 b) 步骤2 但这将表现为一个循环错误:因为如果源语言的编译器不存在,那么编译器本身也就不可 能被编译了。从这个方法中可以得到很重要的启示。 让我们设想一个问题:如何解决循环。我们可以在汇编语言中编写一个“虽快但不佳”的 编译器,并翻译那些在编译器中真正使用得到的语言特征(当然,在编写“较好的”编译器时, 会对使用那些特征有所限制)。这些“虽快但不佳”的编译器也可能产生极为无效的代码(它 仅需要正确而已!)。一旦运行这个“虽快但不佳”的编译器,就可用它来编译那个“较好的” 编译器。接着,对“较好的”编译器进行重编译以得到最终的版本。人们将这个过程称为自举 (bootstrapping)。图1-2a 和图1-2b 描述了这一过程。 自举之后,在源代码和执行代码中就有了一个编译器。这样做的好处在于:通过应用与 前面相同的两步过程,编译器的源代码的任何改进都会立即被自举到一个正在工作着的编译 器中。 除此之外,还有一个好处。现在将编译器移植到一个新的主机,只要求重写源代码的后端 来生成新机器的代码。接着用旧的编译器来编译它以生成一个交叉编译器,该编译器又再次被 交叉编译器重新编译,以得到新机器的工作版本。图 1-3a和图1-3b描述了这一过程。 1.7 TINY样本语言与编译器 任何一本关于编译结构的书如果不包括编译过程步骤的示例就不能算完整。本书将会多次 用从现有的语言(如 C、C++、Pascal和Ada)中抽取的实例来讲解。但是仅用这些实例来描述 编译器的各个部分是如何协调一致的却不够。因此,写出一个完整的编译器并对其操作进行注 释仍是很必要的。 描述真实的编译器非常困难。“真正的”编译器——也就是希望在每天编程中用到的—— 内容太复杂而且不易在本教材中掌握。另一方面,一种很小的语言(其列表包括 10页左右的文 本)的编译也不可能准确地描述出“真正的”编译器所需的所有特征。 为了解决上述问题,人们在( ANSI)C中为小型语言提供了完整的源代码,一旦能明白这 15 第 1章 概 论 种技术,就能够很容易地理解这种小型语言的编译器了。这种语言称作 TINY,在每一章的示 例中都会用到它,它的编译代码也很快会被提到。完整的编译代码汇集在附录 B中。 还有一个问题是:选择哪一种机器语言作为 TINY编译器的目标语言?为现有的处理器使 用真正的机器代码的复杂性使得这个选择很困难。但是选择特定的处理器也将影响到执行这些 机器生成的目标代码。相反地,可将目标代码简化为一个假定的简单处理器的汇编语言,这个 处理器称为TM机(tiny machine)。在这里只是简单地谈谈,详细内容将放在第 8章(代码生成) 中。附录 C有C的TM 模拟程序列表。 1.7.1 TINY 语言 TINY的程序结构很简单,它在语法上与 Ada或Pascal的语法相似:仅是一个由分号分隔开 的语句序列。另外,它既无过程也无声明。所有的变量都是整型变量,通过对其赋值可较轻易 地声明变量(类似 FORTRAN或BASIC)。它只有两个控制语句: if语句和repeat语句,这两个 控制语句本身也可包含语句序列。 If语句有一个可选的 else部分且必须由关键字 end结束。除此 之外,read语句和write语句完成输入/输出。在花括号中可以有注释,但注释不能嵌套。 T I N Y的表达式也局限于布尔表达式和整型算术表达式。布尔表达式由对两个算术表达式 的比较组成,该比较使用 <与=比较算符。算术表达式可以包括整型常数、变量、参数以及 4个 整型算符 +、-、*、/,此外还有一般的数学属性。布尔表达式可能只作为测试出现在控制语 句中——而没有布尔型变量、赋值或 I/O。 程序清单1-1是该语言中的一个阶乘函数的简单编程示例。这个例子在整本书中都会用到。 程序清单1-1 一个输出其输入阶乘的 TINY语言程序 虽然 T I N Y缺少真正程序设计语言所需要的许多特征——过程、数组且浮点值是一些较大 的省略——但它足可以用来例证编译器的主要特征了。 1.7.2 TINY编译器 TINY编译器包括以下的 C文件,(为了包含而)把它的头文件放在左边,它的代码文件放 在右边: globals.h util.h scan.h parse.h main.c util.c scan.c parse.c 16 编译原理及实践 symtab.h symtab.c analyze.h analyze.c code.h code.c cgen.h cgen.c 除了将main.c 放在globals.h 的前面之外,这些文件的源代码及其行号都按顺序列在附录 B中了。任何代码文件都包含了 globals.h 头文件,它包括了数据类型的定义和整个编译器 均使用的全程变量。 main.c 文件包括运行编译器的主程序,它还分配和初始化全程变量。其 他的文件则包含了头 /代码文件对、在头文件中给出了外部可用的函数原型以及在相关代码文 件中的实现(包括静态局部函数)。scan、parse、analyze 和cgen 文件与图1-1中的扫描 程序、分析程序、语义分析程序和代码生成器各阶段完全相符。 util 文件包括了实用程序函 数,生成源代码(语法树)的内部表示和显示列表与出错信息均需要这些函数。 symtab 文件 包括执行与 TINY应用相符的符号表的杂凑表。 code文件包括用于依赖目标机器(将在 1.7.3节 描述的TM机)的代码生成的实用程序。图 1-1还缺少一些其他部分:没有单独的错误处理器或 文字表且没有优化阶段;没有从语法树上分隔出来的中间代码;另外,符号表只与语义分析程 序和代码生成器交互(这将在第 6章中再次讨论到)。 虽然这些文件中的交互少了,但是编译器仍有 4遍:第1遍由构造语法树的扫描程序和分析 程序组成;第 2遍和第3遍执行语义分析,其中第 2遍构造符号表而第3遍完成类型检查;最后一 遍是代码生成器。在 main.c中驱动这些遍的代码十分简单。当忽略了标记和编辑时,它的中 心代码如下(请参看附录 B中的第69、77、79和94行): syntaxTree = parse( ); buildSymtab (syntaxTree); typeCheck (syntaxTree); codeGen (syntaxTree, codefile); 为了灵活起见,我们还编写了条件编译标志,以使得有可能创建出一部分的编译器。如下是该 标志及其效果: 标志 NO_PARSE NO_ANALYZE NO_CODE 设置效果 创创建只扫描的编译器 创创建只分析和扫描的 编译器 创创建执行语义分析, 但不生成代码的编译器 编译所需文件(附加) globals.h, main.c, util.h, util.c, scan.h, scan.c parse.h, parse.c symtab.h, symtab.c, analyze.h,analyze.c 尽管这个 TINY编译器设计得有些不太实际,但却有单个文件与阶段基本一致的好处,在 以后的章节中将会一个一个地学到这些文件。 任何一个 ANSI C编译器都可编译 TINY编译器。假定可执行文件是 tiny,通过使用以下 命令: tiny sample. tny 就可用它编译文本文件 sample.tny 中的TINY源程序。(如果省略了 .tny,则编译器会 自己添加 .tny后缀)。屏幕上将会出现一个程序列表(它可被重定向到一个文件之上)并且 (如当代码生成是被激活的)生成目标代码文件 sample.tm(在下面将谈到的TM机中使用)。 17 第 1章 概 论 在编辑列表的信息中有若干选项,以下的标志均可用: 标志 EchoSource TraceScan TraceParse TraceAnalyze TraceCode 设置效果 将TINY源程序回显到带有行号的列表 当扫描程序识别出记号时,就显示每个记号的信息 将语法树以线性化格式显示 显示符号表和类型检查的小结信息 打印有关代码文件的代码生成跟踪注释 1.7.3 TM 机 我们用该机器的汇编语言作为 TINY编译器的目标语言。 TM机的指令仅够作为诸如 TINY 这样的小型语言的目标。实际上,TM 具有精减指令集计算机( RISC)的一些特性。在RISC中, 所有的算法和测试均须在寄存器中进行,而且地址模式极为有限。为了使读者了解到该机的简 便之处,我们将下面 C表达式的代码 a [index] = 6 翻译成TM汇编语言(请读者将它与 1.3节中相同语句假定的汇编语言比较一下): LDC 1, 0 ( 0 ) load o into reg 1 * 下面指令 * 假设index 在存储器地址 10中 LD 0, 10 ( 1 ) load val at (10+R1 into R0 LDC 1, 2 ( 0 ) load 2 into reg 1 MUL 0, 1, 0 put R1 * R0 into R0 LDC 1, 0 ( 0 ) load 0 into reg 1 * 下面指令 * 假设a在存储器地址 20中 LDA 1, 20 ( 1 ) load 20 + R1 into R0 ADD 0, 1, 0 put R1 + R0 into R0 LDC 1, 6 ( 0 ) load 6 into reg 1 ST 1, 0 ( 0 ) store R1 at 0 + R0 我们注意到装入操作中有 3个地址模式并且是由不同的指令给出的: LDC是“装入常量”, LD是“由存储器装入”,而LDA是“装入地址”。另外,该地址通常必须给成“寄存器 +偏差” 值。例如“ 10(1)”(上面代码的第 2条指令),它代表在将偏差10加到寄存器1的内容中计算该 地址。(因为在前面的指令中, 0已被装入到寄存器1中,这实际是指绝对位置 10) 。我们还看 到算术指令 MUL和ADD可以是“三元”指令且只有寄存器操作数,其中可以单独确定结果的目 标寄存器( 1.3节中的代码与此相反,其操作是“二元的”)。 TM机的模拟程序直接从一个文件中读取汇编代码并执行它,因此应避免将由汇编语言翻 译为机器代码的过程复杂化。但是,这个模拟程序并非是一个真正的汇编程序,它没有符号地 址或标号。因此, TINY编译器必须仍然计算跳转的绝对地址。此外为了避免与外部的输入 /输 出例程连接的复杂性,TM机有内部整型的 I/O设备;在模拟时,它们都对标准设备读写。 LDC命令也要求一个“寄存器 +偏差”的格式;但由于 TM汇编程序格式简单统一,也就忽略了寄存器,偏差 本身也作为常量装入。 18 编译原理及实践 通过使用任何一个 ANSI C编译器,都可将tm.c 源代码编译成TM模拟程序。假定它的可 执行文件叫作 tm,通过发出命令 tm sample.tm 就可使用它了。其中, sample. tm是TIMY编译器由sample. tny源文件生成的代码文件。该命令 引起代码文件的汇编和装入,接着就可交互地运行 TM模拟程序了。例如:如果 sample. tny是 程序清单1-1中的范例程序,则按以下计算就可得到 7的阶乘: tm sample. tm TM simulation (enter h for help) ... Enter command: go Enter value for IN instruction: 7 OUT instruction prints: 5040 HALT: 0, 0, 0 Halted Enter command: quit Simulation done. 1.8 C-Minus:编译器项目的一种语言 附录A将描述一个比TINY更大的适用于编译器项目的语言。由于它受到 C子集的严格限制, 因此就称作 C-Minus。它包括整型变量、整型数组以及函数(包含过程或空函数);它还有局 部、全局(静态)说明和(简单)递归函数,此外还有 if语句和while语句;除此之外几乎什么 也没有了。程序由函数序列和变量说明序列组成。最后必须是一个 main函数,执行则由一个 对main的调用开始 。 程序清单 1-2是在C-Minus中的一个程序示例,在其中通过递归函数编写了程序清单 1-1中 的阶乘程序。该程序的输入 /输出由一个 read函数提供,此外还有一个根据标准的 C函数 scanf和printf定义的write函数。 程序清单1-2 一个以其输入的阶乘为输出的 C-Minus程序 C-Minus是一个比 TINY复杂的语言,在它的代码生成的要求中尤其如此;但是, TM机仍 是其编译器合理的目标机器。附录 A中有关于如何修改和扩充TINY编译器到C-Minus的内容。 为了与C-Minus的其他函数一致, main被说明为一个带有 void参数列表的 void函数。尽管这与 ANSI C不同, 但编译器还是接受了这种表示。 19 第 1章 概 论 练习 1.1 从开发环境中找一个熟悉的编译器,并列出所有的相关程序,这些相关程序适用于与 编译器一同进行操作,该编译器带有对其函数的简要描述。 1.2 下面是C中的赋值 a[i+1]=a[i]+2 利用1.3节中的类似例子为参考,画出这个表达式的分析树和语法树。 1.3 编译错误大致可分为两类:语法错误和语义错误。语法错误包括丢失记号和记号放置 错误,例如算术表达式( 2+3,就缺少了右括号。语义错误包括表达式中错误的类型 及未说明的变量(在大多数语言中),例如赋值x=2,其中x 是一个数组变量。 a. 在你所选语言的每一个类型的错误中,再举出两个例子。 b. 找出一个你所熟悉的编译器,并判断它是在语义错误之前列出了所有的语法错误还 是混合了语法和语义错误。这和编译的遍数有什么关系? 1.4 这个问题假设你有一个编译器,它有一个产生汇编语言输出的选项。 a. 判断你的编译器是否完成常量的合并优化。 b. 一个相关的但更先进的优化是常量传送的优化:当变量在表达式中有一个常量值时, 它就由该值替代。例如,代码(在 C语法中): x = 4; y = x + 2; 通过常量传送(和常量合并)就变成代码: x = 4; y = 6; 判断你的编译器是否进行了常量的传送。 c. 为什么常量传送比常量合并更难,请说出尽可能多的理由。 d. 与常量传送和常量合并相关的情况是程序中被命名的常量的用法。利用命名了的常 量x代替一个变量,就可将上例翻译为以下的代码: const int x = 4; ... y = x + 2; ... 判断你的编译器是否在这些情况下执行传送 /合并,这与b有何不同? 1.5 若你的编译器由键盘直接接受输入,则判断编译器是在生成出错信息之前读取整个程 序还是在遇到它们时生成出错信息。这和编译的遍数有什么关系? 1.6 描述由以下程序完成的任务,并解释它们怎样与编译器相似或相关: a. 语言预处理器 b. 格式打印程序 c. 文本格式化程序 1.7 假设有一个用 C编写的由 Pascal到C的翻译程序以及一个可运行的 C编译器。请利用 T 型图来描述创建可运行的 Pascal编译器的步骤。 1.8 我们已用箭头符 表示出将一个有两个 T型图的格式变成只有一个 T型图的格式。可 将这个箭头符认为是一个“归约相关”,并构成它的传递闭包 *,在其中允许有归 约序列发生。下面给出了一个 T型图,其中字母代表任意语言。请判断哪些语言必须 20 编译原理及实践 相等才能使归约有效,并且显示使它有效的单个归约步骤: 请给出一个该图所描述归约的实例。 1.9 在1.6节的图1-3中移植编译器的另一种方法是:对编译器生成的中间代码使用解释程 序,并完全去掉后端。 Pascal P-system使用的就是这种方法, Pascal P-system包括了 生成P-代码的 Pascal编译器、“一般的”栈式机器的汇编代码,以及模拟执行 P-代码 的P-代码解释程序。 Pascal编译器和P-代码解释程序均用P-代码编写。 a. 假设有一个Pascal P-system,请描述在任一台机器上得到可运行的 Pascal编译器的 步骤。 b. 描述在 (a)中从你的系统得到一个可运行的本机代码编译器必需的步骤(也就是, 为主机生成可执行代码的编译器,但不使用 P-代码解释程序)。 1.10 可以认为移植编译器的过程有两个不同的操作:重置目标( retargeting)(为新机器 修改编译器以生成目标代码)和重置主机( rehosting)(修改编译程序以在新机器上 运行)。请根据 T型图讨论两个操作的不同之处。 注意与参考 本章所讲到的大部分问题都将在以后各章中更加详细地再次提到,并且在以后的“注意与 参考”中也会给出恰当的参考。例如,第 2章会谈到Lex,第5章中则是Yacc,第6章中是类型检 查、符号表和属性分析,第 8章中则为代码生成、三元式代码和 P-代码,第 4章和第5章会研究 错误处理。 Aho [1986] 是编译器的一个标准的综合参考,尤其是在理论和算法方面。 Fischer and LeBlanc [1991]中有许多有用的实行提示。在 Fraser and Hanson [1995]和Holub [1990]中可找到 对C编译器的完整描述。 Gnu编译器是一个源代码在 Internet上广泛应用的流行的 C/C++编译器, Stallman [1994]详细描述了它。 如要了解程序设计语言的概念以及其与翻译程序相互影响的资料,可参考 Louden [1993]或 Sethi [1996]。 Hopcroft and Ullman [1979]对来自数学观点(不同于此处的实用观点)的自动机理论很有 用。在这里还可以找到更多的有关乔姆斯基分类的内容(第 3章中也会提到)。 Backus [1957]和Backus [1981]中有对早期FORTRAN编译器的描述。在Randell and Russell [1964]中有对早期的 Algo160编译器的描述。 Barron[1981]描述了Pascal编译器,在这里还提到 了Pascal P-system(Nori [1981])。 Kernighan [1975]中有1.2节中提到的Ratfor预处理器,Bratman [1961]介绍了1.6节的T型图。 本书着重于对大多数语言的翻译中有用的标准翻译技术。要对在基于 Algol命令语言的主 要传统语言之外的语言进行有效的翻译可能还需要其他技术。特别地,用于诸如 ML和Haskell 的函数语言翻译已经发展了许多新技术,其中一些可能会成为将来重要的通用技术。 Appel [1992]、Peyton Jones [1992]和Peyton Jones [1987]均提到了这些技术。Peyton Jones [1987]还描 述了 Hindley-Milner类型检查( 1.1节中已讲到过)。 第2章 词 法 分 析 • 扫描处理 • 正则表达式 • 有穷自动机 本章要点 • 从正则表达式到 DFA • TINY扫描程序的实现 • 利用Lex自动生成扫描程序 编译器的扫描或词法分析( lexical analysis)阶段可将源程序读作字符文件并将其分为若 干个记号。记号与自然语言中的单词类似:每一个记号都是表示源程序中信息单元的字符序列。 典型的有:关键字( keyword),例如if和while,它们是字母的固定串;标识符( identifier) 是由用户定义的串,它们通常由字母和数字组成并由一个字母开头;特殊符号(special symbol) 如算术符号+和*、一些多字符符号,如> = 和< >。在各种情况中,记号都表示由扫描程序从剩 余的输入字符的开头识别或匹配的某种字符格式。 由于扫描程序的任务是格式匹配的一种特殊情况,所以需要研究在扫描过程中的格式说明 和识别方法,其中最主要的是正则表达式(regular expression)和有穷自动机(finite automata)。 但是,扫描程序也是处理源代码输入的编译器部分,而且由于这个输入经常需要非常多的额 外时间,扫描程序的操作也就必须尽可能地高效了。因此还需十分注意扫描程序结构的实际 细节。 扫描程序问题的研究可分为以下几个部分:首先,给出扫描程序操作的一个概貌以及所涉 及到的结构和概念。其次是学习正则表达式,它是用于表示构成程序设计语言的词法结构的串 格式的标准表示法。接着是有穷状态机器或称有穷自动机,它是对由正则表达式给出的串格式 的识别算法。此外还研究用正则表达式构造有穷自动机的过程。之后再讨论当有穷自动机表示 识别过程时,如何实际编写执行该过程的程序。另外还有 TINY语言扫描程序的一个完整的实 现过程。最后再看到自动产生扫描器生成器的过程和方法,并使用 Lex再次实现TINY的扫描程 序,它是适用于Unix和其他系统的标准扫描生成器。 2.1 扫描处理 扫描程序的任务是从源代码中读取字符并形成由编译器的以后部分(通常是分析程序)处 理的逻辑单元。由扫描程序生成的逻辑单元称作记号( token),将字符组合成记号与在一个英 语句子中将字母构成单词并确定单词的含义很相像。此时它的任务很像拼写。 记号通常定义为枚举类型的逻辑项。例如,记号在 C中可被定义为 : L在一种没有列举类型的语言中,则只能将记号直接定义为符号数值。因此在老版本的 C中有时可看到: #define #define #define ... IF 256 THEN 257 ELSE 258 (这些数之所以是从 256开始是为了避免与ASCII的数值混淆。) 22 编译原理及实践 typedef enum { I F , T H E N , E L S,EP L U S , M I N U S , N U M , I D , . . . } TokenType; 记号有若干种类型,这其中包括了保留字( reserved word),如IF和THEN,它们表示字符 串“ if”和“ then ”;第 2类是特殊符号( special symbol ),如算术符号加( PLUS)和减 (MINUS),它们表示字符“ +”和“-”。第3类是表示多字符串的记号,如 NUM和ID,它们分 别表示数字和标识符。 作为逻辑项的记号必须与它们所表示的字符串完全区分开来。例如:保留字记号 IF须与 它表示的两个字符“ if”的串相区别。为了使这个区别更明显,由记号表示的字符串有时称作 它的串值( string value)或它的词义( lexeme)。某些记号只有一个词义:保留字就具有这个 特性。但记号还可能表示无限多个语义。例如标识符全由单个记号 ID表示,然而标识符有许 多不同的串值来表示它们的单个名字。因为编译器必须掌握它们在符号表中的情况,所以不能 忽略这些名字。因此,扫描程序也需用至少一些记号来构造串值。任何与记号相关的值都是记 号的属性( attribute),而串值就是属性的示例。记号还可有其他的属性。例如, NUM记号可有 一个诸如“32767”的串值属性,它是由 5个数字字符组成,但它还会有一个由其值计算所得的 真实值32767组成的数字值属性。在诸如 PLUS这样的特殊符号记号中,不仅有串值“ +”还有 与之相关的真实算术操作 +。实际上,记号符号本身就可看作是简单的其他属性,而记号就是 它所有属性的总和。 为了后面的处理,扫描程序要求至少具有与记号所需相等的属性。例如要计算 NUM记号的 串值,但由于从它的串值就可计算,因此也就无需立刻计算它的数字值了。另一方面,如果计 算它的数字值,就会丢弃掉它的串值。有时扫描程序本身会完成在恰当位置记录属性所需的操 作,或直接将属性传到编译器后面的阶段。例如,扫描程序能利用标识符的串值将其输入到符 号表中,或在后面传送它。 由于扫描程序必须计算每一个记号的若干属性,所以将所有的属性收集到一个单独构造的 数据类型中是很有用的,这种数据类型称作记号记录( token record)。可在C中将这样的记录 说明为: typedef struct { TokenType tokenval; char * stringval; int numval; } TokenRecord; 或可能作为一个联合 typedef struct { TokenType tokenval; union { char * stringval; int numval; } attribute; } TokenRecord; (以上假设只有标识符需要串值属性,只有数字需要数值属性)。对于扫描程序一个更普通 的安排是只返回记号值并将变量的其他属性放在编译器的其他部分访问得到的地方。 尽管扫描程序的任务是将整个源程序转换为记号序列,但扫描程序却很少一次性地完成它。 实际上,扫描程序是在分析程序的控制下进行操作的,它通过函数从输入中返回有关命令的下 23 第 2章 词 法 分 析 一个记号,该函数有与 C说明相似的说明: TokenType getToken( void ); 这个方式中声明的 getToken函数在调用时从输入中返回下一个记号,并计算诸如记号串值这 样的附加属性。输入字符串通常并不给这个函数提供参数,但参数却被保存在缓冲区中或由系 统输入设备提供。 请考虑在getToken的操作示例中以下的C源代码行,在第 1章中已用到过它: a [ index ] = 4 + 2 假定将这个代码行放在一个输入缓冲区中,它的下一个输入字符由箭头指出,如下所示: 一个对getToken的调用现在需要跳过下面的 4个空格,识别由单个字符a 组成的串“a”,并返 回记号值 ID作为下个记号,此时的输入缓冲区如下所示: 因此,getToken随后的调用将再次从左括号字符开始识别过程。 现在开始研究在字符串中定义和识别格式的方法。 2.2 正则表达式 正则表达式表示字符串的格式。正则表达式 r完全由它所匹配的串集来定义。这个集合称 为由正则表达式生成的语言( language generated by the regular expression),写作L(r)。此处 的语言只表示“串的集合”,它与程序设计语言并无特殊关系(至少在此处是这样的)。该语言 首先依赖于适用的字符集,它一般是 ASCII字符的集合或它的某个子集。有时该集比 ASCII字 符的集合更普通一些,此处集合的元素称作符号( symbol)。这个正规符号的集合称作字母表 (alphabet)并且常写作希腊符号 ∑(sigma)。 正则表达式r还包括字母表中的字符,但这些字符具有不同的含义:在正则表达式中,所 有的符号指的都是模式。在本章中,所有模式都是用黑体写出以与作为模式的字符区分开来。 因此,a 就是一个作为模式的字符 a。 最后,正则表达式 r还可包括有特殊含义的字符。这样的字符称作元字符( metacharacter) 或元符号(metasymbol)。它们并不是字母表中的正规字符,否则当其作为元字符时就与作为 字母表中的字符时很难区分了。但是通常不可能要求这种排斥,而且也必须使用一个惯例来区 分元字符的这两种用途。在很多情况下,它是通过使用可“关掉”元字符的特殊意义的转义字 符(escape character)做到的。源代码层一般是反斜杠和引号。如果转义字符是字母表中的正 规字符,则请注意它们本身也就是元字符了。 2.2.1 正则表达式的定义 现在通过讲解每个模式所生成的不同语言来描述正则表达式的含义。首先讲一下基本正则 24 编译原理及实践 表达式的集合(它是由单个符号组成),之后再描述从已有的正则表达式生成一个新的正则表达 式的运算。它同构造算术表达式的方法类似:基本的算术表达式是由数字组成,如 43和2.5。算 术运算的加法和乘法等可用来从已有的表达式中产生新的表达式,如在 43 * 2.5和43 * 2.5 + 1.4 中。 从它们只包括了最基本的运算和元符号这一方面而言,这里所讲到的一组正则表达式都是 最小的,以后还会讲得更深一些。 1) 基本正则表达式 它们是字母表中的单个字符且自身匹配。假设 a是字母表∑中的任一字 符,则指定正则表达式a 通过书写L (a) = {a}来匹配a字符。而特殊情况还要用到另外两个字符。 有时需要指出空串( empty string)的匹配,空串就是不包含任何字符的串。空串用 (epsilon) 来表示,元符号 (黑体 )是通过设定 L( ) = { }来定义的。偶尔还需要写出一个与任何串都 不匹配的符号,它的语言为空集(empty set ),写作{}。我们用符号 来表示,并写作L ( ) = {}。 请注意{}和{ }的区别:{}集不包括任何串,而{ }则包含一个没有任何字符的串。 2) 正则表达式运算 在正则表达式中有 3种基本运算:① 从各选择对象中选择,用元字符 | (竖线)表示。②连结,由并置表示(不用元字符)。③重复或“闭包”,由元字符 *表示。后面 通过为匹配了的串的语言提供相应的集合结构依次讨论以上 3种基本运算。 3) 从各选择对象中选择 如果r 和s 是正则表达式,那么正则表达式 r | s 可匹配被r 或s 匹 配的任意串。从语言方面来看, r | s 语言是r 语言和s 语言的联合(union),或L (r | s) = L (r) ∪L (s)。以下是一个简单例子:正则表达式 a | b匹配了a 或b 中的任一字符,即L (a | b) = L (a) ∪L (b) = {a}∪{b} = {a, b}。又例如表达式 a | 匹配单个字符a 或空串(不包括任何字符),也 就是L (a | ) = {a, }。 还可在多个选择对象中选择,因此 L (a | b | c | d) = { a, b, c, d} 也成立。有时还用点号表示 选择的一个长序列,如a | b |...| z,它表示匹配a~z 的任何小写字母。 4) 连结 正则表达式r 和正则表达式s 的连结可写作rs,它匹配两串连结的任何一个串,其 中第1个匹配r,第2个匹配s。例如:正则表达式ab只匹配ab,而正则表达式 ( a | b ) c 则匹配 串ac 和bc(下面将简要介绍括号在这个正则表达式中作为元字符的作用)。 可通过由定义串的两个集合的连结所生成的语言来讲解连结的功能。假设有串 S 和S 的两 1 2 个集合,串S S 的连结集合是将串S 完全附加到串S 上的集合。例如若S = {aa, b}, S = {a, bb}, 12 2 1 1 2 则S S = { aaa, aabb, ba, bbb}。现在可将正则表达式的连结运算定义如下: L (rs) = L (r) L (s), 12 因此(利用前面的示例),L (( a | b ) c) = L ( a | b ) L (c) = {a, b} {c} = {ac, bc}。 连结还可扩展到两个以上的正则表达式: L (r r ... r ) = L (r ) L (r ) ... L (r ) = 由将每一个 12 n 1 2 n L (r ), ..., L (r ) 串连结而成的串集合。 1 n 5) 重复 正则表达式的重复有时称为 Kleene闭包((Kleene) closure),写作r*,其中r 是一 个正则表达式。正则表达式r* 匹配串的任意有穷连结,每个连结均匹配r。例如a*匹配串 、a、 aa、aaa ...(它与 匹配是因为 是与a匹配的非串连结)。通过为串集合定义一个相似运算 *, 就可用生成的语言定义重复运算了。假设有一个串的集合 S,则 S* = { } ∪ S ∪ SS ∪ SSS ∪. . . 这是一个无穷集的联合,但是其中的每一个元素都是 S中串的有穷连结。有时集合 S*可写作: 其中Sn = S . . . S,它是S 的n 次连结(S0 = { })。 现在可如下定义正则表达式的重复运算: 25 第 2章 词 法 分 析 L (r*) = L (r) * 例:在正则表达式(a | bb) * (括号作为元字符的原因将在后面解释)中,该正则表达式与以 下串任意匹配: 、a、bb、aa、abb、bba、bbbb、aaa、aabb 等等。在语言方面,L ((a | bb)*) = L (a | bb)* = {a, bb}* = { , a, bb, aa, abb, bba, bbbb, aaa, aabb, abba, abbbb, bbaa, . . . }。 6) 运算的优先和括号的使用 前面的内容忽略了选择、连结和重复运算的优先问题。例如 对于正则表达式 a | b*,是将它解释为( a | b )* 还是a | ( b* ) 呢(这里有一个很大的差别,因 为L (( a | b )*) = { , a, b, aa, ab, ba, bb, . . . },但L ( a | ( b* )) = { , a, b, bb, bbb, . . . })?标准 惯例是重复的优先权较高,所以第 2个解释是正确的。实际上,在这 3个运算中, *优先权最 高,连结其次, | 最末。因此, a | bc* 就可解释为 a | ( b ( c* )),而ab | c*d 却解释为( ab ) | (( c* ) d )。 当需指出与上述不同的优先顺序时,就必须使用括号。这就是为什么用 ( a | b ) c 能表示 选择比连结有更高的优先权的原因。而 a | bc 则被解释为与a 或bc 匹配。类似地,没有括号的 ( a | bb )* 应解释为a | bb*,它匹配a、b、bb、bbb. . . 。此处括号的用法与其在算术中类似: (3 + 4) * 5 =35,而3 + 4 * 5 = 23,这是因为* 的优先权比+ 的高。 7) 正则表达式的名字 为较长的正则表达式提供一个简化了的名字很有用处,这样就不再 需要在每次使用正则表达式时书写长长的表达式本身了。例如:如要为一个或多个数字序列写 一个正则表达式,则可写作: (0|1|2|...|9)(0|1|2|...|9)* 或写作 d i g i t d i g i t* 其中 digit = 0 | 1 | 2 | . . . | 9 就是名字digit的正则定义(regular definition)了。 正则定义的使用带来了巨大的方便,但是它却增加了复杂性,它的名字本身也变成一个元 符号,而且必须要找到一个方法能将这个名字与它的字符连结相区分开。在我们的例子中是用 斜体来表示它的名字。请注意,在名字的定义中不能使用名字(也就是递归地)——必须能够 用它代表的正则表达式替换它们。 在考虑用一些例子来详细描述正则表达式的定义之前,先将有关正则表达式定义的所有信 息收集在一起。 定义 正则表达式(regular expression)是以下的一种: 1. 基本(basic)正则表达式由一个单字符 a(其中a 在正规字符的字母表 ∑中),以及 元字符 或元字符 组成。在第 1种情况下,L (a) = {a};在第2种情况下, L ( ) = { };在第3种情况下,L ( ) = {}。 2. r | s 格式的表达式:其中r 和s 均是正则表达式。在这种情况下,L (r | s) = L (r) ∪ L (s)。 3. rs 格式的表达式:其中 r 和s 是正则表达式。在这种情况下, L (rs) = L (r) L (s)。 4. r* 格式的表达式:其中r 是正则表达式。在这种情况下, L (r*) = L (r)*。 5. (r)格式的表达式:其中r 是正则表达式。在这种情况下, L ((r)) = L (r),因此,括 号并不改变语言,它们只调整运算的优先权。 我们注意到在上面这个定义中,(2)、(3)和(4)的优先权与它们所列的顺序相反,也就 26 编译原理及实践 是:|的优先权最低,连结次之, * 则最高。另外还注意到在这个定义中, 6个符号—— 、 、 |、*、( 和 ) 都有了元字符的含义。 本节后面将给出一些示例来详述上述定义,但它们并不经常作为记号描述在程序设计语言 中出现。 2.2.3节将讨论一些经常作为记号在程序设计语言中出现的常用正则表达式。 在下面的示例中,被匹配的串通常是英语描述,其任务是将描述翻译为正则表达式。包含 了记号描述的语言手册是编译器的程序员最常见的。偶尔还需要变一下,也就是将正则表达式 翻译为英语描述,我们也有一些此类的练习。 例2.1 在仅由字母表中的 3个字符组成的简单字母表 ∑ = {a, b, c}中,考虑在这个字母表上的仅 包括一个b 的所有串的集合,这个集合由正则表达式 (a|c)*b(a|c)* 产生。尽管b出现在正则表达式的正中间,但字母 b 却无需位于被匹配的串的正中间。实际上, 在b 之前或之后的a 或c 的重复会发生不同的次数。因此,串 b、abc、abaca、baaaac、ccbaca 和ccccccb 都与上面的正则表达式匹配。 例2.2 在与上面相同的字母表中,如果集合是包括了最多一个 b 的所有串,则这个集合的正则 表达式可通过将上例的解作为一个解(与那些仅为一个 b 的串匹配),而正则表达式( a | c ) * 则作为另一个解(与b 根本不匹配)来获取。因此有以下解: (a|c)*|(a|c)*b(a|c)* 下面是一个既允许 b 又允许空串在重复的a 或c 之间出现的另一个解: (a|c)*(b| )(a|c)* 本例引出了正则表达式的一个重要问题:不同的正则表达式可生成相同的语言。虽然在实际中 从未尝试着证实已找到了“最简单的”,例如最短的,正则表达式,但通常总是试图用尽可能 简单的正则表达式来描述串的集合。这里有两个原因:首先在现实中极少有标准的“最短的” 解;其次,在研究用作识别正则表达式的方法时,那儿的算法无需首先将正则表达式简化就可 将识别过程简化了。 例2.3 在字母表∑= {a, b}上的串S的集合是由一个b及在其前后有相同数目的 a 组成: S = { b, aba, aabaa, aaabaaa, . . . } = { an b an | n≠0 } 正则表达式并不能描述这个集合,其原因在于重复运算只有闭包运算 *一种,它允许有任意次 的重复。因此如要写出表达式 a*ba*(尽可能接近地得到S的正则表达式),就不能保证在 b 前 后的a 的数量是否相等了,它通常表示为“不能计算的正则表达式”。但若要给出一个数学论 证,则需使用有关正则表达式的称作 Pumping引理(Pumping lemma)的著名定理,这将在自 动机理论中学到,现在就不谈了。 很明显,并非用简单术语描述的所有串都可由正则表达式产生,因此为了与其他集合相区 分,作为正则表达式语言的串集合称作正则集合( regular set)。非正则集合偶尔也作为串出现 在需要由扫描程序识别的程序设计语言中,通常是在出现时才处理它们,我们也将其放在扫描 程序一节中讨论。 例2.4 在字母表 ∑= {a, b, c}上的串 S中,任意两个 b 都不相连,所以在任何两个 b 之间都至 少有一个 a 或c。可分几步来构造这个正则表达式,首先是在每一个 b 后都有一个 a 或c,它 写作: 27 第 2章 词 法 分 析 (b(a|c))* 将其与表达式 (a|c)*合并,(a|c)*是与完全不包含 b 的串匹配,则写作: ((a|c)*|(b(a|c))*)* 或考虑到 (r*|s*)*与(r|s)*所匹配的串相同,则: ((a|c)|(b(a|c)))* 或 (a|c|ba|bc)* (警告!这还不是正确答案)。 这个正则表达式产生的语言实际上具有了所需的特性,即:没有两个相连的 b(但这还不正 确)。有时需要证明一下上面的这个说法,也就是证明在 L((a|c|ba|bc)*)中的所有串都不包 括两个相连的b。该证明是通过对串长度(即串中字符数)的归纳实现的。很显然,它对于所有 长度为0、1和2的串是正确的:这些串实际是串 、a、c、aa、ac、ca、cc、ba 和bc。现在假设 对于在长度i < n 的语言中的所有串也为真,并令s 是长度为n > 2 的语言中的一个串,那么s 包 含了至少一个上面所列的非 的串,所以s = s s ,其中s 和s 也是语言中的非 串。通过假设归 12 1 2 纳,证明了s 和s 都没有两个相连的 b。因此要使s 本身包括两个相连的 b 的唯一方法是使s 以 1 2 1 一个b 结尾,而s 以一个b 开头。但又因为语言中的串都不以b 结尾,所以这是不可能的。 2 论证中的最后一个事实,即由上面的正则表达式所产生的串都不以 b 结尾,也显示了我们 的解还不太正确:它不产生串 b、ab和cb,这3个都不包括两个相连的 b。可以通过为其添加一 个可选的结尾b来修改它,如下所示: (a|c|ba|bc)*(b| ) 这个正则表达式的镜像也生成了指定的语言: (b| )(a|c|ab|cb)* 以下也可生成相同的语言: (notb|b notb )*(b| ) 其中notb = a|c。这是一个使用了下标表达式名字的例子。由于无需将原表达式变得更复杂就 可使notb的定义调整为包括了除b 以外的所有字符,因此实际是在字母表较大时使用这个解。 例2.5 本例给出了一个正则表达式,要求用英语简要地描述它生成的语言。若有字母表 ∑= {a, b, c},则正则表达式: ((b|c)*a(b|c)*a)*(b|c)* 生成了所有包括偶数个a 的串的语言。为了看清它,可考虑外层左重复之中的表达式: (b|c)*a(b|c)*a 它生成的串是以 a 结尾且包含了两个 a(在这两个a 之前或之间可有任意个 b 和c)。重复这些串 则得到所有以 a 结尾的串,且 a 的个数是 2的倍数(即偶数)。在最后附加重复 (b|c)*(如前 例所示)则得到所需结果。 这个正则表达式还可写作: (nota* a n o t a* a ) * nota* 2.2.2 正则表达式的扩展 前面已给出了正则表达式的一个定义,这个正则表达式使用了在所有应用程序中都常见到 28 编译原理及实践 运算的最小集合,而且使上面的示例仅限于使用 3种基本运算(包括括号)。但是从以上这些示 例中可看出仅利用这些运算符来编写正则表达式有时显得很笨拙,如果可用一个更有表达力的 运算集合,那么创建的正则表达式就会更复杂一些。例如,使任意字符的匹配具有一个表示法 很有用(我们现在须在一个长长的解中列出字母表中的每个字符)。除此之外,拥有字符范围 的正则表达式和除单个字符以外所有字符的正则表达式都十分有效。 下面几段文字将描述前面所讨论的标准正则表达式的一些扩展情况,以及与它及类似情况 相对应的新元符号。在大多数情况下并不存在通用术语,所以使用的是与在扫描程序生成器 Lex中用到的类似的表示法,本章后面将会讲到 Lex。实际上,以后要谈到的很多情况都会在 对Lex的讨论中再次提到。并非所有使用正则表达式的应用程序都包括这些运算,即使是这样, 也要用到不同的表示法。 下面是新运算的列表。 (1) 一个或多个重复 假若有一个正则表达式r,r 的重复是通过使用标准的闭包运算来描述,并写作 r *。它允许 r 被重复0次或更多次。0次并非是最典型的情况,一次或多次才是,这就要求至少有一个串匹 配r,但空串 却不行。例如在自然数中需要有一个数字序列,且至少要出现一个数字。如要匹 配二进制数,就写作 (0|1)*,它同样也可匹配不是一个数的空串。当然也可写作 (0|1)(0|1)* 但是这种情况只出现在用+代替*的这个相关的标准表示法被开发之前: r+表明r 的一个或 多个重复。因此,前面的二进制数的正则表达式可写作: (0|1)+ (2) 任意字符 为字母表中的任意字符进行匹配需要一个通常状况:无需特别运算,它只要求字母表中 的每个字符都列在一个解中。句号“ .”表示任意字符匹配的典型元字符,它不要求真正将字 母表写出来。利用这个元字符就可为所有包含了至少一个 b 的串写出一个正则表达式,如下 所示: .*b.* (3) 字符范围 我们经常需要写出字符的范围,例如所有的小写字母或所有的数字。直到现在都是在用表 示法a|b|...|z 来表示小写字母,用 0|1|...|9来表示数字。还可针对这种情况使用一个 特殊表示法,但常见的表示法是利用方括号和一个连字符,如 [a-z]是指所有小写字母,[09]则指数字。这种表示法还可用作表示单个的解,因此 a|b|c可写成[abc],它还可用于多 个范围,如 [a-zA-Z]代表所有的大小写字母。这种普遍表示法称为字符类( character class)。 例如,[A-Z]是假设位于 A和Z之间的字符 B、C等(一个可能的假设)且必须只能是 A和Z之间 的大写字母(ASCII字符集也可)。但[A-z]则与[A-Za-z]中的字符不匹配,甚至与 ASCII字 符集中的字符也不匹配。 (4) 不在给定集合中的任意字符 正如前面所见的,能够使要匹配的字符集中不包括单个字符很有用,这点可由用元字符表 示“非”或解集合的互补运算来做到。例如,在逻辑中表示“非”的标准字符是波形符“ ~”, 那么表示字母表中非a 字符的正则表达式就是~a。非a、b 及c 表示为: ~(a|b|c) 在Lex中使用的表示法是在连结中使用插入符“ ^”和上面所提的字符类来表示互补。例如, 29 第 2章 词 法 分 析 任何非a 的字符可写作 [^a],任何非a、b 及c 的字符则写作: [^abc] (5) 可选的子表达式 有关串的最后一个常见的情况是在特定的串中包括既可能出现又可能不出现的可选部分。 例如,数字前既可有一个诸如 +或-的先行符也可以没有。这可用解来表示,同在正则定义中 是一样的: natural = [0-9]+ s i g n e d N a t u r a l= natural | + n a t u r a l | - natural 但这会很快变得麻烦起来,现在引入问号元字符 r?来表示由r 匹配的串是可选的(或显示 r 的0 个或1个拷贝)。因此上面那个先行符号的例子可写成: natural = [0-9]+ s i g n e d N a t u r a l= (+|-)? natural 2.2.3 程序设计语言记号的正则表达式 在众多不同的程序设计语言中,程序设计语言记号可分为若干个相当标准的有限种类。第 1类是保留字的,有时称为关键字( keyword),它们是语言中具有特殊含意的字母表字符的固 定串。例如:在 Pascal、C和Ada语言中的 if、while和do。另一个范围由特殊符号组成,它 包括算术运算符、赋值和等式。它们可以是一单个字符,如 =,也可是多个字符如: =或++。 第3种由标识符( identifier)组成,它们通常被定义为以字母开头的字母和数字序列。最后一 种包括了文字(literal)或常量(constant),如数字常量42和3.14159,如串文字“hello, world,”, 及字符“ a”和“ b”。在这里仅讨论一下它们的典型正则表达式以及与记号识别相关的问题, 本章后面还会更详细地谈到实际识别问题。 1) 数 数可以仅是数字(自然数)、十进制数、或带有指数的数(由 e 或E 表示)的序列。 例如:2.71E-2表示数.0271。可用正则定义将这些数表示如下: nat = [0-9]+ signedNat= (+|-)?nat n u m b e r = s i g n e d N a t("." nat ) ? (E signedNat)? 此处,在引号中用了一个十进制的点来强调它应直接匹配且不可被解释为一个元字符。 2) 保留字和标识符 正则表达式中最简单的就是保留字了,它们由字符的固定序列表示。 如果要将所有的保留字收集到一个定义中,就可写成: reserved= if | while | do | ... 相反地,标识符是不固定的字符串。通常,标识符必须由一个字母开头且只包含字母和数 字。可用以下的正则定义表示: letter = [ a - zA - Z ] digit = [ 0 - 9 ] identifier= letter (letter | digit)* 3) 注释 注释在扫描过程中一般是被忽略的 。然而扫描程序必须识别注释并舍弃它们。 因此尽管扫描程序可能没有清晰的常量记号(可将其称为“伪记号 pseudotoken”),仍需要给 注释编写出正则表达式。注释可有若干个不同的格式。通常,它们可以是前后为分隔符的自由 它们有时可包括编译目录。 30 编译原理及实践 格式,例如: { this is a Pascal comment } /* this is a C comment */ 或由一个或多个特殊字符开头并直到该行的结尾,如在 ; this is a Scheme comment -- this is an Ada comment 中。 为有单个字符的分隔符的注释(如 Pascal 注释)编写正则表达式并不难,且为那些从行的特 殊字符到行尾编写正则表达式也不难。例如 Pascal 注释可写作: { (~} ) * } 其中,用~ }表示“非 }”,并假设字符 }作为元字符没有意义(在 Lex中的表示与之不同,本章 后面将会提到)。类似地,一个Ada注释可被正则表达式 --(~newline)* 匹配。其中,假设newline匹配一行的末尾(在许多系统中可写作 \n),“-”字符作为元字符没 有意义,该行的结尾并未包括在注释本身之中( 2.6节将谈到如何在Lex中书写它)。 为同C注释一样,其中的分隔符如多于一个字符时,则编写正则表达式就要困难许多。例 如串集合ba. . .(没有ab). . . ab(用ba. . . ab 代替C的分隔符/*...*/,这是因为星号,有时还有 前斜杠要求特殊处理的元字符)。不能简单地写作: b a (~( a b ) ) * a b 由于“非”运算通常限制为只能是单个字符而不能使用字符串。可尝试用 ~a、~b和~(a|b)为 ~(ab)写出一个定义来,但这却没有多大用处。其中的一个解是: b*(a*~(a|b)b*)*a* 然而这很难读取(且难证明是正确的)。因此, C注释的正则表达式是如此之难以至于在实际 中几乎从未编写过。实际上,这种情况在真正的扫描程序中经常是通过特殊办法解决的,本章 后面将会提到它。 最后,在识别注释时会遇到的另一个复杂的问题是:在一些程序设计语言中,注释可被嵌 套。例如 Modula-2允许格式注释: (* this is (*a Modula-2 *) comment *) 在这种嵌套注释中,注释分隔符必须成对出现,故以下注释在 Modula-2中是不正确的: (* this is ( * illegal in Modula-2 *) 注释的嵌套要求扫描程序计算分隔符的数量,但我们又注意到在例 2.3(2.2.1节)中,正则 表达式不能表示计数操作。实际上,一个简单的计算器配置可作为这个问题的特殊解(参见 练习)。 4) 二义性、空白格和先行 在程序设计语言记号使用正则表达式的描述中,有一些串经 常可被不同的正则表达式匹配。例如:诸如 if和while的串可以既是标识符又可以是关键 字。类似地,串 < >可解释为表示两个记号(“小于号”和“大于号”)或一单个符号(“不等 于”)。程序设计语言定义必须规定出应遵守哪个解释,但正则表达式本身却无法做到它。相 反地,语言定义必须给出无二义性规则( disambiguating rules ),由其回答每一种情况下的 含义。 下面给出处理示例的两个典型规则。首先当串既可以是标识符也可以是关键字时,则通常 认为它是关键字。这暗示着使用术语保留字( reserved word),其含义只是关键字不能同时也 31 第 2章 词 法 分 析 是标识符。其次,当串可以是单个记号也可以是若干记号的序列时,则通常解释为单个记号。 这常常被称作最长子串原理( principle of longest substring):可组成单个记号的字符的最长串 在任何时候都是假设为代表下一个记号 。 在使用最长子串原理时会出现记号分隔符( token delimiter)的问题,即表示那些在某时 不能代表记号的长串的字符。分隔符应是肯定为其他记号一部分的字符。例如在串 xtemp=ytemp中,等号将标识符 xtemp分开,这是因为 =不能作为标识符的部分出现。通常 也认为空格、新行和制表位是记号分隔符:因此 w h i l e x . .就. 解释为包含了两个记号——保 留字while和带有名字 x的标识符,这是因为一个空格将两个字符串分开。在这种情况下,定 义空白格伪记号非常有用 0它与注释伪记号相类似,但注释伪记号仅仅是在扫描程序内部区分 其他记号。实际上,注释本身也经常作为分隔符,因此例如 C代码片段: do / ** / if 表示的就是两个保留字 do和if,而不是标识符 doif。 程序设计语言中的空白格伪记号的典型定义是: w h i t e s p a c e= ( newline | blank | tab | comment)+ 其中,等号右边的标识符代表恰当的字符或串。请注意:空白格通常不是作为记号分隔符,而 是被忽略掉。指定这个行为的语言叫作自由格式语言( free format)。除自由格式语言之外还可 以是一些诸如 FORTRAN的固定格式语言,以及各种使用缩排格式的语言,例如越位规则 (offside rule)(参见“注意与参考”一节)。自由格式语言的扫描程序必须在检查任意记号分 隔功能之后舍弃掉空白格。 分隔符结束记号串,但它们并不是记号本身的一部分。因此,扫描程序必须处理先行 (lookahead)问题:当它遇到一个分隔符时,它必须作出安排分隔符不会从输入的其他部分中 删除,方法是将分隔符返回到输入串(“备份”)或在将字符从输入中删除之前先行。在大多数 情况下,只有单个字符才需要这样做(“单个字符先行”)。例如在串 xtemp=ytemp中,当遇 到=时,就可找到标识符 xtemp的结尾,且=必须保留在输入中,这是因为它表示要识别下一 个记号。还应注意,在识别记号时可能不需要使用先行。例如,等号可能是以 =开头的唯一字 符,此时无需考虑下一个字符就可立即识别出它了。 有时语言可能要求不仅仅是单个字符先行,且扫描程序必须准备好可以备份任意多个字符。 在这种情况下,输入字符的缓冲和为追踪而标出位置就给扫描程序的设计带来了问题(本章后 面将会讨论到其中的一些问题)。 FORTRAN是一个并不遵守上面所谈的诸多原则的典型语言。它是固定格式语言,它的空 白格在翻译开始之前已由预处理器删除了。因此,下面的 FORTRAN行 I F ( X 2 . EQ. 0 ) THE N 在编译器中就是 IF(X2.EQ.0)THEN 所以空白格再也不是分隔符了。 FORTRAN中也没有保留字,故所有的关键字也可以是标识符, 输入每行中字符串的位置对于确定将要识别的记号十分重要。例如,下面代码行在 FORTRAN 中是完全正确的: IF(IF.EQ.0 )THENTHEN=1.0 有时这也称作“最大咀嚼”定理。 32 编译原理及实践 第1个IF和THEN都是关键字,而第 2个IF和THEN则是表示变量的标识符。这样的结果是 FORTRAN的扫描程序必须能够追踪代码行中的任意位置。例如: DO99I=1,10 它引起循环体为第99行代码的循环。在Pascal中,则表示为f o r i : = 1 t o 。1 0另一方面, 若将逗号改为句号: DO99I=1.10 就将代码的含义完全改变了:它将值 1.1赋给了名字为 DO99I的变量。因此,扫描程序只有到 它接触到逗号(句号)时才能得出起始的 DO。在这种情况下,它可能只得追踪到行的开头并 且由此开始。 2.3 有穷自动机 有穷自动机,或有穷状态的机器,是描述(或“机器”)特定类型算法的数学方法。特别 地,有穷自动机可用作描述在输入串中识别模式的过程,因此也能用作构造扫描程序。当然有 穷自动机与正则表达式之间有着很密切的关系,在下一节中大家将会看到如何从正则表达式中 构造有穷自动机。但在学习有穷自动机之前,先看一个说明的示例。 通过下面的正则表达式可在程序设计语言中给出标识符模式的一般定义(假设已定义了 letter和digit): identifier= letter ( letter | digit)* 它代表以一个字母开头且其后为任意字母和 / 或数字序列的串。识别这样的一个串的过程可表 示为图 2-1。在此图中,标明数字 1和2的圆圈 表示的是状态( state),它们表示其中记录已 被发现的模式的数量在识别过程中的位置。 带有箭头的线代表由记录一个状态向另一个 状态的转换( transition),该转换依赖于所标 字符的匹配。在较简单的图示中,状态 1是初 始状态(start state)或识别过程开始的状态。 图2-1 标识符的有穷自动机 按照惯例,初始状态表示为一个“不来自任何地方”且指向它的未作标识的箭头线。状态 2代 表有一单个字母已被匹配的点(表示为从状态 1到状态2的转换,且其上标有 letter)。一旦 位于状态2中,就可看到任何数量的字母和 / 或数字,它们的匹配又使我们回到了状态 2。代表 识别过程结束的状态称作接受状态( accepting state),当位于其中时就可说明成功了,在图中 它表示为在状态的边界画出双线。它们可能不只一个。在上面的例图中,状态 2就是一个接受 状态,它表示在看到一个字母之后,随后的任何字母和数字序列(也包括根本没有)都表示一 个正规的标识符。 将真实字符串识别为标识符的过程现在可通过列出在识别过程中所用到的状态和转换的序 列来表示。例如,将 xtemp识别为标识符的过程可表示为: x →1→ 2→ t 2→ e 2→ m p 2→2 在此图中,用在每一步中匹配的字母标出了每一个转换。 2.3.1 确定性有穷自动机的定义 因为上面所示的例图很方便地展示出算法过程,所以它对于有穷自动机的描述很有用处。 33 第 2章 词 法 分 析 但是偶尔还需使用有穷自动机的更正式的描述,现在就给出一个数学定义。但绝大数情况并不 需要这么抽象,且在大多数示例中也只使用示意图。有穷自动机还有其他的描述,尤其是表格, 表格对于将算法转换成运行代码很有用途。在需要它的时候我们将会谈到它。 另外读者还需注意:我们一直在讨论的是确定性的( deterministic)有穷自动机,即:下 一个状态由当前状态和当前输入字符唯一给出的自动机。非确定性的有穷自动机是由它产生的。 本节稍后将谈到它。 定义:DFA(确定性有穷自动机) M由字母表∑、状态集合S、转换函数 T:S×∑→S、 初始状态s ∈ S以及接受状态的集合 A ⊂ S组成。由 M接受的且写作L (M) 被定义为字符 0 c c . . . c 串的集合,其中每个c ∈∑ ,存在状态s = T (s , c ), s = T (s , c ), . . . , s = T 12 n i 1 01 2 12 n (s , c ),其中s 是A(即一个接受状态)的一个元素。 n-1 n n 有关这个定义请注意以下几点。 S×∑指的是S 和∑的笛卡尔积或叉积:集合对( s, c),其 中s ∈ S且c ∈∑ 。如果有一个标为c 的由状态s 到状态s' 的转换,则函数T记录转换:T (s, c) = s' 。 与M相应的示图片段如下: 当接受状态序列s = T (s , c ), s = T (s , c ), . . . , s = T (s , c )存在,且s 是一个接受状态 1 01 2 12 n n-1 n n 时,它表示如下所示的意思: 在DFA的定义和标识符示例的图示之间有许多区别。首先,在标识符图中的状态使用了数 字,而定义并未用数字对状态集合作出限制。实际上可以为状态使用任何标识系统,这其中也 包括了名字。例如:下图与图 2-1完全一样: 在这里就称作状态 start(因为它是初始状态)和 in_id(因为我们发现了一个字母并识别 其 后 的 任 何 字 母 和 数 字 后 面 的 标 识 符 )。这个图示中的状态集合现在变成了 { s t a r t , in_id},而不是 {1,2}了。 图示与定义的第 2个区别在于转换不是用字符标出而是表示为字符集合的名字。例如,名 字letter表示根据以下正则定义的字母表中的任意字母: letter = [ a - zA - Z ] 因为如要为每个小写字母和每个大写字母画出总共 52个单独的转换非常麻烦,所以这是定义的 一个便利的扩展。本章后面还会用到这个定义的扩展。 图示与定义的第 3个区别更为重要:定义将转换表示为一个函数 T:S×∑→S。这意味着 T (s, c)必须使每个s 和c 都有一个值。但在图中,只有当 c 是字母时,才定义T (start, c);而 也只有当c 是字母或数字时,才定义 T (in_id, c)。那么,所丢失的转换跑到哪里去了呢?答案 是它们表示了出错——即在识别标识符时,我们不能接受除来自初始状态之外的任何字符以及 34 编译原理及实践 这之后的字母或数字 。按照惯例,这些出错 转换( error transition)在图中并没有画出来 而只是假设它总是存在着。如果要画出它们, 则标识符的图示应如图 2-2所示: 在该图中,我们将新状态 error标出来了(这 是因为它表示出错的发生),而且还标出了出 错转换 other。按照惯例, other表示并未 出现在来自于它所起源的状态的任何其他转 换中的任意字符,因此 other的定义来自于 初始状态为: other = ~letter 来自in_id状态的other的定义是: 图2-2 带有出错转换的标识符的有穷自动机 o t h e r = ~( letter|digit) 请注意,来自出错状态的所有转换都要回到其本身(这些转换用 any 标出以表示在这个转换 中得出的任何字符)。另外,出错状态是非接受的,因此一旦发生一个出错,则无法从出错状 态中逃避开来,而且再也不能接受串了。 下面是DFA的一些示例,其中也有一些是在上一节中提到过的。 例2.6 串中仅有一个b 的集合被下示的DFA接受: 请注意,在这里并未标出状态。当无需用名字来指出状态时就忽略标签。 例2.7 包含最多一个b 的串的集合被下示的DFA接受: 请注意这个DFA是如何修改前例中的 DFA,它将初始状态变成另一个的接受状态。 例2.8 前一节给出了科学表示法中数字常量的正则表达式,如下所示: nat = [ 0 - 9 ] + signedNat = ( + | - ) ? nat number = s i g n e d N a t( " . " nat) ? ( E signedNat)? 我们想为由这些定义匹配的串写出 DFA,但是先如下重写它会更为有用: digit = [0-9] nat = digit+ 在实际情况中,这些非文字数字的字符意味着根本就没有标识符 (如果是在初始状态中 )或遇到了一个结束标 识符的识别的分隔符 (如果是在接受状态中 )。本节后面将介绍如何处理这些情况。 35 第 2章 词 法 分 析 signedNat= (+|-)?nat n u m b e r = s i g n e d N a t("." nat) ? ( E signedNat)? 如下为nat写出一个DFA是非常简单(请记住a+ = aa*对任意的a均成立)的: 由于可选标记,signedNat要略难一些,但是可注意到 signedNat是以一个数字或一个标记 与数字开头,并写作以下的 DFA: 在它上面添加可选的小数部分也很简单,如下所示: 请注意,它有两个接受状态,它们表示小数部分是可选的。 最后需要添加可选的指数部分。要做到它,就要指出指数部分必须是以字母 E开头,且只 能发生在前面的某个接受状态之后,图 2-3是最终的图。 图2-3 浮点数的有穷自动机 例2.9 使用DFA可描述非嵌套注释。例如,前后有花括号的注释可由以下的 DFA接受: 在这种情况下, other意味着除了右边花括号外的所有字符。这个 DFA与第2.2.4节中所写 的正则表达式 {(~})*}相对应。 我们注意到在2.2.4中,为被两个字符的序列分隔开的注释编写一个正则表达式很难, C注 释的格式/*...(no*/s).../ 就是这样的。编写接受这个注释的 DFA比编写它的正则表达式 实际上要简单许多,图 2-4中的 DFA就是这样的 C注释。 36 编译原理及实践 图2-4 有C风格注释的有穷自动机 在该图中,由状态 3到其自身的 other转换表示除“ *”之外的所有字符,但由状态 4到状 态3的other转换则表示除“ *”和“ /”之外的所有字符。为了简便起见,还给状态编了号, 但仍能为状态赋予更多有意义的名字,如下所示(在括号中带有其相应的编号):start (1 )、 entering_comment (2)、in_comment (3)、exiting_comment (4)和finish(5)。 2.3.2 先行、回溯和非确定性自动机 作为根据模式接受字符串的表示算法的一种方法,我们已经学习了 DFA。正如同读者可能 早已猜到的一样,模式的正则表达式与根据模式接受串的 DFA之间有很密切的关系,下一节我 们将探讨这个关系。但首先需要更仔细地学习 DFA表示的精确算法,这是因为希望最终能将这 些算法变成扫描程序的代码。 我们早已注意到 DFA的图表并不能表示出 DFA所需的所有东西而仅仅是给出其运算的要 点。实际上,我们发现数学定义意味着 DFA必须使每个状态和字符都具有一个转换,而且这些 导致出错的转换通常是不在 DFA的图表中。但即使是数学定义也不能描述出 DFA算法行为的所 有方面。例如在出错时,它并不指出错误是什么。在程序将要到达接受状态时或甚至是在转换 中匹配字符时,它也不指出该行为。 进行转换时发生的典型动作是:将字符从输入串中移到属于单个记号(记号串值或记号词) 累积字符的字符串中。在达到某个接受状态时的典型的动作则是将刚被识别的记号及相关属性 返回。遇到出错状态的典型动作则是在输入中备份(回溯)或生成错误记号。 在关于标识符最早的示例中有许多这里将要描述的行为,所以我们再次回到图 2-4中。由 于某些原因,该图中的 DFA并没有如希望的那样来自扫描程序的动作。首先,出错状态根本就 不是一个真正的错误,而是表示标识符将不被识别(如来自于初始状态)或是已看到的一个分 隔符,且现在应该接受并生成标识符记号。我们暂时假设(实际这是正确的操作)有其他的转 换可表示来自初始状态的非字母转换。接着指出可看到来自状态 in_id的分隔符,以及应被生成 的一个标识符记号,如图 2-5所示。 图2-5 有分隔符和返回值的标识符的有穷自动机 在该图中, other转换前后都带有方括号,它表示了应先行考虑分隔字符,也就是:应先 将其返回到输入串并且不能丢掉。此外在该图中,出错状态已变成接受状态,且没有离开接受 状态的转换。因为扫描程序应一次识别一个记号并在每一个记号识别之后再一次从它的初始状 态开始,所以这正是所需要的。 37 第 2章 词 法 分 析 这个新的图示还表述了在 2.2.4节中谈到的最长子串原理: DFA将一直(在状态 in_id中) 匹配字母和数字直到找到一个分隔符。与在读取标识符串时允许 DFA在任何地方接受的旧图相 反,我们确实不希望发生某些事情。 现在将注意力转向如何在一开始就到达初始状态的问题上。典型的程序设计语言中都有许 多记号,且每一个记号都能被其自己的DFA识别出来。如果这每一个记号都以不同的字符开头, 则只需通过将其所有的初始状态统一到一个单独的初始状态上,就能很便利地将它们放在一起 了。例如,考虑串:=、<=和=给出的记号。其中每一个都是一个固定串,它们的 DFA可写作: 因为每一个记号都是以不同的字符开始的,故只需通过标出它们的初始状态就可得出以下 的DFA: 但是假设有若干个以相同字符开头的记号 ,例如<、<=和<>,就不能简单地将其表示为如下的 图表了。这是因为它不是 DFA(给出一个状态和字符,则通常肯定会有一个指向单个的新状态 的唯一转换): 38 编译原理及实践 相反地,我们必须做出安排,以便在每一个状态中都有一个唯一的转换。例如下图: 在理论上是应该能够将所有的记号都合并为具有这种风格的一个巨大的 DFA,但是它非常复杂, 在使用一种非系统性的方法时尤为如此。 解决这个问题的一个方法是将有穷自动机的定义扩展到包括了对某一特定字符一个状态存 在有多个转换的情况,并同时为系统地将这些新生成的有穷自动机转换成 NFA开发一个算法。 这里会讲解到这些生成的自动机,但有关转换算法的内容要在下一节才能提到。 新的有穷自动机称作非确定性有穷自动机( nondeterministic finite automaton)或简称为 NFA。在对它下定义之前,还需要为在扫描程序中应用有穷自动机再给出一个概括的讲法: 转换的概念。 -转换( -transition)是无需考虑输入串(且无需消耗任何字符)就有可能发生的转换。 它可看作是一个空串的“匹配”,空串在前面已讲过是写作 的。 -转换在图中的表示就好像 是一个真正的字符: 这不应同与在输入中的字符 的匹配相混淆:如果字母表包括了这样一个字符,则必须与 使用 作为表示 - 转换的元字符相区别。 - 转换与直觉有些相矛盾,这是因为它们可以“同时”发生,换言之,就是无需先行和改 变到输入串,但它们在两方面有用:首先,它们可以不用合并状态就表述另一个选择。例如: 记号:=、<=和=的选择可表述为:为每一个记号合并自动机,如下所示: 这对于使最初的自动机保持完整并只添加一个新的初始状态来链接它们很有好处。 -转换 的第2个好处在于它们可以清晰地描述出空串的匹配: 39 第 2章 词 法 分 析 当然,这与下面的 DFA等同,该DFA表示接受可在无任何字符匹配时发生: 但是具有前面清晰的表示法也是有用的。 现在来讲述非确定性自动机的定义。它与 DFA的定义很相似,但有一点不同:根据上面所 讨论的,需要将字母表 ∑扩展到包括了 。将原来写作 ∑的地方(这假设 最初并不属于 ∑)改 写成∑∪{ }(即∑和 的并集)。此外还需要扩展 T(转换函数)的定义,这样每一个字符都可 以导致多个状态,通过令 T的值是状态的一个集合而不是一个单独的状态就可以做到它。例如 下示的图表: 使T (1,<) = {2,3},即:在输入字符 <上,由状态1可到状态2或状态3,且T成为一个将状态/符号 对映射到状态集合的函数。因此, T的范围是状态的 S集合( S的所有子集的集合)的幂集 (power set),写作 (S)(S的草写的p 的集合)。下面给出定义。 定义:NFA(nondeterministic finite automaton)M由字母表∑、状态的集合 S、转换函 数T : S×(∑∪{ })→ (S)、S 的初始状态 s ,以及S的接受状态 A的集合组成。由 M接受 0 的语言写作L (M),它被定义为字符 c c . . . c ,其中每一个 c 都属于∑∪{ },且存在 12 n i 关系:s 在T (s , c ) 中、s 在T (s , c ) 中、. . .、s 在T (s , c ) 中,s 是A 中的元素。 1 01 2 12 n n-1 n n 有关这个定义还需注意以下几点。在 c c . . . c 中的任何c 都有可能是 ,而且真正被接受 12 n i 的串是删除了 的串c c . . . c (这是因为s 和 的联合就是s 本身)。因此,串c c . . . c 中真正的 12 n 12 n 字符数可能会少于 n个。另外状态序列s . . . s 是从状态集合T (s , c ) , . . . , T (s , c )选出的, 1 n 01 n-1 n 这个选择并不总是唯一确定的。实际上这就是为什么称这些自动机是非确定性的原因:接受特 定串的转换序列并不由状态和下一个输入字符在每一步中确定下来。实际上,任意个 都可在 任一点上被引入到串中,并与 NFA中 - 转换的数量相对应。因此, NFA并不表示算法,但是却 可通过一个在每个非确定性选择中回溯的算法来模拟它,本节下面会谈到这一点。 首先看一些 NFA的示例。 40 编译原理及实践 例2.10 考虑下面的NFA图。 下面两个转换序列都可接受串 abb: 实际上,a 上的由状态 1向状态2的转换与b 上的由状态 2向状态4的转换均允许机器接受串 ab,接着再使用由状态 4向状态2的转换,所有的串与正则表达式 ab+匹配。类似地, a 上的由 状态1向状态3的转换,和 上的由状态3向状态4的转换也允许接受与ab*匹配的所有串。最后, 由状态 1向状态 4的 - 转换可接受与 b * 匹配的所有串。因此,这个 NFA 接受与正则表达式 ab+|ab*|b*相同的语言。能够生成相同的语言的更为简单的正则表达式是 (a| )b*。下面 的DFA也接受这个语言: 例2.11 考虑下面的NFA: 它通过下面的转换接受串 acab : 不难看出,这个NFA接受的语言实际上与由正则表达式 (a|c)*b生成的语言相同。 41 第 2章 词 法 分 析 2.3.3 用代码实现有穷自动机 将DFA或NFA翻译成代码有若干种方法,本节将会讲到它们。但并不是所有的方法对编译 器的扫描程序都有用,本章的最后两节将更详细地讲到与扫描程序相关的编码问题。 请再想一下最开始那个接受由一个字母及一个字母和 /或数字序列组成的标识符的 DFA的 示例,以及当它位于包含了先行和最长子串原理的修改格式(参见图 2-5): 模拟这个DFA最早且最简单的方法是在下面的格式中编写代码: { starting in state 1 } if the next character is a letter then advance the input; { now in state 2 } while the next character is a letter or a digit do advance the input; { stay in state 2 } end while; { go to state 3 without advancing the input } accept; else { error or other cases } end if; 这个代码使用代码中的位置(嵌套于测试中)来隐含状态,这与由注释所指出的一样。如 果没有太多的状态(要求有许多嵌套层)且 DFA中的循环较小,那么就合适了。类似这样的代 码已被用来编写小型扫描程序了。但这个方法有两个缺点:首先它是特殊的,即必须用略微不 同的方法处理各个 DFA,而且规定一个用这种办法将每个 DFA翻译为代码的算法较难。其次: 当状态增多或更明确时,且当相异的状态与任意路径增多时,代码会变得非常复杂。现在来考 虑一下在例2.9(图2-4)中接受注释的 DFA,它可用以下的格式来实现: { state 1 } if the next character is “ / ” then advance the input: { state 2 } if the next character is “*” then advance the input;{ state 3 } done := false; while not done do while the next input character is not “*” do advance the input; end while; advance the input; { state 4 } 42 编译原理及实践 while the next input character is “*” do advance the input; end while; if the next input character is “/” then done : = true; end if; advance the input; end while; accept; { state 5 } else { other processing } end if; else { other processing } end if; 我们注意到这样做复杂性大大增加了,并且还需要利用布尔变量 done来处理涉及到状态 3 和状态4的循环。 一个较之好得多的实现方法是:利用一个变量保持当前的状态,并将转换写成一个双层嵌 套的case语句而不是一个循环。其中第 1个case语句测试当前的状态,嵌套着的第 2层测试输入 字符及所给状态。例如,前一个标识符的 DFA可翻译为程序清单 2-1的代码模式。 程序清单 2-1 利用状态变量和嵌套的 case测试实现标识符 DFA 请注意这个代码是如何直接反映 DFA的:转换与对state变量新赋的状态相对应,并提前输 入(除了由状态 2到状态3的“非消耗”转换)。 现在C注释的DFA(图2-4)可被翻译成程序清单 2-2中更可读的代码模式。除了这个结构 之外,还可使外部case基于在输入字符之上,并使内部 case基于在当前状态之上(参见练习)。 程序清单2-2 图2-4中DFA的实现 43 第 2章 词 法 分 析 在前面的示例中, DFA已正好被“硬连”进代码之中,此外还有可能将 DFA表示为数据结 构并写成实现来自该数据结构的行为的“类”代码。转换表( transition table),或二维数组就 是符合这个目标的简单数据结构,它由表示转换函数 T值的状态和输入字符来索引: 状态 S 字母表C中的字符 代表转换T (s, c) 的状态 例如:标识符的DFA可表示为如下的转换表: 状态 输入 1 2 3 字母 2 2 数字 其他 2 3 在这个表格中,空表项表示未在 DFA图中显示的转换(即:它们表示到错误状态或其他过 程的转换)。另外还假设列出的第 1个状态是初始状态。但是,这个表格尚未指出哪些状态正在 接受以及哪些转换不消耗它们的输入。这个信息可被保存在与表示表格相同的数据结构中,或 是保存在另外的数据结构中。如果将这些信息添加到上面的转换表中(另用一列来指出接受状 态并用括号指出“未消耗输入”的转换),就会得到下面这个表格: 44 编译原理及实践 输入 字母 数字 其他 接受 状态 1 2 不 2 2 2 [3] 不 3 是 又如:下面是C注释的DFA表格(前述的第2个例子): 状态 输入 / 1 2 2 3 3 4 5 5 * 其他 接受 不 3 不 4 3 不 4 3 不 是 现在若给定了恰当的数据结构和表项,就可以在一个将会实现任何 DFA的格式中编写代码 了。下面的代码图解假设了转换被保存在一个转换数组 T中,而T由状态和输入字符索引;先行 输入的转换(即:那些在表格中未被括号标出的)是由布尔数组 Advance给出,它们也由状态 和输入字符索引;而由布尔数组 Accept给出的接受状态则由状态索引。下面就是代码图解: state := 1; ch : = next input character; while notAccept[state] and not error(state) do newstate := T [state,ch]; if Advance [state,ch] then ch := next input char; state := newstate; end while; if Accept [state] then accept; 类似于刚刚讨论过的算法方法被称作表驱动( table driven),这是因为它们利用表格来引 导算法的过程。表驱动方法有若干优点:代码的长度缩短了,相同的代码可以解决许多不同的 问题,代码也较易改变(维护)了。但也有一些缺点:表格会变得非常大,使得程序要求使用 的空间也变得非常大。实际上,我们刚描述过的数组中的许多空间都是浪费了的。因此,尽管 表压缩经常会多耗费时间,但表驱动方法经常仍要依赖于诸如稀疏数组表示法的压缩方法,这 是因为扫描程序的效率必须很高,因此尽管可能会在诸如 Lex的扫描程序生成器程序上用到它 们,也是仍很少用到这些方法。在这里也就不再提它们了。 最后注意到可用与 DFA相似的方法来实现NFA,但有一点除外——因为NFA是非确定性的, 所以必须要尝试转换潜在的许多不同序列。因此模拟 NFA的程序必须储存还未尝试过的转换并 回溯失败的转换。除了是由输入串引导搜索之外,这与在指示图中试图找到路径的算法相类似。 由于此时进行回溯的算法有可能效率不高,而扫描程序对效率的要求又必须尽可能地高,所以 也就不再谈这个算法了。相反地, NFA的模拟问题可以通过使用下一节将谈到的“将 NFA转换 成DFA”的方法解决,在这一节中将还会简要地再谈到 NFA的模拟问题。 45 第 2章 词 法 分 析 2.4 从正则表达式到 DFA 在本节中,我们将学到将正则表达式翻译成 DFA的算法。由于也存在着将 DFA翻译成正则 表达式的算法,所以这两种概念是等同的。然而因为正则表达式的简洁性,它们趋向于将 DFA 当作记号来描述,而这样扫描程序的生成就通常是从正则表达式开始,并通过 DFA的构造以达 到最终的扫描程序。正是因为这一点,我们只是将兴趣放在完成该等同推导的算法之上。 将正则表达式翻译成 DFA的最简单算法是通过中间构造,在它之中,正则表达式派生出一 个NFA,接着就用该 NFA构造一个同等的 DFA。现在有一些算法可将正则表达式直译为 DFA, 但是它们很复杂,且对中间构造也有些影响。因此我们只关心两个算法:一个是将正则表达式 翻译成NFA,另一个是将NFA翻译成DFA。再与将DFA翻译成前一节中描述的程序的一个算法 合并,则构造一个扫描程序的自动过程可分为 3步,如下所示: 正则表达式 程序 2.4.1 从正则表达式到 NFA 下面将要谈到的结构是 Thompson结构( Thompson construction ),它以其发明者命名。 Thompson结构利用 -转换将正则表达式的机器片段“粘在一起”以构成与整个表达式相对应的 机器。因此该结构是归纳的,而且它依照了正则表达式定义的结构:为每个基本正则表达式展 示一个NFA,接着再显示如何通过连接子表达式的 NFA(假设这些是已经构造好的)得到每个 正则表达式运算。 1) 基本正则表达式 基本正则表达式格式 a、 或 ,其中a表示字母表中单个字符的匹配, 表示空串的匹配,而 则表示根本不是串的匹配。与正则表达式 a等同的NFA(即在其语言中 准确接受)的是: 类似地,与 等同的NFA是: 正则表达式 的情况(它在实际的编译器中是不可能发生)将留在练习中。 2) 并置 我们希望构造一个与正则表达式 rs等同的NFA,其中r 和s 都是正则表达式。假设 已构造好了与r 和s 等同的NFA,并用NFA对应r 且与s 类似来写出它: 在该图中,圆角矩形的左边圆圈表示初始状态,右边的双圆表示接受状态,中间的 3个点表示 NFA中未显示出的状态和转换。这个图假设与 r 相应的NFA中只有一个接受状态。如果构造的 每个NFA都有一个接受状态,那么这个假设就要调整一下。对于基本正则表达式的 NFA,这是 46 编译原理及实践 正确的;且对于下面每个结构,它也是正确的。 可将与rs 对应的NFA构造如下: 我们已将r 机的接受状态与s 机的接受状态通过一个 - 转换连接在一起。新机器将r 机的初始状 态作为它的初始状态,并将 s 机的接受状态作为它的接受状态。很明显,该机可接受 L (rs) = L (r) L (s) 的关系,它也对应于正则表达式 rs。 3) 在各选项中选择 我们希望在与前面相同的假设下构造一个与 r | s 相对应的NFA。如下 进行: 我们添加了一个新的初始状态和一个新的接受状态,并利用 -转换将它们连接在一起。很 明显,该机接受语言L (r | s) = L (r) ∪L (s)。 4) 重复 我们需要构造与 r*相对应的机器,现假设已有一台与 r 相对应的机器。那么就如 下进行: 这里又添加了两个新的状态:一个初始状态和一个接受状态。该机中的重复由从 r 机的接受状 态到它的初始状态的新的 -转换提供。它允许 r 机来回多次移动。为了保证也能接受空串(即 r 的重复为零),就必须也画出一个由新初始状态到新接受状态的 -转换。 这样就完成了Thompson 结构的描述。请读者注意这个构造并不唯一。特别是当将正则表 达式运算翻译成 NFA时,也可能有另一个结构。例如在表述并置 rs 时,就可以省略在 r 机和s 机之间的 -转换,相反却是将 r 机的接受状态等同于s 机的初始状态,如下: 47 第 2章 词 法 分 析 (但是这种简化却要取决于:在别的结构中,接受状态没有来自其他状态的转换,参见练习)。 在其他情况下也会有别的简化。之所以像上面那样来表述转换是因为机器构造遵循的原则也非 常简单。首先,每个状态具有最多两个来自它的状态,而且如果有两个转换,就必须都是 -转 换。其次,不能在构造时删除状态,而且除了来自接受状态的其他转换之外,转换也不可更改。 这些属性就将过程简化了。 用以下几个示例来结束对 Thompson结构的讨论。 例2.12 根据Thompson 结构将正则表达式 ab|a 翻译为NFA。首先为正则表达式a 和b 分别构 造机器: 接着再为并置 ab 构造机器: 现在再为a 构造另一个机器复件,并利用它们组成 ab|a 完整的NFA,如图2-6所示: 图2-6 利用Thompson结构完成的正则表达式 ab|a 的NFA 例2.13 利用Thompson 结构完成正则表达式 letter(letter|digit)*的NFA。同前例一 样,首先分别为正则表达式letter 和digit 构建机器: 接着再为选择 letter|digit 构造机器: 现在为重复 (letter |digit)*构造 NFA,如下所示: 48 编译原理及实践 最后,将 letter 和(letter |digit )*并置在一起,并构造该并置的机器以得到完整的 NFA,如图2-7所示: 图2-7 利用Thompson结构得到正则表达式 letter(letter|digit)* 的NFA 作为最后一个示例:我们注意到例 2.11(2.3.2节)与正则表达式 (a|c)*b在Thompson结 构下完全对应。 2.4.2 从NFA到DFA 若给定了一个任意的 NFA,现在来描述构造等价的 DFA(即:可准确接受相同串的 DFA) 的算法。为了做到它,则需要可从单个输入字符的某个状态中去除 - 转换和多重转换的一些方 法。消除 - 转换涉及到了 - 闭包的构造。 - 闭包( -closure)是可由 - 转换从某状态或某些状 态达到的所有状态集合。消除在单个输入字符上的多重转换涉及跟踪可由匹配单个字符而达到 的状态的集合。这两个过程都要求考虑状态的集合而不是单个状态,因此,当看到构建的 DFA 如同它的状态一样,也有原始 NFA的状态集合时,就不会感到意外了。所以就将这个算法称作 子集构造(subset construction)。我们首先较详细地讨论一下 - 闭包,然后再描述子集合构造。 1) 状态集合的 - 闭包 我们将单个状态s 的 - 闭包定义为可由一系列的零个或多个 - 转换 能达到的状态集合,并将这个集合写作 s 。该定义的更为数学化的语言将放在练习中,现在直 接谈一个示例;但请大家应注意到一个状态的 - 闭包总是包含着该状态本身。 例2.14 考虑在Thompson结构下,下面与正则表达式 a*相对应的NFA: 49 第 2章 词 法 分 析 在这个NFA,有 1 = {1,2,4}, 2 = {2}, 3 = {2,3,4}, 4 = {4}。 现在将状态的一个集合的 -闭包定义为每个单独状态的 - 闭包的和。若 S是状态集,则用 符号表示就是: 例如在例2.14的NFA中, {1,3} = 1 ∪ 3 = {1,2,4} ∪ {2,3,4} = {1,2,3,4}。 2) 子集构造 现在来描述从一个给定的NFA——M来构造DFA的算法,并将其称作 M 。首 先计算 M初始状态的 - 闭包,它就变成 M 的初始状态。对于这个集合以及随后的每个集合, 计算a字符之上的转换如下所示:假设有状态的 S集和字母表中的字符a,计算集合S´a = {t | 对于 S中的一些s,在a上有从s 到t 的转换}。接着计算 S´a ,它是S´a 的闭包。这就定义了子集构造中 的一个新状态和一个新的转换 S —a→ S´a ,继续这个过程直到不再产生新的状态或转换。当接受 这些构造的状态时,按照包含了 M的接受状态的方式作出记号。这就是 DFA的 M ,它并不包括 -转换,这是因为每个状态都被构造成了一个 - 闭包。它至多包括了一个来自字母 a 上的状态 的转换,这又是因为每个新状态都由从单个字符 a 上的一个状态的转换构造为来自 M的所有可 接受到的状态。 下面用若干示例来说明子集构造。 例2.15 请考虑例2.14中的NFA:与它相对应的 DFA的初始状态是 1 = {1,2,4},且存在着在字 符a 上的由状态 2向状态3的转换,而在a 上则没有来自状态 1或状态4的转换,因此在 a 上就有 从{1,2,4}到 {1,2,4} a = {3} = {2,3,4} 的转换。由于再也没有来自一个字符上的 1、2或4状态的转 换了,因此就可将注意力转向新状态 {2,3,4}。此时在a 上有从状态2到状态3的转换,且也没有 来自3或4状态的a- 转换,因此就有从{2,3,4}到 {2,3,4} a = {3} = {2,3,4} 的转换,因而也就有从 {2,3,4}到它本身的a- 转换。我们已将所有的状态都考虑完了,所以也构造出了整个 DFA。唯一 需要读者注意的是NFA的状态4是接受的,这是因为 {1,2,4}和{2,3,4}都包含了状态 4,它们都是 相应的 DFA的接受状态。将构造出的 DFA画出来,其中用状态各自的子集命名状态: (一旦构造完成,则如果愿意就可将子集术语丢置一旁了)。 例2.16 考虑向图2-6中的NFA增添状态数: 50 编译原理及实践 DFA子集结构与它的初始状态 {1} = {1,2,6} 相同。此时在 a上有从状态 2到状态 3的转换, 且有从状态 6到状态 7的转换,因此, {1,2,6} a = {3,7} = {3,4,7,8},且有 {1,2,6} —a→ {3,4,7,8}。 由于再也没有来自1、2或6状态的其他字符的转换,则只需看 {3,4,7,8}就行了。此时在b 上有从 状态4到状态5的转换,且 {3,4,7,8} b = {5} = {5,8},且有转换 {3,4,7,8} —b→ {5,8}。除此之外就 再也没有别的转换了。因此所产生的以下 DFA子集构造与前一个 NFA等同: 例2.17 考虑图2-7中的NFA(正则表达式letter(letter|digit)*的Thompson 结构): 子集构造过程如下:它的初始状态是 {1} = {1},在letter上有到 {2} = {2,3,4,5,7,10} 的 转换。在letter上还有一个从这个状态到 {6} = {4,5,6,7,9,10} 的转换以及在digit上有到 {8} = {4,5,7,8,9,10} 的转换。最后,所有这些状态都有在 letter和digit上的转换,或是到其自 身或是到另一个。完整的 DFA在下图中给出: 2.4.3 利用子集构造模拟 NFA 上一节简要地讨论了编写模拟 NFA的程序的可能性,这是一个要求处理机器的非确定性或 非算法本质的问题。模拟 NFA的一种方法是使用子集构造,但并非是构造与 DFA相关的所有状 态,而是在由下一个输入字符指出的每个点上只构造一个状态。因此,这样只构造了在给出的 51 第 2章 词 法 分 析 输入串上被取用的 DFA的路径中真正发生的状态集合。这样做的好处在于有可能就不再需要构 造整个DFA了,它的缺点在于如果路径中包含了循环,则有可能会多次构造某个状态。 例如:在例2.16中,若使输入串只由单个字符 a 组成,则构建初始状态{1,2,6}和第2个状态 {3,4,7,8},之后再移至这个状态并匹配a。由于随后再没有b 了,因此也就无需生成状态{5,8}了。 另一方面,在例2.17中,给定了输入串 r2d2,就有下面的状态和转换序列: 如果在转换发生时构建这些状态,则也构造了 DFA的所有状态,且构造两次状态 {4,5,7,8,9,10}。因此,这个过程比第一次构造整个 DFA的效率要低一些。正是由于这个原因, 在扫描程序中并不做 NFA的模拟。但在编辑器和搜索程序中却保留了模式匹配的选项,在编辑 器和搜索程序中,正则表达式可由用户动态地提供。 2.4.4 将DFA中的状态数最小化 我们上面所描述的由正则表达式利用算法派生出 DFA的过程有一个缺点:生成的 DFA可能 比需要的要复杂得多。例如在例 2.15中派生出的DFA: 对于正则表达式 a*,下面的DFA同样是可以的: 因为在扫描程序中,效率是很重要的,如果可能的话,在某种意义上构造的 DFA应最小。 实际上,自动机理论中有一个很重要的结果,即:对于任何给定的 DFA,都有一个含有最少量 状态的等价的 DFA,而且这个最小状态的 DFA是唯一的(重命名的状态除外)。人们有可能从 任何指定的 DFA中直接得到这个最小状态的 DFA,本节将简要地描述这个算法,但我们不证明 它确实构造了最小状态的等价的 DFA(对于读者而言,通过阅读算法证明一下并不难)。 该算法通过创建统一到单个状态的状态集来进行。它以最乐观的假设开始:创建两个集合, 其中之一包含了所有的接受状态,而另一个则由所有的非接受状态组成。假设这样来划分原始 DFA的状态,还要考虑字母表中每个a 上的转换。如果所有的接受状态在a 上都有到接受状态的 转换,那么这样就定义了一个由新接受状态(所有旧接受状态的集合)到其自身的 a- 转换。类 似地,如果所有的接受状态在 a 上都有到非接受状态的转换,那么这也定义了由新接受状态到 新的非接受状态(所有旧的非接受状态的集合)的 a- 转换。另一方面,如果接受状态 s 和t 在a 上有转换且位于不同的集合,则这组状态不能定义任何a- 转换,此时就称作a 区分(distinguish) 了状态s 和t。在这种情况下必须根据考虑中状态集合(即所有接受状态的集合)的 a- 转换的位 置而将它们分隔开。当然状态的每个其他集合都有类似的语句,而且一旦要考虑字母表中的所 有字符时,就必须移到它们的位置之上。当然如果还要分隔别的集合,就得返回到开头并重复 这一过程。我们继续将原始 DFA的各部分状态集中到集合里,并一直持续到所有集合只有一个 52 编译原理及实践 元素(在这种情况下,就显示原始 DFA为最小)或一直是到再没有集合可以分隔了。 如要准确地完成上面所描述的过程,还必须掌握非接受的错误状态的错误转换。也就是: 如果有两个接受状态 s 和t,其中 s 有一个到其他接受状态的 a- 转换,而 t 却根本没有 a- 转换 (即:错误转换),那么a 就将s 和t 区分开来了。类似地,如果非接受状态 s 有到某个接受状态 的a-转换,而另一个非接受状态 t 却没有a- 转换,那么在这种情况下,a 也将s 和t 区分开来。 下面用几个示例来总结一下状态最小化的讨论。 例2.18 考虑在前例中构造的 DFA,其与正则表达式letter(letter|digit)*相对应,它 有4个状态:1个初始状态和3个接受状态,这3个接受状态在 letter和digit上都有到其他接 受状态的转换,且除此之外再也没有其他(非错的)转换了。因此,任何字符也不能区分开这 3个接受状态,且最小化算法将 3个接受状态合并为一个接受状态,而剩下了下面的最小状态 DFA(即在2.3节开头所看到的): 例2.19 考虑下面的DFA,在例2.1(2.3.2节)中已指出它与正则表达式 (a| )b*相对应: 在这种情况下,所有的状态(除了错误状态之外)都在接受。现在考虑字符 b。每个接受 状态都有到其他接受状态的 b- 转换,因此 b 不能区分任何状态。另一方面,状态 1存在有到一 个接受状态的a- 转换,但状态2和状态3却没有a- 转换(或说成是:在 a 上到错误的非接受状态 的错误转换,更合适一些)。所以,a可将状态1与状态2和3区分开来,我们必须将状态重新分 配为集合{1}和{2,3}。然后再重复一遍。集合 {1}不能再分隔了,我们也就不再考虑它了。状 态2和状态3不能由a 或b 区分开来。因此,就得到了最小状态的 DFA: 2.5 TINY扫描程序的实现 现在开发扫描程序的真正代码以阐明本章前面所学到的概念。在这里将用到 TINY语言, 它曾在第 1章( 1.7节)中被提到过,接着再讲一些由该扫描程序引出的实际问题。 53 第 2章 词 法 分 析 2.5.1 为样本语言 TINY实现一个扫描程序 第1章只是非常简要地介绍了一下 TINY语言。现在的任务是完整地指出 TINY的词法结构, 也就是:定义记号和它们的特性。 TINY的记号和记号类都列在表 2-1中。 TINY的记号分为 3个典型类型:保留字、特殊符号和“其他”记号。保留字一共有 8个, 它们的含义类似(尽管直到很后面才需知道它们的语义)。特殊符号共有 10种:分别是4种基本 的整数运算符号、 2种比较符号(等号和小于),以及括号、分号和赋值符号。除了赋值符号是 两个字符的长度之外,其余均为一个字符。 表2-1 TINY语言的记号 保留字 if then else end repeat until read write 特殊符号 + * / = < ( ) ; := 其他 数 (1个或更多的数字) 标识符 (1个或更多的字母) 其他记号就是数了,它们是一个或多个数字以及标识符的序列,而标识符又是(为了简便) 一个或多个字母的序列。 除了记号之外,TINY还要遵循以下的词法惯例:注释应放在花括号 {...}中,且不可嵌套; 代码应是自由格式;空白格由空格、制表位和新行组成;最长子串原则后须接识别记号。 在为该语言设计扫描程序时,可以从正则表达式开始并根据前一节中的算法来开发 NFA和 DFA。实际上,前面已经给出了数、标识符和注释的正则表达式( TINY具有更为简单的版本)。 其他记号的正则表达式都是固定串,因而均不重要。由于扫描程序的 DFA记号十分简单,所以 无需按照这个例程就可直接开发这个 DFA了。我们将按以下步骤进行。 首先要注意到除了赋值符号之外,其他所有的特殊符号都只有一个字符,这些符号的 DFA 如下: 54 编译原理及实践 在该图中,不同的接受状态是由扫描程序返回的记号区分开来。如果在这个将要返回的记 号(代码中的一个变量)中使用其他指示器,则所有接受状态都可集中为一个状态,称之为 DONE。若将这个二状态的DFA与接受数和标识符的DFA合并在一起,就可得到下面的 DFA: 请注意,利用方括号指出了不可被消耗的先行字符。 现在需要在这个 DFA中添加注释、空白格和赋值。一个简单的从初始状态到其本身的循环 要消耗空白格。注释要求一个额外的状态,它由花括号左边达到并在花括号右边返回到它。赋 值也需要中间状态,它由分号上的初始状态达到。如果后面紧跟有一个等号,那么就会生成一 个赋值记号。反之就不消耗下一个字符,且生成一个错误记号。实际上,未列在特殊符号中的 所有单个字符既不是空白格或注释,也不是数字或字母,它们应被作为错误而接受,我们将它 们与单个字符符号混合在一起。图 2-8是为扫描程序给出的最后一个 DFA。 图2-8 TINY扫描程序的DFA 在上面的讨论或图 2-8中的DFA都未包括保留字。这是因为根据 DFA的观点,而认为保留 字与标识符相同,以后再在接受后的保留字表格中寻找标识符是最简单的。当然,最长子串原 则保证了扫描程序唯一需要改变的动作是被返回的记号。因而,仅在识别了标识符之后才考虑 保留字。 现在再来讨论实现这个 DFA的代码,它已被放在了 scan.h文件和scan.c文件之中(参 55 第 2章 词 法 分 析 见附录B)。其中最主要的过程是 getToken(第674到第793行),它消耗输入字符并根据图 2-8 中的DFA返回下一个被识别的记号。这个实现利用了在2.3.3节中曾提到过的双重嵌套情况分析, 以及一个有关状态的大型情况列表,在大列表中的是基于当前输入字符的单独列表。记号本身 被定义成globals.h(第174行到第186行)中的枚举类型,它包括在表 2-1中列出的所有记号 以及内务记号 EOF(当达到文件的末尾时)和 ERROR(当遇到错误字符时)。扫描程序的状态 也被定义为一个枚举类型,但它是位于扫描程序之中(第 612行到第614行)。 扫描程序还需总地计算出每个记号的特性(如果有的话),并有时会采取其他动作(例如 将标识符插入到符号表中)。在TINY扫描程序中,所要计算的唯一特性是词法或是被识别的记 号的串值,它位于变量 tokenString之中。这个变量同 getToken一并是提供给编译器其他 部分的唯一的两个服务,它们的定义已被收集在头文件 scan.h(第550行到第571行)。请读 者注意声明了tokenString的长度固定为 41,因此那个标识符也就不能超过 40个字符(加上 结尾的空字符)。后面还会提到这个限制。 扫描程序使用了3个全程变量:文件变量 source和listing,在globals.h中声明且在 main.c中被分配和初始化的整型变量 lineno。 由getToken过程完成的额外的簿记如下所述:表 reservedWords(第649行到第 656行) 和过程 reservedLookup(第658行到第666行)完成位于由 getToken的主要循环识别的标 识符之后的保留字的查找, currentToken的值也随之改变。标志变量 save被用作指示是否 将一个字符增加到 tokenString之上;由于需要包括空白格、注释和非消耗的先行,所以这 些都是必要的。 到扫描程序的字符输入由 getNextChar函数(第627行到第642行)提供,该函数将一个 256-字符缓冲区内部的 lineBuf中的字符取到扫描程序中。如果已经耗尽了这个缓冲区,且假 设每一次都获取了一个新的源代码行(以及增加的 lineno),那么getNextChar就利用标准 的C过程fgets从source文件更新该缓冲区。虽然这个假设允许了更简单的代码,但却不能 正确地处理行的字数超过 255个字符的 TINY程序。在练习中,我们再探讨在这种情况下的 getNextChar的行为(以及它更进一步的行为)。 最后,TINY中的数与标识符的识别要求从 INNUM和INID到最终状态的转换都应是非消耗 的(参见图 2-8)。可以通过提供一个 ungetNextChar过程(第644行到第647行)在输入缓冲 区中反填一个字符来完成这一任务,但对于源行很长的程序而言,这也不是很好,练习将提到 其他的方法。 作为TINY扫描程序行为的解释,读者可考虑一下程序清单2-3中TINY程序sample.tny(在 第1章中已作为一个示例给出了)。程序清单2-4假设将这个程序作为输入,那么当TraceScan和 EchoSource都是集合时,它列出了扫描程序的输出。 本节后面将详细讨论由这个扫描程序的实现所引出的一些问题。 程序清单 2-3 TINY语言中的样本程序 56 编译原理及实践 程序清单2-4 当程序清单 2-3中的TINY程序作为输入时,扫描程序的输出 2.5.2 保留字与标识符 T I N Y对保留字的识别是通过首先将它们看作是标识符,之后再在保留字表中查找它们来 57 第 2章 词 法 分 析 完成的。这在扫描程序中很平常,但它却意味着扫描程序的效率须依赖于在保留字表中查找过 程的效率。我们的扫描程序使用了一种非常简便的方法—线性搜索,即按顺序从开头到结尾 搜索表格。这对于小型表格不成问题,例如 TINY中的表格,它只有8个保留字,但对于真实语 言而言,这却是不可接受的,因为它通常有 30~60个保留字。这时就需要一个更快的查找,而 这又要求使用更好的数据结构而不是线性列表。假若保留字列表是按字母表的顺序写出的,那 么就可以使用二分搜索。另一种选择是使用杂凑表,此时我们希望利用一个冲突性很小的杂凑 函数。由于保留字不会改变(至少不会很快地),所以可事先开发出这样一个杂凑函数,它们 在表格中的位置对于编译器的每一步运行而言都是固定的。人们已经确定了各种语言的最小完 善杂凑函数(minimal perfect hash function),也就是说能够区分出保留字且具有最小数值的函 数,因此杂凑表可以不大于保留字的数目。例如,如果只有 8个保留字,则最小完善杂凑函数 总会生成一个0~7的值,且每个保留字也会生成不同的值(参见“注意与参考”一节)。 在处理保留字时,另一个选择是使用储存标识符的表格,即:符号表。在过程开始之前, 将所有的保留字整个输入到该表中并且标上“保留”(因此不允许重新定义)。这样做的好处在 于只要求一个查找表。但在 TINY扫描程序中,直到扫描阶段之后才构造符号表,因此这个方 法对于这种类型的设计并不合适。 2.5.3 为标识符分配空间 TINY扫描程序设计中的另一个缺点是记号串最长仅为 40个字符。由于大多数的记号的大 小都是固定的,所以对于它们而言这并不是问题;但是对于标识符来讲就麻烦了,这是因为程 序设计语言经常要求程序中的标识符长度为任意值。更糟的是:如果为每一个标识符都分配一 个40个字符长度的数组,那么就会浪费掉大多数的空间;这是因为绝大多数的标识符都很短。 由于使用了实用程序函数 copyString复制记号串,其中 copyString函数动态地分配仅为所需 的空间(如同将在第 4 章 看 到 的 一 样 ), T I N Y 编译器的代码就不会出现这个问题了。 TokenString长度限制的解决办法与之类似:仅仅基于需要来分配,有可能使用 realloc标 准C函数。另一种办法是为所有的标识符分配最初的大型数组,接着再在该数组中按照自己做 的方式进行存储器的分配(这是将在第 7章要谈到的标准动态存储器管理计划的特殊情况)。 2.6 利用Lex自动生成扫描程序 本节将重复前一节完成的用于 TINY语言的扫描程序的开发,但此次是利用 Lex扫描程序生 成器从作为正则表达式的 TINY记号的描述中生成一个扫描程序。由于 Lex存在着多个不同的版 本,所以我们的讨论仅限于对于所有的或大多数版本均通用的特征。 Lex最常见的版本是 f l e x (Fast Lex),它是由Free Software Foundation创建的Gnu compiler package 的一部分,可以在 许多Internet 站点上免费得到。 Lex 是一个将包含了正则表达式的文本文件作为其输入的程序,此外还包括每一个表达式 被匹配时所采取的动作。 Lex生成一个包含了定义过程 yylex的C源代码的输出文件,其中 yylex是与输入文件相对应的 DFA表驱动的实现,它的运算与 getToken过程类似。接着编译通 常称作 lex.yy.c或lexyy.c的Lex输出文件,并将它们链接到一个主程序上以得到一个可运 行的程序,这与在前一节中将 scan.c文件与tiny.c文件链接相似。 下面将首先讨论用于编写正则表达式的 Lex惯例和Lex输入文件的格式,之后还会谈到附录 58 编译原理及实践 B中给出的 TINY扫描程序的 Lex输入文件。 2.6.1 正则表达式的Lex约定 Lex 约定与2.2.3节中所谈到的十分相似,但它并不列出所有的 Lex元字符且不逐个地描述 它们,我们将给出一个概述,之后再在一个表格中写出 Lex 约定。 Lex允许匹配单个字符或字符串,只需像前面各节中所做地一样按顺序写出字符即可。 Lex 还允许通过将字符放在引号中而将元字符作为真正的字符来匹配。引号可用于并不是元字符的 字符前后,但此时的引号却毫无意义。因此,在要被直接匹配的所有字符前后使用引号很有意 义,而不论该字符是否为元字符。例如,可以用 if或"if"来匹配一个 if语句开始的保留字if。 另一方面,如要匹配一个左括号,就必须写作 "(",这是因为左括号是一个元字符。另一个方 法是利用反斜杠元字符 \,但它只有在单个元字符时才起作用:如要匹配字符序列 (*,就必须 重复使用反斜杠,写作 \(\*。很明显,"(*"更好一些。另外将反斜杠与正规字符一起使用就 有了特殊意义。例如: \n匹配一新行, \t匹配一个制表位(这些都是典型的 C约定,大多数这 样的约定在 Lex中也可行)。 Lex按通常的方法解释元字符 *、+、(、)和|。Lex 还利用问号作为元字符指示可选部分。 为了说明前面所讲到的Lex表示法,可为a 串和b 串的集合写出正则表达式,其中这些串是以 aa 或bb开头,末尾则是一个可选的 c: (aa|bb)(a|b)*c? 或写作: ("aa"|"bb")("a"|"b")*"c"? 字符类的Lex 约定是将字符类写在方括号之中。例如 [abxz]就表示a、b、x 或z 中的任意 一个字符,此外还可在Lex 中将前面的正则表达式写作: (aa|bb)[ab]*c? 在这个格式的使用中还可利用连字符表示出字符的范围。因此表达式 [0-9]表示在Lex 中,任 何一个从 0~9的数字。句点也是一个表示字符集的元字符:它表示除了新行之外的任意字符。 互补集合——也就是不包含某个字符的集合——也可使用这种表示法:将插入符 ^作为括号中 的第1个字符,因此 [^0-9abc]就表示不是任何数字且不是字母 a、b 或c 中任何一个的其他任 意字符。 例:为一个标有符号的数集写出正则表达式,这个集合可能包含了一个小数部分或一个以 字母E开头的指数部分(在 2.2.4节中,这个表达式的写法略有不同): ("+"|"-")?[0-9]+("."[0-9]+)?(E("+"|"-")?[0-9]+)? Lex有一个古怪的特征:在方括号(表示字符类)中,大多数的元字符都丧失了其特殊状 况,且无需用引号引出。甚至如果可以首先将连字符列出来的话,则也可将其写作正则字符。 因此,可将前一个数字的正则表达式 ("+"|"-")写作[-+],(但不可写作 [+-],这是因为元 字符“-”用于表示字符的一个范围)。又例如: [."?]表示了句号、引号和问号 3个字符中的 任一个字符(这 3个字符在括号中都失去了它们的元字符含义)。但是一些字符即使是在方括号 中也仍旧是元字符,因此为了得到真正的字符就必须在字符前加一个反斜杠(由于引号已失去 了它们的元字符含义,所以不能用它),因此[\^\\]就表示了真正的字符^或\。 L e x中一个更为重要的元字符约定是用花括号指出正则表达式的名字。在前面已经提到过 可以为正则表达式起名,而且只要没有递归引用,这些名字也可使用在其他的正则表达式中。 59 第 2章 词 法 分 析 例如:将 2.2.4节中的signedNat 定义如下: nat = [ 0 - 9 ] + s i g n e d N a t= ( "+"|"-")? nat 在本例和其他示例中,我们使用斜体字将名字和普通的字符序列区分开来。但是 Lex 文件是普 通的文本文件,因此无需使用斜体字。相反地, Lex 却遵循将前面定义的名字放在花括号中的 约定。因此,上一例在Lex 中就表示为(Lex 还在定义名字时与等号一起分配): nat [0-9] + signedNat (+|-)? {nat} 请注意,在定义名字时并未出现花括号,它只在使用时出现。 表2-2是讨论过的Lex元字符约定的小结列表。 Lex 中还有许多我们用不到的元字符,这里 也就不讲了(参见本章末尾的“注意与参考”)。 表2-2 Lex中的元字符约定 格式 a "a" \a a* a+ a? a|b (a) [abc] [a-d] [^ab] . {xxx} 含义 字符a 即使a 是一个元字符,它仍是字符 a 当a 是一个元字符时,为字符 a a 的零次或多次重复 a 的一次或多次重复 一个可选的 a a 或b a 本身 字符a、b 或c 中的任一个 字符a、b、c 或d 中的任一个 除了a 或b 外的任一个字符 除了新行之外的任一个字符 名字xxx 表示的正则表达式 2.6.2 Lex 输入文件的格式 Lex输入文件由 3个部分组成:定义( definition)集、规则( rule)集以及辅助程序 (auxiliary routine)集或用户程序( user routine)集。这3个部分由位于新一行第 1列的双百分 号分开,因此,Lex输入文件的格式如下所示: {definitions} %% {rules} %% {auxiliary routines} 为了正确理解Lex如何解释这样的输入文件,就必须记住该文件的一些部分是正则表达式 信息,Lex利用这个信息指导构成它的 C输出代码,而文件的另一部分则是提供给 Lex的真正的 C代码,Lex会在适当的位置逐字地将它插入到输出代码中。在我们逐个解释完这 3个部分以及 给出一些示例之后,将会告诉大家 Lex 在这里所用的规则。 定义部分出现在第1个双百分号之前。它包括两样东西:第 1件是必须插入到应在这一部分 60 编译原理及实践 中分隔符“ %{”和“%}”之间的任何函数外部的任意 C代码(请注意这些字符的顺序)。第2件 是正则表达式的名字也得在该部分定义。这个名字的定义写在另一行的第 1列,且其后(后面 有一个或多个空格)是它所表示的正则表达式。 第2个部分包含着一些规则。它们由一连串带有 C代码的正则表达式组成;当匹配相对应的 正则表达式时,这些 C代码就会被执行。 第3个部分包括着一些 C代码,它们用于在第 2个部分被调用且不在任何地方被定义的辅助 程序。如果要将 Lex输出作为独立程序来编译,则这一部分还会有一个主程序。当第 2个双百分 号无需写出时,就不会出现这一部分(但总是需要写出第 1个百分号)。 下面给出一些示例来说明 Lex输入文件的格式。 例2.20 以下的Lex输入指出一个给文本添加行号的扫描程序,它将其输出发送到屏幕上(如 果被重定向,则是一个文件): %{ /* a Lex program that adds line numbers to lines of text, printing the new text to the standard output */ #include int lineno = 1; %} line *.\n %% {line} { printf ( "%5d %s", lineno++, yytext ); } %% main() { yylex(); return 0; } 例如,运行从这个输入文件本身的 Lex中获取的程序会得到以下输出: 1 %{ 2 /* a Lex program that adds line numbers 3 to lines of text, printing the new text 4 to the standard output 5 */ 6 #include 7 int lineno = 1; 8 %} 9 line .*\n 10 %% 11 { line } { printf ( "%5d %s", lineno++,yytext); } 12 %% 13 main ( ) 14 { yylex( ) ; return 0; } 下面为这个使用了这些行号的 Lex 输入文件作出解释。首先,第 1行到第8行都是位于分隔 符%{和%}之间,这样就使这些行可以直接插入到由 Lex 产生的C代码中,而它是位于任何过程 的外部。特别是从第 2行到第 5行的注释可以插入到程序开头的附近,还将从外部插入 #include指示与第 6行和第7行上的整型变量 lineno的定义,因此 lineno就变成了一个全 程变量且在最初被赋值为 1。出现在第1个%%之前的其他定义是名字line的定义,line被定义 61 第 2章 词 法 分 析 为正则表达式 ".*\n",它与零个或多个其后接有一新行的字符匹配(但不包括新行)。换而 言之:由line定义的正则表达式与输入的每一行都匹配。在第 10行的%%之后,第 11行包括了 Lex 输入文件的行为部分。此时每当匹配了一个 line时,都写下了一个要完成的行为(根据 Lex 约定,line前后都用花括号以示与其作为一个名字相区别)。正则表达式之后是action, 即每当匹配正则表达式都要被执行的 C代码。在这个示例中,该行为由包含在一个 C块的花括 号中的C语句组成(请记住在名字 line前后的花括号与构成下面行为中的 C代码块的花括号有完 全不同的作用)。这个C语句将打印行号(在一个有 5个空格的范围内且右对齐)以及在它后面 要增加lineno的串yytext。yytext的名字是Lex 赋予并由正则表达式匹配的串的内部名字, 此时的正则表达式是由输入的每一行组成(包括新行) 。最后,当Lex 生成C代码结束时在第 2个双百分号(第 13行和第14行)之后插入 C代码。在本例中,代码包括了一个调用函数 yylex 的main过程(yylex是由Lex构造的过程的名字,这个Lex 实现了与正则表达式和在输入文件 的行为部分中与给出的行为相关的 DFA)。 例2.21 考虑下面的Lex输入文件: %{ /* a Lex program that changes all numbers from decimal to hexadecimal notation, printing a summary statistic to stderr */ #include #include int count = 0; %} digit [0-9] number {digit}+ %% {number} { int n = atoi (yytext); printf ("%x", n); if (n > 9) count++;} %% main( ) { yylex ( ); fprintf ( stderr, "number of replacements = %d", count); return 0 ; } 它在结构上与前例类似,但 main过程打印了在调用了yylex之后替换到stderr的次数。这个 例子与前例的不同还在于它并没有匹配所有的文本。而实际上只在行为部分匹配了数字;在行 为部分中,行为的 C代码第1次将匹配串( yytext)转变成一个整型 n,接着又将其打印为十 六进制的格式 (printf("%x",...)),最后如果这个数大于 9则增加count(如果小于或等 于9,则与在十六进制中没有分别)。因此,为串指定的唯一行为就是数字序列。 Lex还生成了 一个可匹配所有非数字字符的程序,并将它传送到输出中。这是 Lex 的一个缺省行为( default action)的示例。如果字符或字符串与行为部分中的任何一个正则表达式都不匹配,则缺省地, 本节最后将用一个表格列出这一节中所讨论过的 Lex 内置名字。 62 编译原理及实践 Lex 将会匹配它并将它返回到输出中( Lex 还可被迫生成一个程序错误,但这里不讨论它了)。 Lex 的内部定义宏ECHO也可特别指定缺省行为(下一个示例将会学到它)。 例2.22 考虑下面的Lex 输入文件: %{ /* Selects only lines that end or begin with the letter 'a'. Deletes everything else. */ #include %} ends_with_a .*a\n begins_with_a a.*\n %% {ends_with_a} ECHO; {begins_with_a} ECHO; .*\n ; %% main( ) { yylex( ); return 0; } 这个Lex 输入将以字符 a 开头或结尾的所有输入行均写到输出上,并消除其他行。行的消除是 由ECHO规则下的规则引起的,在这个规则中,为 C行为代码编写一个分号就可为正则表达 式.*\n指定“空”行为。 这个Lex 输入还有一个值得注意的特征:所列的规则具有二义性( ambiguous),这是因为 串可匹配多个规则。实际上,无论它是否是以 a 开头或结尾的行的一部分,任何输入行都可与 表达式.*\n匹配。Lex 有一个解决这种二义性的优先权系统。首先, Lex 总是匹配可能的最长 子串(因此 Lex 总是生成符合最长子串原则的扫描程序)。接着,如果最长子串仍与两个或更 多的规则匹配, Lex 就选取在行为部分所列的第 1个规则。正是由于这个原因,上面的 Lex 输 入文件就将 ECHO行为放在第 1个。如果已按下面的顺序列出行为: .*\n; {ends_with_a} ECHO; {begins_with_a} ECHO; 则由Lex 生成的程序就不会再生成任何文件的输出,这是因为第 1个规则已匹配了输入的每一 行了。 例2.23 在本例中,Lex 生成了将所有的大写字母转变成小写字母的程序,但这不包括 C- 风格 注释中的字母(即:任何位于分隔符 /*...*/之间的字母): %{ /* Lex program to convert uppercase to lowercase except inside comments */ #include #ifndef FALSE #define FALSE 0 #endif #ifndef TRUE 63 第 2章 词 法 分 析 #define TRUE 1 #endif %} %% [A-Z] {putchar(tolower(yytext[0])); /* yytext[0] is the single uppercase char found */ } "/*" { char c ; int done = FALSE; ECHO; do { while ((c=input())!='*') putchar(c); putchar(c); w h i l e ( ( c = i n p u t ( ) ) = = ' * ') putchar(c); putchar(c); if (c == '/') done = TRUE; } while (!done); } %% void main(void ) { yylex();} 这个示例显示如何编写代码以回避较难的正则表达式,并且像执行一个 Lex 行为一样直接 执行一个小的DFA。读者可以回忆一下 2.2.4节中用C注释的一个正则表达式极难编写,相反地, 我们只为开始 C注释的串编写了正则表达式即: "/*",之后还提供了搜索结束串 "*/"的行为 代码,同时为注释中的其他字符提供适当的行为(此时仅是返回它们而不是继续进行)。这是 通过模拟例2.9中的DFA来完成的(参见图2-4)。一旦识别出串"/*",则就是在状态 3,因此代 码就在这里找到了 DFA。首先做的事情是在字符中循环直到看到一个星号(与状态 3中的 other循环相对应)为止,如下所示: while ((c=input())!='*') putchar(c); 这里又使用了Lex 另一个内部过程input,该过程的使用——并不是利用getchar的一个直接 输入——保证使用了Lex 输入缓冲区,而且还保留了这个输入串的内部结构(但请注意,我们 确实使用了一个直接输出过程 putchar,2.6.4节将谈到它)。 DFA代码的下一步与状态 4相对应。再次循环直到看不到星号为止;之后如在字符前有一 个前斜杠就退出;否则就返回到状态 3中。 本节的最后是小结在各例中介绍到的 Lex约定。 (1) 二义性的解决 L e x输出总是首先将可能的最长子串与规则相匹配。如果某个子串可与两个或更多的规则 匹配,则Lex的输出将找出列在行为部分中的第 1个规则。如果没有规则可与任何非空子串相匹 配,则缺省行为将下一个字符复制到输出中并继续下去。 (2) C代码的插入 1) 任何写在定义部分 %{和%}之间的文本将被直接复制到外置于任意过程的输出程序之中。 2) 辅助过程中的任何文本都将被直接复制到 Lex 代码末尾的输出程序中。 3) 将任何跟在行为部 64 编译原理及实践 分(在第 1个%%之后)的正则表达式之后(中间至少有一个空格)的代码插入到识别过程 yylex的恰当位置,并在与对应的正则表达式匹配时执行它。代表一个行为的 C代码可以既是 一个C语句,也可以是一个由任何说明及由位于花括号中的语句组成的复杂的 C语句。 (3) 内部名字 表2-3列出了在本章中所提到过的 Lex内部名字,大多数都已在前面的示例中讲过了。 表2-3 一些Lex内部名字 Lex 内部名字 lex.yy.c或lexyy.c yylex yytext yyin yyout input ECHO 含义/使用 Lex 输出文件名 Lex 扫描例程 当前行为匹配的串 Lex 输入文件(缺省: stdin) Lex 输出文件(缺省: stdout) Lex 缓冲的输入例程 Lex 缺省行为(将 yytext打印到yyout) 上表中有一个前面未曾提到过的特征: Lex 为一些文件备有其自身的内部名字: yyin和 yyout,Lex 从这些文件中获得输入并向它们发送输出。通过标准的 Lex 输入例程input就可 自动地从文件 yyin中得到输入。但是在前述的示例中,却回避了内部输出文件 yyout,而只 通过printf和putchar写到标准输出中。一个允许将输出赋到任一文件中的更好的实现方法 是用fprintf(yyout,...)和putc(...,yyout)取代它们。 2.6.3 使用Lex 的TINY扫描程序 附录B中有一个Lex 输入文件tiny.1的列表,tiny.1将生成TINY语言的扫描程序( 2.5 节已描述了 TINY语言的记号,参见表 2-1)。下面对这个输入文件(第 3000行到第3072行)做 一些说明。 首先,在定义部分中,直接插入到Lex 输出中的C代码是由3个#include指示(globals.h、 util.h和scan.h)及tokenString特性组成的。在扫描程序和其他的 TINY编译器之间有 必要提供一个界面。 定义部分更深的内容还包括了定义 TINY记号的正则表达式的名字的定义。请注意, number的定义利用了前面定义的名字 digit,而 identifier的定义利用了前面定义的 letter。由于新行会导致增加 lineno,所以定义还区分了新行和其他的空白格(空格和制 表位,以及第3019行和第3020行)。 Lex输入的行为部分由各种记号的列表和 return语句组成,其中 return语句返回在 globals.h中定义的恰当记号。在这个 Lex定义中,在标识符规则之前列出了保留字规则。假 若首先列出标识符规则, Lex 的二义性解决规则就会总将保留字识别为标识符。我们还可以写 出与前一节中的扫描程序中相同的代码,在这里只能识别出标识符,然后再在表中查找保留字。 由于单独识别的保留字使得由 Lex 生成的扫描程序代码中的表格变得很大(而且扫描程序使用 的存储器也会因此变得很大),因此在真正的编译中倾向于使用它。 Lex 输入还有一个“怪僻”:即使TINY注释的正则表达式很容易书写,也必须编写识别注 释的代码以确保正确地更新了 lineno。正则表达式实际是: 65 第 2章 词 法 分 析 "{"[^\}]*"}" (请注意,方括号中的花括号的作用是删除右花括号的元字符含义——引号在这里不起作用) 。 我们还注意到:并未为在遇到输入文件末尾时返回 EOF编写代码。Lex 过程yylex在遇到 EOF时有一个缺省行为——它返回 0值。正是由于这个原因,在 globals.h中的TokenType定义 (第179行)中首先写出记号 ENDFILE,所以它有 0值。 最后,tiny.1文件包括了辅助过程部分中的 getToken过程的定义(第 3056行到第3072 行)。虽然这个代码包含了 Lex内部代码(如 yyin和yyout)的一些在主程序中能更好地直接 完成的特殊初始化,它还是允许直接使用 Lex生成的扫描程序,而无需改变 TINY编译器中的任 何其他文件。实际上在生成 C扫描程序 lex.yy.c(或lexyy.c)后,就可编译这个文件,并 可将它与其他的TINY源文件链接以生成一个基于 Lex版本的编译器了。但是这个版本的编译器 却缺少了以前版本的一项服务,这是因为没有源代码回应提供的行号(参见练习 2.35)。 练习 2.1 为以下的字符集编写正则表达式;若没有正则表达式,则说明原因: a. 以a 开头和结尾的所有小写字母串。 b. 以a 开头或/和结尾的所有小写字母串。 c. 第1个不为0的所有数字串。 d. 所有表示偶数的数字串。 e. 每个2均在每个9之前的所有数字串。 f. 所有的a 串和b 串,且不包含3个连续的b。 g. 包含单数个a 或/和单数个b 的所有a 串和b 串。 h. 包含偶数个a 或偶数个b 的所有a 串和b 串。 i. a 和b 数目相等的所有a 串和b 串。 2.2 为由以下正则表达式生成的语言写出英语描述: a. (a|b)*a(a|b| ) b. (A|B|...|Z)(a|b|...|z)* c. (aa|b)*(a|bb)* d. (0|1|...|9|A|B|C|D|E|F)+(x|X) 2.3 a. 许多系统都有 grep(global regular expression print)的一个版本,它是最早为 Unix 编写的正则表达式搜索程序 。寻找一个描述你的本地 grep文档,并描述它的元符 号约定。 b. 如果你的编辑器为它的串搜索接受某种正则表达式,则描述它的元符号约定。 2.4 在正则表达式的定义中,我们讲了一些运算的优先问题,但并未提到它们之间的联系。 例如并未指出 a|b|c 表示的是 (a|b)|c 还是a|(b|c)以及与并置是否相似,为什么 是这样的呢? 2.5 试证明对于任何正则表达式r 都有L (r**) = L (r*)。 2.6 在使用正则表达式描述的程序设计语言的记号中,并不需要有元符号 (空集)或 (空串)。为什么? Lex的一些版本有一个内部定义的变量 yylineno,它可以自动更新。用这个变量代替 Lineno就有可能省掉特殊 代码了。 大多数的Unix系统上实际有3个版本的grep:“regular”grep、egrep (extended grep)和fgrep (fast grep)。 66 编译原理及实践 2.7 画出与正则表达式 相对应的DFA。 2.8 为练习2.1中a至i的每个字符集画出DFA,或说出为什么不存在DFA? 2.9 画出从C语言中接受以下 4个保留字case、char、const和continue的DFA。 2.10 利用将输入字符作为外部情况测试及将状态作为内部情况测试,重写用于 C注释的 DFA实现(2.3.3节)的伪代码。将你的伪代码与书中的作一比较。什么时候为代码 实现DFA使用这个组织? 2.11 给NFA的状态集的闭包下一个数学定义。 2.12 a. 使用Thompson结构将正则表达式 (a|b)*a(a|b| )转化成一个NFA。 b. 利用子集结构将a中的NFA转化成一个DFA。 2.13 a. 使用Thompson结构将正则表达式 (aa|b)*a(a|bb)*转化成一个NFA。 b. 利用子集结构将a中的NFA转化成一个DFA。 2.14 利用子集结构将例 2.10(2.3.2节)中的NFA转化成一个DFA。 2.15 2.4.1节讲到了为了并置将 Thompson结构简化,即:省略被并置的正则表达式的两个 NFA之间的 - 转换。另外还提到这种简化在结构的其他步骤中,除接受状态之外不 能再有转换。请给出一个例子来说明这一点(提示:考虑用于重复的一个新 NFA结 构,它省略了新初始状态和接受状态,再为 r*s*考虑NFA)。 2.16 为下面的DFA提供2.4.4节中用到的状态最小化算法: a. b. 2.17 Pascal注释中允许有两个不同的注释约定:花括号对 {...}(当在TINY中)和括号星号对(*...*)。试写出一个识别这两种风格的注释的 DFA。 2.18 a. 为Lex表示法中的C注释写出一个正则表达式(提示:参见 2.2.3节中的讨论)。 b. 试证明a中的答案是正确的。 2.19 下面的正则表达式已作为 C注释的一个 Lex定义给出(参见 Schreiner和Friedman [1985.p.25]): 67 第 2章 词 法 分 析 "/*""/"*([^*/]|[^*]"/"|"*"[^/])*"*"*"*/" 请说明这个表达式是不正确的(提示:考虑串 /**_/*/)。 编程练习 2.20 编写将一个C程序中的所有注释字母均大写的程序。 2.21 编写一个程序,使之将一个 C程序注释之外的所有保留字全部大写(在 Kernighan和 Ritchie [1988,p.192]中可找到一个C的保留字列表)。 2.22 编写一个Lex输入文件,使之可生成将 C程序中所有注释的字母均大写的程序。 2.23 编写一个Lex输入文件,使之可生成将 C程序注释之外的所有保留字均大写的程序。 2.24 编写一个Lex输入文件,使之生成可计算文本文件的字符、单词和行数且能报告该数 字的程序。试定义一个单词是不带标点或空格的字母和 /或数字的序列。标点和空白 格不计算为单词。 2.25 通过能将注释中的行为与其他任何地方的行为区别开来的一个全程标志 inComment, 可缩短例 2.23(2.6.2节中)的 Lex代码。按上所述重写这个示例中的代码。 2.26 向例2.23中的Lex 代码添加嵌套的C注释。 2.27 a. 重写TINY的扫描程序,使之能利用二分法搜索保留字。 b. 重写TINY的扫描程序,使之能利用杂凑表查找保留字。 2.28 通过为tokenString动态地分配空间来去除对于 TINY扫描程序中的标识符不可多 于40个字符的限制。 2.29 a. 当源程序行超过扫描程序中的缓冲区大小时,测试 TINY扫描程序的行为,同时找 出尽可能多的问题。 b. 重写TINY扫描程序以解决在a中发现的问题(或至少改善其行为)(此时要求重写 getNextChar和ungetNextChar过程)。 2.30 如果要求在TINY扫描程序中不用 ungetNextChar过程来完成非消费的转换,则也 可使用布尔标志指出要消费的当前字符,这样就无需在输入中进行备份了。请按照 这个方法重写TINY扫描程序,并将它与现存的代码相比较。 2.31 利用一个称为nestLevel的计数器将嵌套注释添加到TINY扫描程序中。 2.32 在TINY扫描程序中添加 Ada风格的注释( Ada注释以两个连字符开头并一直到行 结尾)。 2.33 向TINY的Lex扫描程序添加表格中的保留字的备份(可以像在手写的 TINY扫描程序 一样使用线性搜索,或使用练习 2.27中建议的搜索方法)。 2.34 在TINY扫描程序的lex代码中添加 Ada风格注释(Ada注释以两个连字符开头并一直 到行结尾)。 2.35 在TINY扫描程序的Lex代码中添加源代码行回应(利用 EchoSource标志),这样当设 置了标志时,源代码的每一行及其行数都会打印到列表文件中(此时要求比本书所 提到的更多的Lex内部知识)。 注意与参考 Hopcroft和Ullman [1979]详细讨论了正则表达式的数学理论与有穷自动机,在其中还能找 到一些对该理论的历史发展的描述。特别地,这里还有一个关于对有穷自动机与正则表达式相 68 编译原理及实践 等价的证明(本章只谈到了这个等式的一个方面)。在这里还可找到关于 pumping引理的讨论, 以及对在描述格式中正则表达式的限制的推理。此外还能看到对状态最小化算法的更详细的描 述,另外还包括了对这样的 DFA在本质上是唯一的证明。在 Aho、Hopcroft 和 Ullman[1986]中 可看到从正则表达式到 DFA的进一步构造(与这里所谈到的用两步来构造相反)的描述。此外 这里还有将表格压缩成一个表驱动的扫描程序的方法。 Sedgewick [1990]中有使用与本章所提 到的相当不同的 NFA约定的Thompson结构的描述,此外在这里还能看到为了识别保留字,描 述了二分搜索和杂凑法的算法(第 6章还要讨论到杂凑)。Cichelli [1980]和Sager [1985]都提到 了2.5.2节中的最小完善杂凑函数。一个称作 gperf的实用程序可作为 Gnu编译包的一部分来分 发,它可以快速地为保留字更大的集合生成完善的杂凑函数。虽然它们并不是最小生成的,但 在实际中仍很有用。Schmidt [1990]中有gperf的描述。 Lesk [1975]中有Lex扫描程序生成器的最早描述,它仍然对许多最近的版本有点作用。稍 后的版本,尤其是Flex ( Paxson [1990])解决了一些很重大的问题,它可以与调整得已很好的 手写的扫描程序相竞争( Jacobson[1987])。在Schreiner和Friedman[1985]中可看到Lex的一个有 用的描述,以及可完成各种格式匹配任务的简单 Lex程序的许多示例。在 Kernighan and Pike [1984]中可找到格式匹配的 grep家族(练习 2.3)的简要描述,其更为深入的讨论可在 Aho [1979]中找到。Landin [1966]和Hutton [1992]讲到了在2.2.3节中所提到过的。 第3章 上下文无关文法及分析 • 分析过程 • 上下文无关文法 • 分析树与抽象语法树 • 二义性 本章要点 • 扩展的表示法: EBNF和语法图 • 上下文无关语言的形式特性 • TINY语言的语法 分析的任务是确定程序的语法,或称作结构,也正是这个原因,它又被称作语法分析 (syntax analysis)。程序设计语言的语法通常是由上下文无关( context-free grammar)的文法规 则(grammar rule)给出,其方式同扫描程序识别的由正则表达式提供的记号的词法结构相类 似。上下文无关文法的确利用了与正则表达式中极为类似的命名惯例和运算。二者的主要区别 在于上下文无关文法的规则是递归的( recursive)。例如一般来说, if 语句的结构应允许可其中 嵌套其他的if语句,而在正则表达式中却不能这样做。这个区别造成的影响很大。由上下文无 关文法识别的结构类比由正则表达式识别的结构类大大增多了。用作识别这些结构的算法也与 扫描算法差别很大,这是因为它们必须使用递归调用或显式管理的分析栈。用作表示语言语义 结构的数据结构现在也必须是递归的,而不再是线性的(如同用于词法和记号中的一样)了。 经常使用的基本结构是一类树,称作分析树( parse tree)或语法树(syntax tree)。 同第2章相似,在学习分析算法和如何利用这些算法进行真正的分析之前需要先学习上下 文无关文法的理论,但是又与在扫描程序中的情形不同——其中主要只有一种算法方法(表示 为有穷自动机),分析涉及到要在许多属性和能力截然不同的方法中做出选择。按照它们构造 分析树或语法树的方式,算法大致可分为两种:自顶向下分析( top-down parsing)和由底向 上分析(bottom-up parsing)。对这些分析方法的详细讨论将放到以后的章节中,本章只给出分 析过程的一般性描述,之后还要学习上下文无关文法的基础理论。最后一节则从一个上下文无 关文法的角度给出 TINY语言的文法。对上下文无关文法理论和语法树熟悉的读者可以跳过本 章中间的某些内容(或将其作为复习)。 3.1 分析过程 分析程序的任务是从由扫描程序产生的记号中确定程序的语法结构,以及或隐式或显式地 构造出表示该结构的分析树或语法树。因此,可将分析程序看作一个函数,该函数把由扫描程 序生成的记号序列作为输入,并生成语法树作为它的输出: 分析程序 记号序列 语法树 记号序列通常不是显式输入参数,但是当分析过程需要下一个记号时,分析程序就调用诸如 g e t T o k e n的扫描程序过程以从输入中获得它。因此,编译器的分析步骤可减为对分析程序的 一个调用,如下所示: syntaxTree = parse(); 70 编译原理及实践 在单遍编译中,分析程序合并编译器中所有的其他阶段,这还包括了代码生成,因此也就 不需要构造显式的语法树了(分析步骤本身隐式地表示了语法树),由此就发生了一个 parse(); 调用。在编译器中更多的是多遍,此时后面的遍将语法树作为它们的输入。 语法树的结构在很大程度上依赖于语言特定的语法结构。这种树通常被定义为动态数据结 构,该结构中的每个节点都由一个记录组成,而这个记录的域包括了编译后面过程所需的特性 (即:并不是那些由分析程序计算的特性)。节点结构通常是节省空间的各种记录。特性域还可 以是在需要时动态分配的结构,它就像一个更进一步节省空间的工具。 在分析程序中有一个比在扫描程序中更为复杂的问题,这就是对于错误的处理。在扫描程 序中,如果遇到的一个字符是不正规记号的一部分,那么它只需生成一个出错记号并消耗掉这 个讨厌的字符即可(在某种意义上,通过生成一个出错记号,扫描程序就克服了发生在分析程 序上的困难)。但对于分析程序而言,它必须不仅报告一个出错信息,而且还须从错误状态恢 复( recover)并继续进行分析(去找到尽可能多的错误)。分析程序有时会执行错误修复 (error repair),此时它从提交给它的非正确的版本中推断出一个可能正确的代码版本(这通常 是在简单情况下才发生的)。错误恢复的一个尤为重要的方面是有意义的错误信息报告以及在 尽可能接近真正错误时继续分析下去。由于要到错误真正地已经发生了分析程序才会发现它, 所以做到这一点并不简单。由于错误恢复技术依赖于所使用的特定分析算法,所以本章先就不 学习它了。 3.2 上下文无关文法 上下文无关文法说明程序设计语言的语法结构。除了上下文无关文法涉及到了递归规则之 外,这样的说明与使用正则表达式的词法结构的说明十分类似。例如在一个运算中,就可以使 用带有加法、减法和乘法的简单整型算术表达式。这些表达式可由下面的文法给出: exp→exp op exp | (exp) | number op → + | - | * 3.2.1 与正则表达式比较 在第2章中为number给出的正则表达式规则如下所示: number = d i g i t d i g i*t digit = 0|1|2|3|4|5|6|7|8|9 试与上面上下文无关文法的样本作一比较。基本正则表达式规则有 3种运算:选择(由竖 线元字符表示)、并置(不带元符号)以及重复(由星号元符号提供)。此外还可用等号来表示 正则表达式的名字定义,此时名字用斜体书写以示与真正字符序列的区别。 文法规则使用相似的表示法。名字用斜体表示(但它是一种不同的字体,所以可与正则表 达式相区分)。竖线仍表示作为选择的元符号。并置也用作一种标准运算。但是这里没有重复 的元符号(如正则表达式中的星号 *),稍后还会再讲到它。表示法中的另一个差别是现在用箭 头符号“→”代替了等号来表示名字的定义。这是由于现在的名字不能简单地由其定义取代, 而需要更为复杂的定义过程来表示,这是由定义的递归本质决定的 。在我们的示例中, exp的 参见本章后面的语法规则和等式。 71 第 3章 上下文无关文法及分析 规则是递归的,其中名字 exp出现在箭头的右边。 读者还要注意到文法规则将正则表达式作为部件。在 exp规则和op规则中,实际有6个表示 语言中记号的正则表达式。其中 5个是单字符记号: +、-、*、( 和 ),另一个是名字number。 记号的名字表示数字序列。 与这个例子的格式相类似的文法规则最初是用在 Algol60语言的描述中。其表示法是由 John Backus为Algol60报告开发,之后又由Peter Naur更新,因此这个格式中的文法通常被称作 Backus-Naur范式(Backus-Naur form)或BNF文法。 3.2.2 上下文无关文法规则的说明 同正则表达式类似,文法规则是定义在一个字母表或符号集之上。在正则表达式中,这些 符号通常就是字符,而在文法规则中,符号通常是表示字符串的记号。在前一章中,我们利用 C中的枚举类型定义了在扫描程序中的记号;本章为了避免涉及到特定实现语言(例如 C)中 表示记号的细节,就使用了正则表达式本身来表示记号。此时的记号就是一个固定的符号,如 同在保留字 while中或诸如 +或:=这样的特殊符号一样,使用在第 2章曾用到的代码字体书写 串本身。对于作为表示多于一个串的标识符和数的记号来说,代码字体为斜体,这就同假设这 个记号是正则表达式的名字(这是它经常的表示)一样。例如,将 TINY语言的记号字母表表 示为集合 {if, then, else, end, repeat, until, read, write, identifier, number, +, -, *, /, =, <, (, ), ;, := } 而不是记号集(如在 TINY扫描程序中定义的一样): {IF, THEN, ELSE, END, REPEAT, UNTIL, READ, WRITE, ID, NUM, PLUS, MINUS, TIMES, OVER, EQ, LT, LPAREN, RPAREN, SEMI, ASSIGN } 假设有一个字母表, BNF中的上下文无关文法规则( context-free grammar rule in BNF)是 由符号串组成。第 1个符号是结构名字,第 2个符号是元字符→,这个符号之后是一个符号串, 该串中的每个符号都是字母表中的一个符号(即一个结构的名字)或是元符号|。 在非正式术语中,对 BNF的文法规则解释如下:规则定义了在箭头左边名字的结构。这个 结构被定义为由被竖线分隔开的选择右边的一个选项组成。每个选项中的符号序列和结构名字 定义了结构的布局。例如,前例中的文法规则: exp → exp op exp | (exp) | number op → + | - | * 第1个规则定义了一个表达式结构(用名字exp)由带有一个算符和另一个表达式的表达式, 或一个位于括号之中的表达式,或一个数组成。第 2个规则定义一个算符(利用名字 op)由符 号+、-或*构成。 这里所用的元符号和惯例与广泛使用中的类似,但读者应注意到这些惯例并没有统一的 标准。实际上,用以代替箭头元符号→的通常有 =(等号)、:(冒号)和 ::=(双冒号等号)。 在普通文本文件中,找到能替代斜体用法的方法也很有必要,通常的办法是在结构名字前后 加尖括号 <...>并将原来斜体的记号名字大写;因此,使用不同的惯例,上面的文法规则就可 变为: ::= | ( ) | NUMBER ::= + | - | * 每一个作者都还有这些表示法的其他变形。本节后面将会谈到一些非常重要的变形(其中 72 编译原理及实践 一些有时也会碰到)。这里要先讲一下有关表示法方面的另外两个较小的问题。 在BNF的元符号中使用括号有时很有用,这同括号可在正则表达式中重新安排优先权很相 似。例如,可将上面的文法规则重写为如下一个单一的文法规则: exp → exp ("+" | "-" | "*") exp | "("exp")" | number 在这个规则中,括号很必要,它用于将箭头右边的表达式之间的算符选择组合在一起,这 是因为并置优先于选择(同在正则表达式中一样)。因此,下面的规则就具有了不同(但不正 确)的含义: exp → exp "+" | "-" | "*" exp | "("exp")" | number 请读者再留意一下:当将括号包含为一个元符号时,就有必要区分括号记号与元符号,这 一点是通过将括号记号放在引号中做到的,这同在正则表达式中的一样(为了具有连贯性,也 将算符符号放在引号中)。 由于经常可将括号中的部分分隔成新的文法规则,所以在 BNF中的括号并不像元符号一样 缺之不可。实际上如果允许在箭头左边的相同名字可出现任意次,那么由竖线元符号给出的选 择运算在文法规则中也不是一定要有的。例如,简单的表达式文法可写作: exp → exp op exp exp → (exp) exp → number op → + op → op → * 然而,通常把文法规则写成每个结构的所有选择都可在一个规则中列出来,而且每个结构 名字在箭头左边只出现一次。 有时我们需要为说明简便性而给出一些用简短表示法写出的文法规则的示例。在这些情形 中,应将结构名字大写,并小写单个的记号符号(它们经常仅仅是一个字符);因此,按这种 速记方法可将简单的表达式文法写作: E → E O E|( E ) | n O→+|-|* 有时当正在将字符如同记号一样使用时,且无需使用代码字体来书写它们,则也可简化表 示法如下: E → E O E |( E ) | a O→+|-|* 3.2.3 推导及由文法定义的语言 现在讨论文法规则如何确定一种“语言”或者是记号的正规串集。 上下文无关文法规则确定了为由规则定义的结构的记号符号符合语法的串集。例如,算术 表达式 (34-3)*42 与7个记号的正规串相对应 ( number - number ) * number 其中number记号具有由扫描程序确定的结构且串本身也是一个正规的表达式,这是因为 73 第 3章 上下文无关文法及分析 每一个部分都与由文法规则 exp → exp op exp | (exp) | number op → + | - | * 给出的选择对应。另一方面,串 (34-3*42 就不是正规的表达式,这是因为左括号没有一个右括号与之匹配,且 exp的文法规则中的 第2个选择要求括号需成对生成。 文法规则通过推导确定记号符号的正规串。推导( derivation)是在文法规则的右边进行选 择的一个结构名字替换序列。推导以一个结构名字开始并以记号符号串结束。在推导的每一个 步骤中,使用来自文法规则的选择每一次生成一个替换。 例如,图3-1利用同在上面简单表达式文法中的一样给出的文法规则为表达式 (34-3)*42 提供了一个推导。在每一步中,为替换所用的文法规则选择都放在了右边(还为便于在后面引 用为每一步都编了号)。 图3-1 算术表达式(34-3)*42 的推导 请注意,推导步骤使用了与文法规则中的箭头元符号不同的箭头,这是由于推导步骤与文 法规则有一点差别:文法规则定义( define),而推导步骤却通过替换来构造( construct)。在 图3-1的第1步中,串exp op exp从规则exp→exp op exp的(在BNF中对于exp的第1个选择)右 边替换了单个的exp。在第2步中,串exp op exp中的exp被符号number从选择exp→number的 右边替换掉以得到串 exp op number。在第3步中,符号 *从规则op→*中替换op(在BNF中对 于op的第3个选择)以得到串 exp * number,等等。 由推导从 exp符号中得到的所有记号符号的串集是被表达式的文法定义的语言( language defined by the grammar)。这个语言包括了所有合乎语法的表达式。可将它用符号表示为: L (G) = {s | exp ⇒ *s } 其中G代表表达式文法,s 代表记号符号的任意数组串(有时称为句子( sentence)),而符 号⇒*表示由如前所述的替换序列组成的推导(星号用作指示步骤的序列,这与在正则表达式 中指示重复很相像)。由于它们通过推导“产生” L (G)中的串,文法规则因此有时也称作产 生式(production)。 文法中的每一个结构名定义了符合语法的记号串的自身语言。例如,在简单表达式文法中 由op 定义的语言定义了语言 {+, -, *}只由3个符号组成。我们通常对由文法中最普通的结 构定义的语言最感兴趣。用于程序设计语言的文法经常定义一个称作程序的结构,而该结构的 语言是程序设计语言的所有符合语法的程序的集合(注意这里在两个不同的意思中所使用的 “语言”)。 74 编译原理及实践 例如:Pascal的BNF以诸如 program → program-heading ; program-block. program-heading → ... program-block → ... ... 的文法规则开始(第 1个规则认为程序由一个程序头、随后的分号、随后的程序块,以及位于 最后的一个句号组成)。在诸如C的带有独立编译的语言中,最普通的结构通常称作编译单元。 除非特别指出不是,在各种情况下都假定在文法规则中,第 1个列出的就是这个最普通的结构 (在上下文无关文法的数学理论中,这个结构称作开始符号( start symbol))。 通过更深一些的术语可更清楚地区分结构名和字母表中的符号(因为它们经常是编译应用 程序的记号,所以我们一直调用记号符号)。由于在推导中必须被进一步替换(它们不终结推 导),所以结构名也称作非终结符( nonterminal)。相反地,由于字母表中的符号终结推导,所 以它们被称作终结符( terminal)。因为终结符通常是编译应用程序中的记号,所以这两个名字 在使用时是基本同义的。终结符和非终结符经常都被认作是符号。 下面是一些由文法生成的语言示例。 例3.1 考虑带有单个文法规则的文法 G E→(E)|a 这个文法有1个非终结符E、3个终结符 (,) 以及a。这个文法生成语言 L(G) = { a, (a), ((a)), (((a))),...} = {(na)n | n 是一个≥0的整型 },即:串由零个或多个左括号、后接一个 a,以及后面 是与左括号相同数量的右括号组成。作为这些串的一个推导示例,我们给出 ((a))的一个推导: E ⇒ (E) ⇒ ((E)) ⇒ ((a)) 例3.2 考虑带有单个文法规则 E→(E) 的文法G。除了减少了选项 E→a之外,这是与前例相同的文法。这个文法根本就不生成串,因 此它的语言是空的: L (G) = { }。其原因在于任何以E开头的推导都生成总是含有 E的串,所以 没有办法推导出一个仅包含有终结符的串。实际上,与带有所有递归进程(如归纳论证或递归 函数)相同,递归地定义一个结构的文法规则必须总是有至少一个非递归情况(称之为基础情 况(base case))。本例中的文法并没有这样的情况,且任何潜在的推导都注定为无穷递归。 例3.3 考虑带有单个文法规则 E→E + a|a 的文法G。这个文法生成所有由若干个“ +”分隔开的a 组成的串: L (G) = { a, a + a, a + a + a, a + a + a + a, . . . } 为了(非正式地)查看它,可考虑规则E→E + a 的效果:它引起串 + a 在推导右边不断地重复: E⇒E+a⇒E+a+a⇒E+a+a+a⇒... 最后,必须用基础情况E→a 来替换左边的E。 若要更正式一些,则可如下来归纳证明:首先,通过归纳 a 的数目来表示每个串a + a + . .. + a 都在L (G)中。推导E ⇒ a 表示a 在L (G) 中;现在假设s = a + a + ... + a 在L (G) 中,且有n-1 个a,则存在推导E ⇒* s。现在推导E ⇒ E + a ⇒ *s + a 表示串s + a 在L (G) 中,且其中有n 个a。 相反地,我们也表示出在 L (G) 中的任何串s 都必须属于格式 a + a + ... + a。这是通过归纳推导 75 第 3章 上下文无关文法及分析 的长度得出的。假设推导的长度为 1,则它属于格式E ⇒ a,而且s 是正确格式。现在假设以下 两个推导都为真:所有串都带有长度为 n-1的推导,并使 E ⇒* s 是长度为n >1的推导;则这个 推导开头必须将E改为E +a,格式E ⇒ E + a ⇒*s’ + a = s 也是这样。则s’有长度为n-1的推导, 格式a + a + ... + a 也是这样;因此, s 本身必须也具有这个相同的格式。 例3.4 考虑下面语句的极为简化的文法: statement → if-stmt | other if-stmt → if (exp) statement | if (exp) statement else statement exp → 0 | 1 这个文法的语言包括位于类似 C的格式中的嵌套if语句(我们已将逻辑测试表达式简化为 0或1, 且除if语句外的所有语句都被放在终结符 other中了)。例如,这个语言中的串是: other if ( 0 ) other if ( 1 ) other if ( 0 ) other else other if ( 1 ) other else other if ( 0 ) if ( 0 ) other if ( 0 ) if ( 1 ) other else other if ( 1 ) other else if ( 0 ) other else other ... 请注意,if 语句中的可选else 部分由用于if-stmt 的文法规则中的单个选择指出。 在前面我们已注意到: BNF中的文法规则规定了并置和选择,但不具有与正则表达式的 * 相等同的特定重复运算。由于可由递归得到重复,所以这样的运算实际并不必要(如同在功能 语言中的程序员所知道的)。例如,文法规则 A → Aa | a 或文法规则 A → aA | a 都生成语言 {an | n 是≥1的整型}(具有1个或多个a 的所有串的集合),该语言与由正则表达式 a+生成的语言相同。例如,串 aaaa 可由带有推导 A ⇒ Aa ⇒ Aaa ⇒ Aaaa ⇒ aaaa 的第1个文法规则生成。一个类似的推导在第 2个文法规则中起作用。由于非终结符 A作为定义 A的规则左边的第 1个符号出现,所以这些文法中的第 1个是左递归(left recursive) ,第2个文 法则是右递归(right recursive)。 例3.3是左递归文法规则的另一个示例,它引出串“ +a”的重复。可将本例及前一个示例 归纳如下。考虑一个规则形式 A→Aα|β 其中α 和β 代表任意串,且 β 不以A开头。这个规则生成形式β、βα、βαα、βααα、.......的所有 串(所有串均以 β开头,其后是零个或多个 α)。因此,这个文法规则在效果上与正则表达式 βα*相同。类似地,右递归文法规则 A→αA|β 这是左递归中的特殊情况,称作直接左递归( immediate left recursion),下一章将讨论一些更普通的情况。 76 编译原理及实践 (其中β并不在A处结束)生成所有串 β、αβ、ααβ、αααβ 、...... 如果要编写生成与正则表达式 a*相同语言的文法,则文法规则必须有一个用于生成空串 的的表示法(因为正则表达式 a*匹配空串)。这样的文法规则的右边必须为空,可在右边什么 也不写,如在 empty → 中,但大多数情况都使用 元符号表示空串(与在正则表达式中的用法类似): empty → 这样的文法规则称作 - 产生式( -production)。生成包括了空串的文法必须至少有一个 -产 生式。 现在可以将一个与正则表达式 a*相等的文法写作 A→Aa| 或 A→aA| 两个文法都生成语言{an | n 是≥0的整型} = L (a*)。 -产生式在定义可选的结构时也很有 用,我们马上就能看到这一点。 下面用更多的一些示例来小结这个部分。 例3.5 考虑文法 A→ (A) A| 这个文法生成所有“配对的括号”的串。例如,串 (( ) (( ))) ( )就由下面的推导生成(利用 -产 生式去除无用的 A): A ⇒ ( A ) A ⇒ ( A ) ( A ) A ⇒ ( A ) ( A ) ⇒ ( A ) ( ) ⇒ (( A ) A ) ( ) ⇒ (( ) A ) ( ) ⇒ (( ) ( A ) A ) ( ) ⇒ (( ) ( A )) ( ) ⇒ (( ) (( A ) A )) ( ) ⇒ (( ) (( ) A )) ( ) ⇒ (( ) (( ))) ( ) 例3.6 例3.4中的语句文法用 -产生式还可写作: statement → if-stmt | other if-stmt → if ( exp ) statement else-part else-part → else statement | exp → 0 | 1 请注意, -产生式指出结构else part是可选的。 例3.7 考虑一个语句序列的以下文法 G: stmt-sequence → stmt ; stmt-sequence | stmt stmt → s 这个文法生成由分号分隔开的一个或多个语句序列(语句已被提练到单个终结符 s中了): L(G) = { s, s;s, s;s;s, ...} 如要允许语句序列也可为空,则可写出以下的文法 G’: stmt-sequence → stmt ; stmt-sequence | stmt → s 但是它将分号变为一个语句结束符号( terminator)而不是分隔符( separator): L (G’) = { , s;, s;s;, s;s;s;, ..}. 77 第 3章 上下文无关文法及分析 如果允许语句序列可为空,但仍要求保留分号作为语句分隔符,则须将文法写作: stmt-sequence → nonempty-stmt-sequence | nonempty-stmt-sequence → stmt ; nonempty-stmt-sequence | stmt stmt → s 这个例子说明在构造可选结构时,必须留意 - 产生式的位置。 3.3 分析树与抽象语法树 3.3.1 分析树 推导为构造来自一个初始的非终结符的特定终结符的串提供了一个办法,但是推导并未唯 一地表示出它们所构造的结构。总而言之,对于同一个串可有多个推导。例如,使用图 3-1中 的推导从简单表达式文法构造出记号串 ( number - number ) * number 图3-2给出了这个串的另一个推导。二者唯一的差别在于提供的替换顺序,而这其实是一 个很表面的差别。为了把它表示得更清楚一些,我们需要表示出终结符串的结构,而这些终结 符将推导的主要特征抽取出来,同时却将表面的差别按顺序分解开来。这样的表示法就是树结 构,它称作分析树。 图3-2 表达式(34-3)*42 的另一个推导 与推导相对应的分析树( parse tree)是一个作了标记的树,其中内部的节点由非终结符标 出,树叶节点由终结符标出,每个内部节点的子节点都表示推导的一个步骤中的相关非终结符 的替换。 以下是一个简单的示例,推导: exp ⇒ exp op exp ⇒ number op exp ⇒ number + exp ⇒ number + number 与分析树 78 编译原理及实践 相对应。推导中的第 1步对应于根节点的 3个孩子。第 2步对应于根下最左边的 exp的单个 number孩子,后面的两步与上面的类似。通过将分析树中内部节点编号可将这个对应表示得 更清楚一些,编号采用在相应的推导中,与其相关的非终结符被取代的步骤编号。因此,如果 如下给前一个推导编号: (1) exp ⇒ exp op exp (2) ⇒ number op exp (3) ⇒ number + exp (4) ⇒ number + number 就可相应地将分析树中的内部节点编号如下: 请注意,该分析树的内部节点的编号实际上是一个前序编号( preorder numbering)。 同一个分析树还可与推导 exp ⇒ exp op exp ⇒ exp op number ⇒ exp + number ⇒ number + number 和 exp ⇒exp op exp ⇒ exp + exp ⇒ number + exp ⇒ number + number 相对应,但是它却提供了内部节点的不同编号。实际上,两个推导中的前一个与下面的编号相 对应: (我们将另一个推导的编号问题留给读者)。此时,该编号与分析树中的内部节点的后序编号 (postorder numbering)相反(后序编号将按4、3、2、1的顺序访问内部节点)。 一般而言,分析树可与许多推导相对应,所有这些推导都表示与终结符的被分析串相同的 基础结构,但是仍有可能找出那个与分析树唯一相关的推导。最左推导( leftmost derivation) 是指它的每一步中最左的非终结符都要被替换的推导。相应地,最右推导( rightmost derivation)则是指它的每一步中最右的非终结符都要被替换的推导。最左推导和与其相关的分 析树的内部节点的前序编号相对应;而最右推导则和后序编号相对应。 79 第 3章 上下文无关文法及分析 实际上,从刚刚给出的示例中的 3个推导和分析 中已可看到这个对应了。在 3个推导的第 1个中给出 的是最左推导,而第 2个则是最右推导(第 3个推导 既不是最左推导也不是最右推导)。 再看一个复杂一些的分析树与最左和最右推导 的示例——表达式 (34-3)*42与图3-1和图3-2中的 推导。这个表达式的分析树在图 3-3中,在其中也根 据图 3-1 的推导为节点编了号。这个推导实际上是最 左推导,且分析树的相应编号是相反的后序编号。 另一方面,图 3-2中的推导是最左推导(我们期望读 者能够提供与这个推导相应的分析树的前序编号)。 图3-3 算术表达式(34-3)*42 的分析树 3.3.2 抽象语法树 分析树是表述记号串结构的一种十分有用的表示法。在分析树中,记号表现为分析树的树 叶(自左至右),而分析树的内部节点则表示推导的各个步骤(按某种顺序)。但是,分析树却 包括了比纯粹为编译生成可执行代码所需更多的信息。为了看清这一点,可根据简单的表达式 文法,考虑表达式 3+4的分析树: 这是上一个例子的分析树。我们已经讨论了如何用这个树来显示每一个 number记号的真 实数值(这是由扫描程序或分析程序计算的记号的一个特征)。语法引导的转换原则( principle of syntax-directed translation)说明了:如同分析树所表示的一样,串 3+4的含义或语义应与其 语法结构直接相关。在这种情况中,语法引导的转换原则意味着在分析树表示中应加上数值 3 和数值4。实际上可将这个树看作:根代表两个孩子 exp子树的数值相加。而另一方面,每个子 树又代表它的每个 number孩子的值。但是还有一个更为简单的方法能表示与这相同的信息, 即如树: 这里的根节点仅是被它所表示的运算标出,而叶子节点由其值标出(不是 number记号)。类 似地,在图3-3中给出的表达式 (34-3)*42的分析树也可由下面的树简单表示出来: 80 编译原理及实践 在这个树中,括号记号实际已消失了,但它仍然准确地表达着从 34中减去3,然后再乘以 42的 语义内容。 这种树是真正的源代码记号序列的抽象表示。虽然不能从其中重新得到记号序列(不同于 分析树),但是它们却包含了转换所需的所有信息,而且比分析树效率更高。这样的树称作抽 象语法树(abstract syntax tree)或简称为语法树(syntax tree)。分析程序可通过一个分析树表 示所有步骤,但却通常只能构造出一个抽象的语法树(或与它等同的)。 我们可将抽象语法树想象成一个称作抽象语法( abstract syntax)的快速计数法的树形表示 法,它很像普通语法结构的分析树表示法(当与抽象语法相比时,也称作具体语法( concrete syntax))。例如,表达式 3+4的抽象语法可写作 OpExp(Plus,ConstExp(3),ConstExp(4));而表达 式(34-3)*42的抽象语法则可写作: OpExp (Times, OpExp (Minus, ConstExp (34), ConstExp (3)), ConstExp (42)) 实际上,可通过使用一个类似 BNF的表示法为抽象语法给出一个正式的定义,这就同具体语法 一样。例如,可将简单的算术表达式的抽象语法的相似 BNF规则写作: exp → OpExp (op, exp, exp) | ConstExp (integer) op → Plus | Minus | Times 对此就不再进一步探讨了,我们把主要的精力放在可被分析程序利用的语法树的真正结构上, 它由一个数据类型说明给出 。例如,简单的算术表达式的抽象语法树可由C数据类型说明给出: typedef enum {Plus, Minus, Times} OpKind; typedef enum {OpKind, ConstKind} ExpKind; typedef struct streenode { ExpKind kind; OpKind op; struct streenode * lchild, *rchild; int val; } STreeNode; typedef STreeNode *SyntaxTree; 请注意:除了运算本身(加、减和乘)之外,我们在语法树节点两种不同类型(整型常数和运 算)中还使用了枚举类型。实际上是可以利用记号来表示运算,而无需定义一个新的枚举类型。 此外还能够使用一个C union类型来节省空间,这是因为节点不能既是一个算符节点,同时又 是一个常数节点。最后还要指出这些树的节点说明只包括了这些示例直接用到的特征。在实际 应用中,编译中使用的特征还会更广泛,例如:数据类型、符号表信息等等,本章后面以及以 后几章的示例都会谈到它。 下面用一些通过使用前面例子中谈到过的文法的分析树和语法树的示例来小结这一段。 例3.8 考虑例3.4中被简化了的if语句的文法: statement → if-stmt | other if-stmt → if (exp) statement | if (exp) statement else statement exp → 0 | 1 串 有一些刚刚给出的抽象语法的语言在本质上是一个类型说明,参见练习。 if (0) other else other 的分析树是: 81 第 3章 上下文无关文法及分析 利用例3.6中的文法 statement → if-stmt | other if-stmt → if (exp) statement else-part else-part → else statement | exp → 0 | 1 这个串有以下的分析树: if语句除了3个附属结构之外,它就什么也不需要了,这 3个附属结构分别是:测试表达式、 then-部分和else部分(如果出现),因此前面一个串的语法树(利用例 3.4或例3.6中的文法)是 在这里我们将保留的记号 if和other用作标记以区分语法树中的语句类型。利用枚举类型则 会更为恰当一些。例如,利用一个 C声明的集合将本例的语句结构和表达式相应地表示如下: typedef enum { ExpK, StmtK} NodeKind; typedef enum { Zero, One} ExpKind; typedef enum { IfK, OtherK} StmtKind; typedef struct streenode {NodeKind kind; ExpKind ekind; StmtKind skind; struct streenode *test, *thenpart, *elsepart; 82 编译原理及实践 } STreeNode; typedef STreeNode * SyntaxTree; 例3.9 考虑将例3.7中的语句序列的文法用分号分隔开: stmt-sequence → stmt ; stmt-sequence | stmt stmt → s 这个s; s; s 串有关于这个文法的以下分析树: 这个串可能有一个语法树: 除了它们的“运算”仅仅是将一个序列中的语句绑定在一起之外,这个树中的分号节点与 算符节点(如算术表达式中的 +节点)类似。我们可以尝试着将一个序列中的所有语句节点都 与一个节点绑定在一起,这样前面的语法树就变成 但它存在一个问题:seq节点可以有任意数目的孩子,而又很难在一个数据类型说明中提供它。 其解决方法是利用树的标准最左孩子右同属( leftmost-child right-sibling)来表示(在大多数数 据结构文本中都有)。在这种表示中,由父亲到它的孩子的唯一物理连接是到最左孩子的。孩 子则在一个标准连接表中自左向右连接到一起,这种连接称作同属( sibling)连接,用于区别 父子连接。在最左孩子右同属安排下,前一个树现在就变成了: 有了这个安排,我们还可以去掉连接的 seq节点,那么语法树变得更简单了: 这很明显是用于表示语法树中一个序列的最简单和最方便的方法了。其复杂之处在于这里的连 83 第 3章 上下文无关文法及分析 接是同属连接,它必须与子连接相区分,而这又要求在语法树说明中有一个新的域。 3.4 二义性 3.4.1 二义性文法 分析树和语法树唯一地表达着语法结构,它们与表达最左和最右推导一样,但并不是对于 所有推导都可以。不幸的是,文法有可能允许一个串有多于一个的分析树。例如在前面作为标 准示例的简单整型算术文法中 exp → exp op exp | ( exp ) | number op → + | - | * 和串34-3*42,这个串有两个不同的分析树: 和 它们与两个最左推导相对应: 和 84 编译原理及实践 则相应的语法树为: 和 可生成带有两个不同分析树的串的文法称作二义性文法( ambiguous grammar)。由于这个文法 并不能准确地指出程序的语法结构(即使是完全确定正规串本身(它是文法的语言成员)),所 以它是分析程序表示的一个严重问题。在某种意义上,二义性文法就像是一个非确定的自动机, 此时两个不同的路径都可接收相同的串。但是因为没有一个合适的算法,因此就不能像自动机 中的情形一样(第 2章中讨论过的子集构造),文法中的二义性就不能如有穷自动机中的非确定 性一样轻易地被解决 。 所以必须将二义性文法认为是一种语言语法的不完善说明,而且也应避免它。幸运的是, 二义性文法在后面将介绍到的标准分析算法的测试中总是失败的,而且也开发出了标准技术体 系来解决在程序设计语言中遇到的典型二义性。 有两个解决二义性的基本方法。其一是:设置一个规则,该规则可在每个二义性情况下指 出哪一个分析树(或语法树)是正确的。这样的规则称作消除二义性规则(disambiguating rule)。 这样的规则的用处在于:它无需修改文法(可能会很复杂)就可消除二义性;它的缺点在于语 言的语法结构再也不能由文法单独提供了。另一种方法是将文法改变成一个强制正确分析树的 构造的格式,这样就可以解决二义性了。当然在这两种办法中,都必须确定在二义性情况下哪 一个树是正确的。这就再一次涉及到语法制导翻译原则了。我们所需的分析(或语法)树应能 够正确地反映将来应用到构造的意义,以便将其翻译成目标代码。 在前面的两个语法树中,哪一个是串 34-3*42的正确解释呢?第 1个树通过把减法节点作 为乘法节点的孩子,指出可将这个表达式等于:先做减法 (34-3=31),然后再做乘法 (31*42 = 1302)。相反地,第2个树指出先做乘法 (3*42 =126)然后再做减法(34-126=-92)。选择哪个树取 决于我们认为哪一个计算是正确的。人们认为乘法比减法优先( precedence)。通常地,乘法和 除法比加法和减法优先。 为了去除在这个简单表达式文法中的二义性,现在可以只需设置消除二义性规则,它建立 了3个运算相互之间的优先关系。其标准解决办法是给予加法和减法相同的优先权,而乘法和 除法则有高一级的优先权。 情况甚至更糟,这是因为没有算法可以在一开始时就确定一个文法是否是二义性的,参见第 3.2.7节。 85 第 3章 上下文无关文法及分析 不幸的是,这个规则仍然不能完全地去除掉文法中的二义性。例如串 34-3-42,这个串 也有两种可能的语法树: 和 第1个语法树表示计算 (34-3)-42=-11,而第2个表示计算 34-(3-42)=73。而哪一个算式正 确又是一个惯例的问题,标准数学规定第 1种选择是正确的。这是由于规定减法为左结合( left associative),也就是认为一个减法序列的运算是自左向右的。 因此,这又要求有一个消除二义性的规则:它能处理每一个加法、减法和乘法的结合性。 这个消除二义性的规则通常都规定它们为左结合,它确实消除了简单表达式文法中其他的二义 性问题(在后面再证明它)。 因为在表达式中不允许有超过一个算符的序列,有时还需要规定运算是非结合性的 (nonassociative)。例如,可将简单表达式文法用下面的格式写出: exp → factor op factor | factor factor → ( exp ) | number op → + | - | * 在这种情况中,诸如 34-3-42甚至于34-3*42的表达式都是正规的,但它们必须带上括 号,例如(34-3)-42和34-(3*42)。这样的完全括号表达式( fully parenthesized expression) 就没有必要说明其结合性或优先权了。上面的文法正如所写的一样去除了二义性。当然,我们 不仅改变了文法,还更改了正被识别的语言。 我们不再讨论消除二义性的规则,现在来谈谈重写文法以消除二义性的方法。请注意,必 须找到无需改变正被识别的基本串的办法(正如完全括号表达式的示例所做的一样)。 3.4.2 优先权和结合性 为了处理文法中的运算优先权问题,就必须把具有相同优先权的算符归纳在一组中,并为每一 种优先权规定不同的规则。例如,可把乘法比加法和减法优先添加到简单表达式文法,如下所示: exp → exp addop exp | term addop → + | term → term mulop term | factor mulop → * factor → ( exp ) | number 在这个文法中,乘法被归在 term规则下,而加法和减法则被归在 exp规则之下。由于 exp的 86 编译原理及实践 基本情况是term,这就意味着加法和减法在分析树和语法树中将被表现地“更高一些”(也就是, 更接近于根),由此也就接受了更低一级的优先权。这样将算符放在不同的优先权级别中的办法 是在语法说明中使用BNF的一个标准方法。这种分组称作优先级联(precedence cascade)。 简单算术表达式的最后一种文法仍未指出算符的结合性而且仍有二义性。它的原因在于算 符两边的递归都允许每一边匹配推导(因此也在分析树和语法树)中的算符重复。解决方法是 用基本情况代替递归,强制重复算符匹配一边的递归。这样将规则 exp → exp addop exp | term 替换为 exp → exp addop term | term 使得加法和减法左结合,而 exp → term addop exp | term 却使得它们右结合。换而言之,左递归规则使得它的算符在左边结合,而右递归规则使得它们 在右边结合。 为了消除简单算术表达式 BNF规则中的二义性,重写规则使得所有的运算都左结合: exp → exp addop term | term addop → + | term → term mulop factor | factor mulop → * factor → ( exp ) | number 现在表达式 34-3*42的分析树就是 表达式34-3-42的分析树是: 87 第 3章 上下文无关文法及分析 注意,优先级联使得分析树更为复杂。但是语法树并不受影响。 3.4.3 悬挂else问题 考虑例3.4中的文法: statement → if-stmt | other if-stmt → if( exp ) statement | if( exp ) statement else statement exp → 0 | 1 由于可选的 else的影响,这个文法有二义性。为了看清它,可考虑下面的串: if (0) if (1) other else other 这个串有两个分析树: 和 哪一个是正确的则取决于将单个 else部分与第 1个或第2个if语句结合:第 1个分析树将 else部分 与第 1个if语句结合;第 2个分析树将它与第 2个if语句结合。这种二义性称作悬挂 else问题 (dangling else problem)。为了分清哪一个分析树是正确的,我们必须考虑 if语句的隐含意思, 请看下面的一段C代码: 88 编译原理及实践 if (x != 0) i f ( y = = 1 / x ) o k = T R;U E e l s e z = 1 /;x 在这个代码中,如果 else部分与第1个if语句结合,只要 x等于0,则会发生一个除以零的错 误。因此这个代码的含义(实际是 else部分缩排的含义)是指一个 else部分应总是与没有 else部 分的最近的 if语句结合。这个消除二义性的规则被称为用于悬挂 else问题的最近嵌套规则 (most closely nested rule),这就意味着上面第2个分析树是正确的。请注意:如要将 else部分与 第1个if语句相结合,则要用C中的{...},如在 if (x != 0) {if (y == 1/x) ok = TRUE;} else z = 1/x; 解决这个位于BNF本身中的悬挂else二义性要比处理前面的二义性困难。方法如下: statement → matched-stmt | unmatched-stmt matched-stmt → if( exp ) matched-stmt else matched-stmt | other unmatched-stmt → if( exp ) statement | if( exp ) matched-stmt else unmatched-stmt exp → 0 | 1 它允许在 if语句中,只有一个 matched-stmt出现在else之前,这样就迫使尽可能快地匹配所有 的else部分。例如,经过结合了的简单串的分析树现在就变成了: 它确实将else 部分与第2个if 语句相连。 通常不能在BNF中建立最近嵌套规则,实际上经常所用的是消除二义性的规则。原因之一 是它增加了新文法的复杂性,但主要原因却是分析办法很容易按照遵循最近嵌套规则的方法来 配置(在无需重写文法时自动获得优先权和结合性有一点困难)。 悬挂else问题来源于Algol60的语法。我们还是有可能按照悬挂 else问题不出现的方法来设 计语法。办法之一是要求出现 else部分,而该办法已在 LISP和其他函数语言中用到了(但须返 回一个值)。另一种办法是为 if语句使用一个带括号关键字( bracketing keyword)。使用这种方 法的语言包括了 Algol68和Ada。例如,程序员写 if x /=0 then if y=1/x then ok := true ; 89 第 3章 上下文无关文法及分析 else z := 1/x ; end if; end if; 将else部分与第 2个if语句结合在一起。程序员还可写出 if x /=0 then if y = 1/x then ok := true; end if; else z := 1/x; end if; 将它与第1个if语句结合在一起。 Ada中相应的BNF(有一些简单化了)就是 if-stmt → if condition then statement-sequence end if | if condition then statement-sequence else statement-sequence end if 因此两个关键字 end if就是Ada的括号关键字。在 Algol68中,括号关键字是 fi(反着写 的if)。 3.4.4 无关紧要的二义性 有时文法可能会有二义性并且总是生成唯一的抽象语法树。例如,在例 3.9中的语句序列 文法中,可选择一个类似于语法树的简单同属表。在这种情形下,右递归文法规则或左递归文 法规则仍导致相同的语法树结构,且可将文法二义地写作 stmt-sequence → stmt-sequence ; stmt-sequence | stmt stmt → s 且仍然可得到唯一的语法树。由于相结合的语义不必依赖于使用的是哪种消除二义性的规则, 所以可将这样的二义性称作无关紧要的二义性( inessential ambiguity)。同样的情况出现在二 进制算符中,例如算术加法或串的并置表现为可结合运算( associative operation)(若对于所有 的值a、b 和c,且有(a·b)·c = a·(b·c),那么二进制算符“·”也是可结合的)。此时的语 法树仍然各不相同,但是却表示相同的语义值,而且我们可能也无需在意到底使用的是哪一个。 然而,分析算法却要提供一些消除二义性的规则,这些都是编译器编写者所需的。 3.5 扩展的表示法: EBNF和语法图 这一部分将对两种扩展表示法分别进行讲解。 3.5.1 EBNF表示法 重复和可选的结构在程序设计语言中极为普通,因此在 BNF文法规则中也是一样的。所以 当看到 BNF表示法有时扩展到包括了用于这两个情况的特殊表示法时,也不应感到惊奇。这些 扩展包含了称作扩展的BNF(extended BNF)或EBNF的表示法。 首先考虑重复情况,如在语句序列中。我们已看到重复是由文法规则中的递归表达,并可 能使用了左递归或右递归,它们由一般规则 A → Aα|β (左递归) 和 A → αA |β (右递归) 90 编译原理及实践 指出,其中 α和β是终结符和非终结符的任意串,且在第 1个规则中 β不以A开始,在第2个规则 中β不以A结束。 重复有可能使用与正则表达式所用相同的表示法,即:星号 *(在正则表达式中也称作 Kleene闭包)。则这两个规则可被写作非递归规则 A→βα* 和 A→α*β 相反地,EBNF选择使用花括号{...}来表示重复(因此清晰地表达出被重复的串的范围),且可 为规则写出 A→β{α} 和 A→{α}β 使用重复表示法的问题是:它使得分析树的构造不清楚,但是正如所看到的一样,我们对此并 不在意,例如语句序列的情形(例 3.9)。在右递归格式中写出如下文法: stmt-sequence → stmt ; stmt-sequence | stmt stmt → s 这个规则具有格式A→α A | β,且A = stmt-sequence,α= stmt ; β= stmt。在EBNF中,它表现为: stmt-sequence → { stmt ; } stmt (右递归格式) 同样也可使用一个左递归规则并得到 EBNF stmt-sequence → stmt { ; stmt } (左递归格式) 实际上,第 2个格式是通常所用的(原因在下一章再讲)。 出现在结合性中的更大的一个问题是发生在诸如二进制运算的减法和除法中。例如,前面 减法的简单表达式文法中的第 1个文法规则: exp → exp addop term | term 它使得A → Aα | β,且A = exp,α = addop term,β = term。因此,将这个规则在EBNF中写作: exp → term { addop term } 尽管规则本身并未明显地说明出来,但现在仍可假设它暗示了左结合。我们还可假设通 过写出 exp →{ term addop } term 来暗示一个右结合规则,但事实并不是这样。相反地,诸如 stmt-sequence → stmt ; stmt-sequence | stmt 的右递归规则可被看作后接一个可选的分号和 stmt-sequence的stmt。 EBNF中的可选结构可通过前后用方括号 [. . .] 表示出来。这同将问号放在可选部分之后的 正则表达式惯例本质上是一样的,但它另有无需括号就可将可选部分围起来的优点。例如,用 带有可选的else部分的if语句(例3.4和例3.6)的文法规则在EBNF中写作: statement → if-stmt | other if-stmt → if( exp ) statement [ else statement ] 91 第 3章 上下文无关文法及分析 而诸如 exp → 0 | 1 stmt-sequence → stmt ; stmt-sequence | stmt 的右递归可写作: stmt-sequence → stmt [ ; stmt-sequence ] (请与前面使用花括号写在左递归格式中的这个规则作一比较)。 如果希望在右结合中写出一个诸如加法的算术运算,则可用 exp → term [ addop exp ] 来代替花括号的使用。 3.5.2 语法图 用作可视地表示 EBNF规则的图形表示法称作语法图( syntax diagram)。它们是由表示终 结符和非终结符的方框、表示序列和选择的带箭头的线,以及每一个表示文法规则定义该非终 结符的图表的非终结符标记组成。圆形框和椭圆形框用来指出图中的终结符,而方形框和矩形 框则用来指出非终结符。 例如,文法规则 factor → ( exp ) | number 用语法图表示则是: 请注意,factor 并未放在框中,而是用作语法图的标记来指出该图表示该名称结构的定义。 另外还需注意带有箭头的线用作指明选择和顺序。 语法图是从EBNF而并非BNF中写出来的,所以需要用图来表示重复和可选结构。给出下 面的重复: A→{ B } 相对应的语法图通常画作 请留意该图必须允许根本没有出现 B。 诸如 的可选结构可画作 A→[ B ] 92 编译原理及实践 下面的示例是几个使用了前面 EBNF的例子,我们用它们来小结语法图的讨论。 例3.10 考虑简单算术表达式的运行示例。它具有 BNF(包括结合性和优先权)。 exp → exp addop term | term addop → + | term → term mulop factor | factor mulop → * factor → ( exp ) | number 相对应的 EBNF是 exp → trem { addop term } addop → + | term → factor { mulop factor } mulop → * factor → ( exp ) | number 图3-4是相对应的语法图( factor的语法图已在前面给出了)。 图3-4 例3.10中文法的语法图 93 第 3章 上下文无关文法及分析 例3.11 考虑例3.4中简化了的if语句的文法。它有 BNF E B N F为 statement → if-stmt | other if-stmt → if (exp) statement | if (exp) statement else statement exp → 0 | 1 statement → if-stmt | other if-stmt → if ( exp ) statement [ else statement ] exp → 0 | 1 图3 - 5是相对应的语法图。 图3-5 例3.11中文法的语法图 3.6 上下文无关语言的形式特性 在这里用更正式的数学方法写出本章前面所提到过的一些术语和定义。 3.6.1 上下文无关语言的形式定义 本节给出上下文无关文法的形式定义。 定义 上下文无关文法由以下各项组成: 1) 终结符(terminal)集合T。 2) 非终结符(nonterminal)集合N(与T不相交)。 94 编译原理及实践 3) 产生式(production)或文法规则(grammar rule)A→α的集合P,其中A是N的一个 元素,α是(T ∪ N)∗中的一个元素(是终结符和非终结符的一个可为空的序列)。 4) 来自集合N的开始符号(start symbol)。 令G是一个如上所定义的文法,则 G = (T, N, P, S)。G上的推导步骤(derivation step)格式 α A γ ⇒ αβγ,其中α 和γ 是(T ∪ N)*中的元素,且有 A→β在P中(终结符和非终结符的并集, 有时被称作G的符号集,set of symbol),且(T ∪ N)*中的串α被称作句型( sentential form))。 将关系 α⇒ *β定义为推导步骤关系 ⇒ 的传递闭包;也就是:假若有零个或更多个推导步骤, 就有α ⇒ ∗β(n≥0) α ⇒ α ⇒ … ⇒α ⇒ α 1 2 n-1 n 其中α=α ,β=α (如果n=0,则α=β)。在文法G上的推导(derivation)形如S ⇒ *w,且w 1 n ∈ T *(即:w 是终结符的一个串,称作句子( sentence)),S是G的开始符号。 由G生成的语言(language generated by G)写作L (G),它被定义为集合 L (G) = { w ∈ T * | 存在G的一个推导S ⇒ *w},也就是:L (G)是由S推导出的句子的集合。 最左推导(leftmost derivation)S ⇒ *w 是一个推导,在其中的每一个推导步骤 αAγ ⇒ αβγ lm 都有α∈ T*,换言之,α 仅由终结符组成。类似地,最右推导( rightmost derivation)就是每一 个推导步骤αAγ ⇒ αβγ 都有属性γ ∈ T*。 文法G上的分析树(parse tree)是一个带有以下属性的作了标记的树: 1) 每个节点都用终结符、非终结符或 标出。 2) 根节点用开始符号 S标出。 3) 每个叶节点都用终结符或 标出。 4) 每个非叶节点都用非终结符标出。 5) 如带有标记A ∈ N的节点有n 个带有标记X , X , . . . X 的孩子(可以是终结符也可以是非 12 n 终结符),就有A→X , X , . . . X ∈ P(文法的一个产生式)。 12 n 每一个推导都引出一个分析树,这个分析树中的每一个步骤 αAγ ⇒ αβγ 都在推导中,且 β= X , X , . . . X 与带有标记X , X , . . . X 的n 个孩子的结构相对应,其中 X , X , . . . X 带有标 1 2 n 1 2 n 1 2 n 记A。许多推导可引出相同的分析树。但每个分析树只有唯一的一个最左推导和一个最右推导。 最左推导与分析树的前序编号相对应,而与之相反,最右推导与分析树的后序编号相对应。 若上下文无关文法 G有L=L(G),就将串 L的集合称作上下文无关语言( context-free language)。一般地,许多不同的文法可以生成相同的上下文无关语言,但是根据所使用的文 法的不同,语言中的串也会有不同的分析树。 若存在串w ∈ L (G),其中w 有两个不同的分析树(或最左推导或最右推导),那么文法 G 就有二义性(ambiguous)。 3.6.2 文法规则和等式 在本节的开始,我们注意到文法规则用箭头符号而不是等号来定义结构名称(非终结符), 这与在正则表达式中用等号定义名称的表示法不同。其原因在于文法规则的递归本质使得定义 关系(文法规则)并不完全与相等一样,而且我们确实也看到了从推导得出的由文法规则定义 的串,而且在BNF中的箭头指示之后,使用了一个从左到右的替换方法。 但文法规则的左、右两边仍然保存某种等式关系,但由这个观点引出的语言定义过程与正 则表达式中的不同。这个观点对于程序设计语言语义理论很重要,且由于它对诸如分析的递归 95 第 3章 上下文无关文法及分析 过程的重要作用(即使我们所学习的分析算法并不基于此),因此值得学习。 例如下面的文法规则是从简单表达式文法中抽取出来的(在简化了的格式中): exp → exp + exp | number 我们已经看到如 exp的非终结符名称定义了一个终结符的串集合(若非终结符是开始符号, 那么它就是文法的语言)。假设将这个集合称作 E,并令 N为自然数集(与正则表达式名称 number对应),则给出的文法规则可解释为集合等式: E = (E + E) ∪ N 其中E+E是串{ u + v | u, v ∈ E }的集合(这里并未添加串 u 和串v,而只是用符号“ +”将 它们连接在一起)。 这是集合E的递归等式。思考一下它是如何定义 E的。首先,由于 E是N与E+E的并集,所 以E中包含了集合N(这是基本情况)。其次,等式E中也包含了 E+E,就意味着 N+N也在E中。 而且又由于 N和N+N均在E中,所以 N+N+N也在E中,同理类推。我们可以把这看作是一个更 长的串的归纳结构,且所有这些集合的和就是所需结果: E = N∪(N + N) ∪(N + N + N)∪(N + N + N + N)∪. . . 实际上,可以证明这个 E满足正在讨论的等式,而 E其实是最小的集合。如将 E的等式右边 看作是E的一个函数(集),则可定义出f (s) = (s + s) ∪N,此时E的等式就变成了E = f (E)。换而 言之,E是函数f 的一个固定点(fixed point),且(根据前面的注释)它确实是这样一个最小的 点。我们将由这种办法定义的E看作是给定的最小的固定点语义(least-fixed point semantics)。 当根据本书后面要讲到的通用算法来完成它们时,被递归地定义的程序设计语言,例如语 法、递归数据类型和递归函数,都能证明出它具有最小的固定点语义。因为以后将会用到这样 的办法来证明编译的正确性,所以这十分重要。现在的编译器很少能被证明是正确的。而编译 器代码的测试只能证实近似地正确,而且仍然保留了许多错误,即使是商品化生产的编译器也 是这样的。 3.6.3 乔姆斯基层次和作为上下文无关规则的语法局限 对于表示程序设计语言的语法结构, BNF和EBNF中的上下文无关文法是一个有用且十分 高效的工具,但是掌握 BNF能够和应该表示什么同样也十分重要。我们已经遇到过故意使文法 具有二义性的情形(悬挂else问题),那么因此也就不能直接表示出完整的语法来了。当试图在 文法中表示太多的东西时,或在文法中表示一个极不可能的要求时,会出现其他情况。本节将 讨论一些常见的情况。 当为某个语言编写 BNF时,会遇到一个常见的问题,那就是:词法结构的范围应表示在 BNF中而不是在一个单独的描述中(可能使用正则表达式)。前面的讨论已指出上下文无关文 法可以表达并置、重复和选择,这与正则表达式是一样的。因此我们能够为所有来自字符的记 号结构写出文法规则,并将其与正则表达式一起分配。 例如,考虑一个使用正则表达式定义作为数字序列的数: digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 number = d i g i t d i g i*t 用BNF写出这个定义: digit→ 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 number → number digit | digit 96 编译原理及实践 注意,第 2个规则中的递归仅仅是用来表达重复。人们认为带有这个属性的文法是一个正规文 法(regular grammar),正规文法能够表达任何正则表达式可以表达的内容。因此,我们就可 以设计一个分析程序,这个程序可以直接从输入源文件中接收字符并与扫描程序一起分配。 它确实是一个很好的办法。分析程序是一个比扫描程序更有效的机器,但相应地效率要差 一些。然而我们还是有理由在 BNF本身中包括记号的定义,文法会接着表达完整的语法结构, 其中还包括了词法结构。当然,人们希望语言设备能从文法中抽取这些定义并将它们返回到一 个扫描程序之上。 另一种情况的发生与上下文规则( context rule)有关,它经常发生在程序设计语言中。我 们以前一直使用着术语“上下文无关”,但实际上却并未解释为什么这样的规则是“上下文无 关”。其简单原因在于非终结符本身就出现在上下文无关规则中箭头的左边。因此,规则 A→α 就说明了无论在何处发生 A,它都可被任何地方的 α所替代。另一方面,可以将上下文 (context)非正式地定义为(终结符和非终结符的)一对串 β、γ,这样仅当β和γ分别在非终结 符的前面和后面发生时,才提供一个规则。可将此写作 β γ→βαγ 当α≠ 时,该规则称作上下文有关文法规则( context-sensitive grammar rule)。上下文有关文 法规则比上下文无关文法规则更强大,但要作为分析程序的基础却仍有许多困难。 在程序设计语言中,对于上下文有关规则有哪些要求呢?典型的要求包括了名称的使用。 要求在使用之前先说明( declaration before use)的C规则就是一个典型的例子。此时在允许语 句或表达式使用名称之前必须在一个说明中先出现一个名称: { int x; ... ...x... ... } 如果想用 BNF规则处理这个要求,也是可以做到的。首先,文法规则必须包括了名称串本身, 而不是包括不可区别的作为标识符记号的所有名称。其次,须为每个名称写出一个建立在可能 的使用之前的说明规则。但是在许多语言中,标识符的长度是非限制性的,因此标识符的数量 也是(至少是可能的)无限的。即使只允许名称为两个字符,仍然有可能有几百种新的文法规 则。这很明显是不实际的。这种情形与消除二义性规则的情况类似:只需说出在文法中不是显 式的规则(使用之前的声明)。但两者有区别:这样的规则不能作为分析程序本身,这是由于 它超出了(可能的)上下文无关规则所能表达的能力。相反地,由于它取决于符号表的使用 (它记录了声明的是哪一些标识符),因此该规则就变成了语义分析的一部分。 超过要分析程序检查的范围,但仍能被编译器检查的语言规则体称作语言的静态语义 (static semantics)。它们包括类型检查(在一个静态分类的语言中)和诸如使用前先说明的规 则。因此,只有当BNF规则可表达这些规则时才将其当作语法,而把其他的都当作语义。 除上下文有关文法之外,还有一种更常见的文法。这些文法称作非限制的文法 (unrestricted grammar),它们具有格式 α→β的文法规则,在其中对格式 α和β没有限制(除了 α不能是 之外)。4种文法——非限制的、上下文有关的、上下文无关的和正则的,分别被称 为0型、 1型、 2型和 3型文法。它们构成的语言类称为以乔姆斯基命名的乔姆斯基层次 (Chomsky hierarchy),这是因为乔姆斯基最先将它们用来描述自然语言。这些文法表示计算 97 第 3章 上下文无关文法及分析 能力的不同层次。非限制的(或 0型)文法与图灵机完全相等,这与正则文法和有穷自动机相 等是一样的,而且由此表示了绝大多数已知的计算。上下文无关文法也有一个对应的相等机 器,即:下推自动机,但是对于分析算法而言,并不需要这种机器的全部能力,所以也就不 再讨论它了。 我们还应留意一些上下文无关语言和文法相关的难处理的计算问题。例如,在处理二义 性文法时,如能找到一个可将二义性文法转变成非二义性文法的算法并且又不会改变语言本 身,那就太好了。然而不幸的是,大家都认为这是一个无法决定的事情,因此这样的算法是 不可能存在的。实际上,甚至还存在着没有非二义性文法的上下文无关语言(称之为先天二 义性语言( inherently ambiguous language)),而且判断一种语言是否是先天二义性的也是无 法做到的。 幸运的是,程序设计语言并不会引起诸如先天二义性的复杂问题,而且也证明了经常谈到 的消除二义性的特殊技术在实际应用中很合适。 3.7 TINY语言的语法 3.7.l TINY的上下文无关文法 程序清单3-1是TINY在BNF中的文法,我们可从中观察到一些内容: TINY程序只是一个语 句序列,它共有5种语句:if 语句、repeat 语句、read 语句、write 语句和assignment 语句。除 了if语句使用 end作为括号关键字(因此在 TINY中没有悬挂else 二义性)以及 if 语句和repeat 语句允许语句序列作为主体之外,它们都具有类似于 Pascal 的语法,所以也就不需要括号或 begin-end对(而且 begin甚至在 TINY中也不是一个保留字)。输入 /输出语句由保留字 read和write开始。read 语句一次只读出一个变量,而 write 语句一次只写出一个表达式。 程序清单 3-1 BNF中的TINY的文法 TINY表达式有两类:在 if 语句和 repeat 语句的测试中使用比较算符 =和<的布尔表达式, 以及包括标准整型算符 +、-、*和/(它代表整型除法,有时也写作 div)的算术表达式(由文 法中的 simple-exp指出)。算术运算是左结合并有通常的优先关系。相反地,比较运算却是非 结合的:每个没有括号的表达式只允许一种比较运算。比较运算比其他算术运算的优先权都 低。 98 编译原理及实践 TINY中的标识符指的是简单整型变量,它没有诸如数组或记录构成的变量。 TINY中也没 有变量声明:它只是通过出现在赋值语句左边来隐式地声明一个变量。另外,它只有一个(全 局)作用域,且没有过程或函数(因此也就没有调用)。 还要注意 TINY的最后一个方面。语句序列必须包括将语句分隔开来的分号,且不能将分 号放在语句序列的最后一个语句之后。这是因为 TINY没有空语句(不同于 Pascal和C)。另外, 我们将stmt-sequence的BNF规则也写作一个左递归规则,但却并不真正在意语句序列的结合性, 这是因为意图很简单,只需按顺序执行就行了。因此,只要将 stmt-sequence编写成右递归即可。 这个观点也出现在 TINY程序的语法树结构中,其中的语法序列不是由树而是由列表表示的。 现在就转到这个结构的讨论上来。 3.7.2 TINY编译器的语法树结构 TINY有两种基本的结构类型:语句和表达式。语句共有 5类(if语句、repeat语句、assign 语句、 read语句和 write语句),表达式共有 3类(算符表达式、常量表达式和标识符表达式)。 因此,语法树节点首先按照它是语句还是表达式来分类,接着根据语句或表达式的种类进行再 次分类。树节点最大可有 3个孩子的结构(仅在带有 else部分的if语句中才需要它们)。语句通 过同属域而不是使用子域来排序。 必须将树节点中的属性保留如下(除了前面所提到过的域之外):每一种表达式节点都需 要一个特殊的属性。常数节点需要它所代表的整型常数的域;标识符节点应包括了标识符名称 的域;而算符节点则需要包括了算符名称的域。语句节点通常不需要属性(除了它们的节点类 型之外)。但为了简便起见,在 assign语句和read语句中,却要保留在语句节点本身中(除了作 为一个表达式子节点之外)被赋予或被读取的变量名。 前面所描述的三个节点结构可通过程序清单 3-2中的C说明得到,该说明还可在附录 B(第 198行到第217行)的globals.h文件的列表中找到。请注意我们综合了这些说明来帮助节省空间。 它们还可帮助提醒每个节点类型的属性。现在谈谈说明中两个未曾提到过的属性。第 1个是簿 记属性lineno;它允许在转换的以后步骤中出现错误时能够打印源代码行数。第 2个是type域, 在后面的表达式(且仅是表达式)类型检查中会用到它。它被说明为枚举类型 ExpType,第6 章将会完整地讨论到它。 程序清单 3-2 一个TINY语法树节点的 C声明 99 第 3章 上下文无关文法及分析 现在需要将语法树结构的描述用图形表示出来,并且画出示例程序的语法树。为了做到这 一点,我们使用矩形框表示语句节点,用圆形框或椭圆形框表示表达式节点。语句或表达式的 类型用框中的标记表示,额外的属性在括号中也列出来了。属性指针画在节点框的右边,而子 指针则画在框的下面。我们还在图中用三角形表示额外的非指定的树结构,其中用点线表示可 能出现也可能不出现的结构。语句序列由同属域连接(潜在的子树由点线和三角形表示)。则 该图如下: if 语句(带有3个可能的孩子)如下所示: repeat 语句有两个孩子。第1个是表示循环体的语句序列,第 2个是一个测试表达式: assign 语句有一个表示其值是被赋予的表达式的孩子(被赋予的变量名保存在语句节点中): write 语句也有一个孩子,它表示要写出值的表达式: 100 编译原理及实践 算符表达式有两个孩子,它们表示左操作数表达式和右操作数表达式: 其他所有的节点( read语句、标识符表达式和常量表达式)都是叶子节点。 最后准备显示一个 TINY程序的树。程序清单 3-3中有来自第 1章计算一个整型阶乘的示例 程序,它的语法树在图 3-6中。 图3-6 程序清单3-3中TINY程序的语法树 程序清单 3-3 TINY语言中的示例程序 101 第 3章 上下文无关文法及分析 练习 3.1 a. 写出一个生成串的集合 {s;, s;s;, s;s;s;, ... } 的非二义性的文法。 b. 利用你的文法为串s;s;给出一个最左推导和最右推导。 3.2 假设有文法A → AA | (A) a. 描述它生成的语言。 b. 说明它有二义性。 3.3 假设有文法 exp → exp addop term | term addop → + | term → term mulop factor | factor mulop → * factor → ( exp ) | number 则为下面的表达式写出最左推导、分析树以及抽象语法树: a. 3+4*5-6 b. 3*(4-5+6) c. 3-(4+5*6) 3.4 下面的文法生成字母表之上的所有正则表达式(以前曾在算符前后加上了引号,这是 因为竖线既是一个算符又是一个元字符): rexp → rexp "|" rexp | rexp rexp | rexp "*" | "(" rexp ")" | letter a. 利用这个文法为正则表达式(ab|b)*给出一个推导。 b. 说明该文法有二义性。 c. 重写该文法以使算符建立正确的优先关系(参见第 2章)。 d. (c) 的答案给二进制算符带来怎样的结合性?为什么? 3.5 为包括了常量 true和false、算符and、or和not,以及括号的布尔表达式编写一 个文法。确保给予 or比and低的优先权,而 and的优先权比not低,并允许not重复使 用,如在布尔表达式中的not not true。另外还需保证该文法没有二义性。 3.6 考虑以下表示简化的类LISP表达式的文法: lexp → atom | list atom → number | identifier list → ( lexp-seq ) lexp-seq → lexp-seq lexp | lexp a. 为串(a 23 (m x y)分) 别写出一个最左推导和一个最右推导。 102 编译原理及实践 b. 为a部分中的串画出一个分析树。 3.7 a. 为练习3.6中语法的抽象语法树结构编写一个 C类型声明。 b. 为从a部分的声明得出的串(a 23 (m x y )画) 出一个语法树。 3.8 假设以下的文法 a. 为串 statement → if-stmt | other | if-stmt → if ( exp ) statement else-part else-part → else statement | exp → 0 | 1 i f ( 0 ) i f ( 1 )other e l s e e l s eother 画出分析树。 b. 用两个else的目的是什么? c. 在C中允许有这样的代码吗?请解释原因。 3.9 (Aho,Sethi andUllman) 显示以下的解决悬挂 else二义性问题的尝试仍存在着二义性 (与3.4.3节中的解法对比一下): statement → if ( exp ) statement | matched-stmt matched-stmt → if ( exp ) matched-stmt else statement | other exp → 0 | 1 3.10 a. 将练习3.6中的文法翻译成 EBNF。 b. 为a部分中的EBNF画出语法图。 3.11 假设集合等式X = (X + X)∪N(N是自然数集:参见3.6.2节)。 a. 说明集合E = N∪(N + N)∪(N + N + N)∪(N + N + N + N)∪ . . . 满足等式。 b. 假设任意一个集合 E´ 满足等式,说明有E E ´成立。 3.12 可以用多种方法将一目减添加到练习 3.3的简单算术表达式文法中。修改 BNF以使其 符合每一个要求的规则。 a. 每个表达式至多允许有一个一目减,且一目减应在表达式的开头,则 -2-3是合法 的 (且答案应为- 5),-2-(-3)也应是正规的,但-2--3却是非法的。 b. 在一个数或左括号之前至多有一个一目减,所以-2--3是合法的,但--2和-2---3 却是非法的。 c. 在数字和左括号之前允许有任意数量的一目减,所以任何情况都是合法的。 3.13 考虑将练习3.6的文法如下简化: lexp → number | ( op lexp-seq ) op → + | - | * lexp-seq → lexp-seq lexp | lexp 这个文法可被认为是表示在类似于 LISP的前缀格式中的简单整型算术表达式。 例如,表达式34-3*42按该文法就可写成(-34 (*3 42))。 a. 正规表达式(- 2 3 4)与(- 2)的解释是什么?表达式(+ 2)与(* 2) 的解释是 又什么呢? 请注意这个表达式中的第2个减法是一个二目减,而不是一目减。 103 第 3章 上下文无关文法及分析 b. 在这个文法中,有没有优先权和结合性的问题?该文法有二义性吗? 3.14 a. 为上题中文法的语法树结构写出 C类型的说明。 b. 利用a部分的答案为表达式 (- 34 (* 3 42)画) 出语法树。 3.15 在3.3.2节中,一个抽象语法树 exp → OpExp (op,exp,exp) | ConstExp(integer) op → Plus | Minus | Times 的类似于BNF的说明与一些语言中的真正类型说明很接近。 ML和Haskell是其中的两 种语言。这个练习是为那些了解这两种语言中任一种的读者编写的。 a. 写出完成上面抽象语法的数据类型说明。 b. 为表达式(34* (42-3))写出一个抽象语法表达式。 3.16 重写3.3.2节开始的语法树 typedef以便使用一个 union。 3.17 证明练习3.5中的文法生成了成对括号的所有串的集合,其中假设 w 具有以下两个属 性,那么w 就是一个成对括号的串: a. w 确实包括了相同数量的左括号和右括号。 b. w 的每个前缀 u(对于某个 x,有w = ux)至少有与右括号相同的左括号(提示: 通过归纳一个推导的长度来证明)。 3.18 a. 写出一个生成格式xcx 的串的文法,其中 x 是a 和b 的一个串。 b. 有没有可能为a部分的串写出一个上下文无关的文法?为什么? 3.19 在一些语言(例如 Modula-2和Ada)中,希望一个过程说明用包括了过程名的语法 结束,在 Modula-2中,一个过程是这样说明的: PROCEDURE P; BEGIN ... END P; 请注意在 END之后的过程名 P。分析程序能够检查这样的要求吗?为什么? 3.20 a. 写出一个生成与下面文法相同的语言的正则表达式: A → aA | B | B → bB | A b. 写出一个生成与下面正则表达式相同的语言的文法: (a|c|ba|bc)*(b| ) 3.21 单元产生式( unit production)是公式 A→B的一个文法规则选择,其中 A和B均为非 终结符。 a. 说明可系统地从文法中删除掉单元产生式,这样就产生了不带有单元产生式的文 法,该单元产生式就生成了与原始文法相同的语言。 b. 你希望单元产生式在程序设计语言中经常出现吗?为什么? 3.22 循环文法(cyclic grammar)是在其中有一些非终结符A的一个推导A⇒* A的文法。 a. 说明循环文法是有二义性的。 b. 你希望定义程序设计语言的文法经常是循环的吗?请解释原因。 3.23 将练习3.6的TINY文法重写为在EBNF中的文法。 3.24 对于TINY程序 read x; 104 编译原理及实践 x:=x+1; write x a. 画出TINY分析树。 b. 画出TINY语法树。 3.25 为下面的程序画出 TINY语法树: read u; read v; { input two integers } if v=0 then v:=0 { do nothing } else repeat temp:=v; v:=u-u/v*v; u:=temp until v=0 end; write u {output gcd of original u & v} 注意与参考 上下文无关文法的许多理论都可在 Hopcroft and Ullman [1979]中找到;在这里还可找到一 些尚未解决的问题,例如先天二义性这样的许多属性;此外还有乔姆斯基层次。其他的内容则 可在Ginsburg [1966, 1975]中找到。早期的一些理论在Chomsky [1956, 1959]中有提到,它为自 然语言的学习提供了帮助。只有到后来对于程序语言的理解才成为了一个重要的论题,上下文 无关文法的第 1个应用是在 Algol60的定义之中(Naur [1963])。使用了上下文无关规则的有关 3.4.3节中的悬挂else问题的解决方法是取自Aho、Hopcroft 和 Ullman [1986]的,练习3.9的答案 也来自这儿。将上下文无关文法看作是递归等式( 3.6.2节)是由指示语义得到的,读者可参看 Schmidt [1986]。 第4章 自顶向下的分析 本章要点 • 使用递归下降分析算法进行自顶向下的分析 • LL(1)分析 • First 集合和Follow集合 • TINY语言的递归下降分析程序 • 自顶向下分析程序中的错误校正 自顶向下( top-down)的分析算法通过在最左推导中描述出各个步骤来分析记号串输入。 之所以称这样的算法为自顶向下是由于分析树隐含的编号是一个前序编号,而且其顺序是由根 到叶(参见第 3章的3.3节)。自顶向下的分析程序有两类:回溯分析程序( backtracking parser) 和预测分析程序( predictive parser)。预测分析程序试图利用一个或多个先行记号来预测出输 入串中的下一个构造,而回溯分析程序则试着分析其他可能的输入,当一种可能失败时就要求 输入中备份任意数量的字符。虽然回溯分析程序比预测分析程序强大许多,但它们都非常慢, 一般都在指数的数量级上,所以对于实际的编译器并不合适。本书将不研究回溯程序(但读者 可查看“注意与参考”部分以及练习以得到一些关于这个主题的提示)。 本章要学习的两类自顶向下分析算法分别是递归下降分析( recursive-descent parsing) 和LL(1)分析( LL(1) parsing )。递归下降分析很常用,且它对于手写的分析程序最为适合, 所以我们最先学习它。之后再来学习 LL(1)分析,由于在实际中并不常用到它,所以只是将 其作为一个带有显式栈的简单实例来学习,它是下一章更强大(但也更复杂)的自底向上算 法的前奏。它对于将出现在递归下降分析中的一些问题形式化也有帮助。 LL(1)分析方法是 这样得名的:第 1个“L”指的是由左向右地处理输入(一些旧式的分析程序惯于自右向左地 处理输入,但现在已不常用了)。第 2个“L”指的是它为输入串描绘出一个最左推导。括号 中的数字 1意味着它仅使用输入中的一个符号来预测分析的方向(“LL(k)分析”也是有可能 的——它利用向前看的 k个符号,本章后面将简要地介绍到它,但是向前看的一个符号是最 为常见的)。 递归下降程序分析和 LL(1)分析一般地都要求计算先行集合,它们分别称作 First集合和 Follow集合 。由于无需显式地构造出这些集合就可以构造出简单的自顶向下的分析程序,所 以在基本算法的介绍之后我们再讨论它们。之后我们还要谈到一个由递归下降分析构造的 TINY分析程序,本章的最后是自顶向下的分析中的错误校正。 4.1 使用递归下降分析算法进行自顶向下的分析 4.1.1 递归下降分析的基本方法 递归下降分析的概念极为简单:将一个非终结符 A的文法规则看作将识别 A的一个过程的 定义。A的文法规则的右边指出这个过程的代码结构:一个选择中的终结符与非终结符序列与 下一章将要研究的自底向上的分析算法有一些也需要这些集合。 106 编译原理及实践 相匹配的输入以及对其他过程的调用相对应,而选择与在代码中的替代情况( case语句和if语 句)相对应。 例如,考虑前一章的表达式文法: exp → exp addop term | term addop → + | term → term mulop factor | factor mulop → * factor → ( exp ) | number 及factor 的文法规则,识别 factor 并用相同名称进行调用的递归下降程序过程可用伪代码编写 如下: procedure factor ; begin case token of ( : match( () ; exp ; match( ) ) ; number : match (number) ; else error ; end case ; end factor ; 在这段伪代码中,假设有一个在输入中保存当前下一个记号的 token变量(以便这个例子使用 先行的一个符号)。另外还假设有一个 match过程,它用它的参数匹配当前的下一个记号。如果 成功则前移,如果失败就表明错误: procedure match ( expectedToken ) ; begin if token = expectedToken then getToken ; else error ; end if ; end match ; 现在脱离开在 match和factor中被调用的未指定的error过程。可以假设它会打印出一个出错信息 并退出。 请注意,在match (( )调用和factor中的match (number) 调用中,我们知道 expectedToken和 token是一样的。但是在 match ())调用中,不能将 token假设为一个右括号,所以就需要有一个 测试。 factor的代码也假设已将过程 exp定义为可以调用。在表达式文法的递归下降分析中, exp过程将调用term,term过程将调用 factor,而factor过程将调用 exp,所以所有的这些过程都 必须能够互相调用。不幸的是,为表达式文法中的其余规则编写递归下降程序过程并不像为 factor编写一样简单,而且它需要使用 EBNF,下面就介绍这一点。 107 第 4章 自顶向下的分析 4.1.2 重复和选择:使用 EBNF 例如,一个 if语句(简化了的)文法规则是: if-stmt → if ( exp ) statement | if ( exp ) statement else statement 可将它翻译成以下过程 procedure ifStmt ; begin match (if) ; match (() ; exp ; match ( )) ; statement ; if token = else then match (else) ; statement ; end if ; end ifStmt ; 在这个例子中,不能立即区分出文法规则右边的两个选择(它们都以记号 if开始)。相反地, 我们必须直到看到输入中的记号 else时,才能决定是否识别可选的 else部分。因此,if语句的 代码与EBNF if-stmt → if ( exp ) statement [ else statement ] 匹配的程度比与 BNF的匹配程序要高,上面的 EBNF的方括号被翻译成 ifStmt的代码中的一个测 试。实际上,EBNF表示法是为更紧密地映射递归下降分析程序的真实代码而设计的,如果使 用的是递归下降程序,就应总是将文法翻译成 EBNF。另外还需注意到即使这个文法有二义性 (参见前一章),编写一个每当在输入中遇到 else记号时就立即匹配它的分析程序也是很自然 的。这与最近嵌套的消除二义性的规则精确对应。 现在考虑一下BNF中简单算术表达式文法中的 exp情况: exp → exp addop term | term 如要根据我们的计划试着将它变成一个递归的 exp过程,则首先应做的是调用 exp本身,而这将 立即导致一个无限递归循环。由于 exp和term可以以相同的记号开头(一个数或左括号),所以 要想测试使用哪个选择( exp → exp addop term或exp → term)就会出现问题了。 解决的办法是使用EBNF规则 exp → term { addop term } 花括号表示可将重复部分翻译到一个循环的代码中,如下所示: procedure exp ; begin term ; while token = + or token = - do 108 编译原理及实践 match (token) ; term ; end while ; end exp ; 相似地, term的EBNF规则: term → factor { mulop factor } 就变成代码 procedure term ; begin factor ; while token = * do match (token) ; factor ; end while ; end term ; 在这里当分隔过程时,删去了非终结符 addop 和mulop,这是因为它们仅有匹配算符功能: addop → + | mulop → * 这里所做的是exp和term中的匹配。 这个代码有一个问题:由花括号(和原始的 BNF中的显式)表示的左结合是否仍然保留。 例如,假设要为本书中简单整型算术的文法编写一个递归下降程序计算器,就可通过在循环 中轮转来完成运算,从而就保证了该运算是左结合(现在假设分析过程是返回一个整型结果 的函数): function exp : integer ; var temp : integer ; begin temp := term ; while token = + or token = - do case token of + : match (+) ; temp := temp + term ; - : match (-) ; temp := temp - term ; end case ; end while ; return temp ; end exp ; term与之也类似。我们已利用这种方法创建了一个可运行的简单计算器,程序清单 4-1中有它 109 第 4章 自顶向下的分析 的C代码。其中并未写出一个完整的扫描程序,而是选择使用了对 getchar和scanf的调用来 代替getToken的过程。 程序清单4-1 简单整型算术的递归下降程序计算器 110 编译原理及实践 这个将EBNF中的文法规则转变成代码的办法十分有效, 4.4节还将利用它为 TINY语言给 出一个完整的分析程序。但是它仍有一些缺点,且须留意在代码中安排的动作。例如:在前面 exp的伪代码中,运算的匹配必须发生在对 term的重复调用之前(否则term会将一个运算看作是 它的第 1个记号,这就会生成一个错误了)。实际上现在必须严格遵循用于保持 token变量的协 议:必须在分析开始之前先设置 token,且对一个记号的测试(将它放在伪代码的 match过程中) 一旦成功,就立即调用 getToken(或它的等价物)。 在构造语法树中也须注意动作的安排。我们已看到可通过在执行循环时完成计算来保持带 有重复的 EBNF的左结合。它再也不能与分析树或语法树中的自顶向下的构造相对应。但相反 地,如考虑表达式 3+4+5 ,其语法树 在根节点(表示其与 5的和的节点)之前应先建立表示 3与4之和的节点。将它翻译为真正的语 法树结构就有了以下的 exp过程的伪代码: 111 第 4章 自顶向下的分析 function exp : syntaxTree ; var temp, newtemp : syntaxTree ; begin temp := term ; while token = + or token = - do case token of + : match (+) ; newtemp := makeOpNode(+) ; leftChild(newtemp) := temp ; rightChild(newtemp) := term ; temp := newtemp ; - : match (-) ; newtemp := makeOpNode(-) ; leftChild(newtemp) := temp ; rightChild(newtemp) := term ; temp := newtemp ; end case ; end while ; return temp ; end exp ; 或更简单的 function exp : syntaxTree ; var temp, newtemp : syntaxTree ; begin temp := term ; while token = + or token = - do newtemp := makeOpNode(token) ; match (token) ; leftChild(newtemp) := temp ; rightChild(newtemp) := term ; temp := newtemp ; end while ; return temp ; end exp ; 这个代码使用了新函数 makeOpNode,它接受作为参数的运算符记号并返回新建的语法树节点。 我们还通过写出leftChild (t) := p 或rightChild (t) := p 指出将一个语法树 p 的赋值作为语法树 t 的 一个左子树或右子树。有了这个伪代码之后, exp过程实际构造了语法树而不是分析树。这是 因为一个对 exp的调用并不总是构造一个新的树节点;如没有运算符, exp则仅仅是传送回从最 初调用到term收到的树来作为它自己的值。也可以写出与 term和factor相对应的伪代码(参见 练习)。 112 编译原理及实践 相反地,递归下降分析程序在严格的自顶向下的风格中可构造出 if语句的语法树: function ifStatement : syntaxTree ; var temp : syntaxTree ; begin match (if) ; match (() ; temp := makeStmtNode(if) ; testChild(temp) := exp ; match ( ) ) ; thenChild(temp) := statement ; if token = else then match (else) ; elseChild(temp) := statement ; else elseChild(temp) := nil ; end if ; end ifStatement ; 正因为递归下降分析允许程序设计人员可调整动作的安排,这样它就可以选择手工生成的分析 程序了。 4.1.3 其他决定问题 前面描述的递归下降分析虽然非常强大,但它仍有特殊性。若使用的是一个设计精巧的小 型语言(例如 TINY,甚至是 C),那么对于构造一个完整的分析程序,这些办法是适合的。读 者应注意在复杂情况中还需要更形式的方法。此时还会出现一些问题。首先,将原先在 BNF中 编写的文法转变成 EBNF格式可能会有些困难。下一节将学习不用 EBNF的另一种方法,其中 构造了一个与 EBNF基本相等的转变了的 BNF。其次,在用公式表达一个用以区分两个或更多 的文法规则选项的测试 A→α | β | . . . 时,如果α和β均以非终结符开始,那么就很难决定何时使用 A→α选项,何时又使用 A→β选项。 这个问题就要求计算 α和β的First集合:可以正规地开始每个串的记号集合。 4.3节将详细介绍 这个计算。再次,在写 产生式的代码 A→ 时,需要了解什么记号可以正规地出现在非终结符 A之后,这是因为这样的记号指出 A可以恰 当地在分析中的这个点处消失。这个集合被称作 A的Follow集合。4.3节中也有这个集合的准确 计算。 最后读者需要注意:人们进行 First集合和 Follow集合的计算是为了对早期错误进行探测。 例如:在程序清单 4-1的计算器程序中,假设有输入 )3-2),分析程序在报告错误之前,将从 exp到term再到factor下降;由于在表达式中,右括号作为第 1个字符并不合法,而在 exp 中可能早已声明了这个错误。 exp的First集合将会告诉我们这一点,从而可以进行较早的错误 探测(本章最后将更详细地讨论错误探测和恢复)。 113 第 4章 自顶向下的分析 4.2 LL(1)分析 4.2.1 LL(1)分析的基本方法 L L ( 1 )分析使用显式栈而不是递归调用来完成分析。以标准方式表示这个栈非常有用,这 样LL(1)分析程序的动作就可以快捷地显现出来。在这个介绍性的讨论中,我们使用了生成成 对括号的串的简单文法: S→(S) S | (参见第 3章的例 3.5)。 假设有这个文法和串 ( ) ,则表4-1给出了自顶向下的分析程序的动作。此表共有 4列。第1 列为便于以后参考给每个步骤标上了号码。第 2列显示了分析栈的内容,栈的底部向左,栈的 顶部向右。栈的底部标注了一个美元符号,这样一个在顶部包含了非终结符 S的栈就是: $S 且将额外的栈项推向右边。表的第 3列显示了输入。输入符号由左列向右。美元符号标出了输 入的结束(它与由扫描程序生成的 EOF记号相对应)。表的第4列给出了由分析程序执行的动作 的简短描述,它将改变栈和(有可能)输入,如在表的下一行中所示一样。 表4-1 自顶向下的分析程序的分析动作 分析栈 1 $S 2 $S)S( 3 $S)S 4 $S) 5 $S 6$ 输入 ()$ ()$ )$ )$ $ $ 动作 S→(S)S 匹配 S→ 匹配 S→ 接受 自顶向下的分析程序是从将开始符号放在栈中开始的。在一系列动作之后,它接受一个输 入串,此时栈和输入都空了。因此,成功的自顶向下的分析的一般示意法应是: $ StartSymbol ... ... $ InputString $ ... ... $ accept 在上面的例子中,开始符号是 S,输入串是()。 自顶向下的分析程序通过将栈顶部的非终结符替换成文法规则中( BNF中)该非终结符的 一个选择来作出分析。其方法是在分析栈的顶部生成当前输入记号,在顶部它已匹配了输入记 号并将它从栈和输入中舍弃掉。这两个动作 1) 利用文法选择A→α将栈顶部的非终结符A替换成串α。 2) 将栈顶部的记号与下一个输入记号匹配。 是自顶向下的分析程序中的两个基本动作。第 1个动作称为生成( generate):通过写出在替换 中使用的 BNF选择(它的左边在当前必须是栈顶部的非终结符)来指出这个动作。第 2个动作 将栈顶部的一个记号与输入中的下一个记号匹配(并通过取出栈和将输入向前推进而将二者全 114 编译原理及实践 部舍弃掉);这个动作是通过书写单词来指出的。另外还需注意在生成动作中,从 BNF中替换 掉的串α必须颠倒地压在栈中(这是因为要保证串 α按自左向右的顺序进到栈的顶部)。 例如,在表 4-1的分析的第 1步中,栈和输入分别是 $ S () $ 且用来替换栈顶部的S的规则是S→ ( S ) S,所以将串S ) S ( 压入到栈中得到 $S)S( ()$ 现在已生成了下一个输入终结符,即在栈的顶部的一个左括号,我们还完成了一个匹配以得到 以下的情况: $S)S )$ 表4-1中生成动作的列表与串( )最左推导的步骤完全对应: S⇒(S)S ⇒()S ⇒() [S → ( S ) S] [S→ ] [S→ ] 这是自顶向下分析的特征。如果要在分析进行时构造一个分析树,则可当将每个非终结符或终 结符压入到栈中时添加节点来构造动作。因此分析树根节点的构造是在分析开始时进行的(与 开始符号对应)。而在表4-1的第2步中,当 ( S ) S替换S时,用于4个替换符号中的每个符号的 节点将作为放到栈中的符号来构造,并且作为子节点与在栈中替换的 S节点连接。为了使其具 有高效率,就必须修改栈以包括指向这些构造的节点的指针,而不是仅仅是指向非终结符或终 结符本身的指针。另外,读者还将看到如何将这个处理进行修改以生成语法树的结构而非分析 树的结构。 4.2.2 LL(1)分析与算法 当非终结符 A位于分析栈的顶部时,根据当前的输入记号(先行),必须使用刚刚描述过的 分析办法做出一个决定:当替换栈中的 A时应为 A选择哪一个文法规则,相反地,当记号位于 栈顶部时,就无需做出这样的决定,这是因为无论它是当前的输入记号(由此就发生一个匹配), 还是不是输入记号(从而就发生一个错误),两者都是相同的。 通过构造一个LL(1)分析表(LL(1) parsing table)就可以表达出可能的选择。这样的表格基本 上是一个由非终结符和终结符索引的二维数组,其中非终结符和终结符包括了要在恰当的分析 步骤(包括代表输入结束的 $)中使用的产生式选择。这个表被称为 M [N, T ],这里的N是文法 的非终结符的集合, T是终结符或记号的集合(为了简便,禁止将 $加到T上),M可被认为是 “运动的”表。我们假设表 M [N, T ]在开始时,它的所有项目均为空。任何在构造之后继续为 空的项目都代表了在分析中可能发生的潜在错误。 根据以下规则在这个表中添加产生式: 1) 如果A→α是一个产生式选择,且有推导 α ⇒ *aβ成立,其中a 是一个记号,则将 A→α添 加到表项目M [A, a] 中。 2) 如果A→α是一个产生式选择,且有推导α ⇒ ∗ 和S $ ⇒ ∗βAaγ 成立,其中S是开始符号, a是一个记号(或$),则将A→α添加到表项目M [A, a]中。 这个规则的观点是:在规则1中,在输入中给出了记号 a,若α可为匹配生成一个 a,则希望 挑选规则A→α。在规则2中,若A派生了空串(通过A→α),且如a 是一个在推导中可合法地出 115 第 4章 自顶向下的分析 现在A之后的记号,则要挑选A→α以使A消失。请注意规则 2中当α= 时的特殊情况。 很难直接完成这些规则。在下一节中,我们将为此开发一个算法,这其中包括了早已提到 过的First和Follow集合。但在极为简单的例子中,这些规则是可手工完成的。 读者可考虑在前一小节中使用成对括号的文法的第 1个示例,它有一个非终结符 (S)、3个 记号(左括号、右括号和 $),以及两个选择。由于 S只有一个非空产生式,即 S→(S)S,每一 个可从S派生的串必须或为空或以左括号开始,并将这个产生式选择添加到项目 M [S, (] 中(且 仅能在这里)。这样就完成了规则 1之下的所有情况。因为有 S ⇒ (S) S,规则 2应用了 α= , β= (, A = S, a = ) 且γ = S $,所以S→ 就被添加到M [S, )] 中了。由于S $ ⇒ *S$(空推导), 所以S→ 也被添加到M [S, $] 中。这样就完成了这个文法的 LL(1)分析表的构造,我们可以将 它写在下面的格式中: 为了完善LL(1)分析算法,该表必须为每个非终结符 -记号对给出唯一选择,可以从下面的 定义开始。 定义: 如果文法 G相关的 LL(1)分析表的每个项目中至多只有一个产生式,则该文法 就是LL(1)文法(LL(1) grammar)。 由于上面的定义暗示着利用 LL(1)文法表就能构造出一个无二义性的分析,所以 LL(1)文法 不能是二义性的。当给出 LL(1)文法时,程序清单 4-2中有使用 LL(1)分析表的一个分析算法。 这个算法完全导致了在前一小节的示例中描述的那些动作。 虽然程序清单 4-2中的算法要求分析表的每个项目最多只能有一个产生式,但仍有可能在 表构造中建立消除二义性的规则,它可处理简单的二义性情况,如在与递归下降程序相似的方 式中的悬挂 else问题。 程序清单4-2 基于表的 LL(1)分析算法 116 编译原理及实践 例如, if语句简化了的文法(参见第 3章3.6节): statement → if-stmt | other if-stmt → if ( exp ) statement else-part else-part → else statement | exp → 0 | 1 构造LL(1)分析表就得出了表4-2中的结果,我们并未在表中列出括号终结符 (或),这是因为它 们并不引起动作(将在下一节中详细地解释这个表的构造)。 表4-2 (二义性的) if语句的LL(1)分析表 M [N, T ] statement if-stmt e l s e - p a rt exp if statement → if-stmt if-stmt → if ( exp ) statement e l s e - p a rt Other statement → other else 0 1 $ else-part → else statement else-part→ else-part → Exp → 0 exp → 1 在表4-2中,项目 M[ else-part, else]包括了两个项目,且它与悬挂 else二义性对应。与在 递归下降程序中一样,当构造这个表时,我们可以提供总是倾向于生成当前先行记号规则的消 除二义性的规则,则倾向于产生式 else-part → else statement 而不是产生式 else-part→ 。这实际上与最接近嵌套消除二义性规则对应。通过这个修改,表 42就变成无二义性的了,而且可以对文法进行分析,这就好像它是一个 LL(1)文法一样。例如, 表4-3显示了LL(1)分析算法的分析动作,它给出了串 if(0) if(1) other else other (为了简便,我们将图中的词进行缩写: statement = S、if-stmt = I、else-part = L、exp = E、if = i、else = e、other = o )。 表4-3 为if语句使用最接近嵌套消除二义性规则的 LL(1)分析 分析栈 $S $I $ LS ) E ( i $LS)E( $LS)E $ LS ) o 输入 i(0)i(1)oeo$ i(0)i(1)oeo$ i(0)i(1)oeo$ (0)i(1)oeo$ 0)i(1)oeo$ 0)i(1)oeo$ 动作 S→I I→i (E) SL 匹配 匹配 E→0 匹配 117 第 4章 自顶向下的分析 分析栈 $ LS ) $ LS $ LI $ LL S ) E ( i $ LL S ) E ( i $LLS)E( $ LL S ) E $ LL S ) $ LL S $ LL o $LL $ LS e $LS $ Lo $L $ 输入 )i(1)oeo$ i(1)oeo$ i(1)oeo$ i(1)oeo$ i(1)oeo$ (1)oeo$ 1)oeo$ )oeo$ oeo$ oeo$ eo$ eo$ o$ o$ $ $ (续) 动作 匹配 S→I I→i(E)SL I→i( E)SL 匹配 匹配 E→1 匹配 S→o 匹配 L→e S 匹配 S→o 匹配 L→ 接受 4.2.3 消除左递归和提取左因子 L L ( 1 )分析中的重复和选择也存在着与在递归下降程序分析中遇到的类似问题,而且正是 由于这个原因,还不能够为前一节的简单算法表达式文法给出一个 LL(1)分析表。我们利用 EBNF表示法解决了递归下降程序中的这些问题,但却不能在 LL(1)分析中使用相同的办法;而 必须将BNF表示法中的文法重写到 LL(1)分析算法所能接受的格式上。此时应用的两个标准技 术是左递归消除(left recursion removal)和提取左因子(left factoring)。我们将逐个考虑它们。 必须指出的是:这两个技术无法保证可将一个文法变成 LL(1)文法,这就同 EBNF一样无法保证 在编写递归下降程序中可以解决所有的问题。然而,在绝大多数情况下,它们都十分有用,并 且具有可自动操作其应用程序的优点,因此,假设有一个成功的结果,利用它们就可自动地生 成LL(1)分析程序(参见“注意与参考”一节)。 1) 左递归消除 左递归被普遍地用来运算左结合,如在简单表达式文法中, exp → exp addop term | term 使得运算由 addop左结合来代表。这是左递归的最简单情况,在其中只有一个左递归产生式选 择。当有不止一个的选择是左递归时,就略微复杂一些了,如可将 addop写出来: exp → exp + term | exp - term | term 这两种情况都涉及到了直接左递归( immediate left recursion),而左递归仅发生在一单个非终 结符(如 exp)的产生式中。间接左递归是更复杂的情况,如在规则 A→Bb|... B→Aa|... 中。这样的规则在真正的程序设计语言文法中几乎不可能发生,但本书为了完整仍给出它的解 决方法。首先考虑直接左递归。 118 编译原理及实践 情况1:简单直接左递归 在这种情况下,左递归只出现在格式 A→Aα | β 的文法规则中,其中 α和β是终结符和非终结符的串,而且 β不以A开头。在 3.2.3节中,我们看 到这个文法规则生成了格式 βαn (n≥0)的串。选择A→β是基本情况,而 A→Aα是递归情况。 为了消除左递归,将这个文法规则重写为两个规则:一个是首先生成 β,另一个是生成 α的 重复,它不用左递归却用右递归: A →βA′ A ′→αA′ | 例4.1 再次考虑在简单表达式文法中的左递归规则: exp → exp addop term | term 它属于格式A → Aα | β,且A = exp,α = addop term,β = term。将这个规则重写以消除左递归, 就可得到 exp → term exp′ exp′ → addop term exp′ | 情况2:普遍的直接左递归 这种情况发生在有如下格式的产生式 A → Aα | Aα | . . . | Aα | β | β | . . . | β 1 2 n 1 2 m 中。其中β , ... , β 均不以A开头。在这种情况下,其解法与简单情况类似,只需将选择相应地 1 m 扩展: A →β A′ | β A′ | ... | β A′ 1 2 m A′ →α A′ | α A′ | ... | α A′ | 1 2 n 例4.2 考虑文法规则 如下消除左递归: exp → exp + term | exp - term | term exp → term exp′ exp′ → + term exp ′ | - term exp ′ | 情况3:一般的左递归 这里描述的算法仅是指不带有 产生式且不带有循环的文法,其中循环( cycle)是至少有 一步是以相同的非终结符: A ⇒ α⇒*A开始和结束的推导。循环几乎肯定能导致分析程序进入 无穷循环,而且带有循环的文法从不作为程序设计语言文法出现。程序设计语言文法确实是有 产生式,但这经常是在非常有限的格式中,所以这个算法对于这些文法也几乎总是有效的。 该算法的方法是:为语言的所有非终结符选择任意顺序,如 A , . . . , A ,接着再消除不增 1 m 加A ′ 索引的所有左递归。它消除所有 A →A γ,其中j≤i 形式的规则。如果按这样操作从 1到m i i j 的每一个 i,则由于这样的循环的每个步骤只增加索引,且不能再次到达原始索引,因此就不 会留下任何递归循环了。程序清单 4-3是这个算法的详细步骤。 119 第 4章 自顶向下的分析 程序清单 4-3 普遍左递归消除的算法 例4.3 考虑下面的文法: A→Ba|Aa|c B→Bb|Ab|d (由于该情况在任何标准程序设计语言中都不会发生,所以这个文法完全是自造的)。 为了使用算法,就须令 B的数比A的数大(即A = A,且A = B)。因为n = 2,则图4-3中的 1 2 算法的外循环执行两次,一次是当 i = 1 ,另一次是当i = 2。当i = 1 时,不执行内循环(带有索 引j),这样唯一的动作就是消除 A的直接左递归。最后得到的文法是 A →B a A′ | c A′ A′ → a A′ | B → B b | A b| d 现在为i = 2执行外循环,且还执行一次内循环,此时 j = 1。在这种情况下,我们通过用第 1个 规则中的选择替换 A而省略了规则B→A b。因此就得到了文法 A →B a A′ | c A′ A′ → a A′ | B → B b | B a A′b | c A′b | d 最后消除 B的直接左递归以得到 A → B a A′ | c A′ A′ → a A′| B → c A′ b B′ | d B′ B′ → b B′ | a A′bB′ | 这个文法没有左递归。 左递归消除并不改变正被识别的语言,但它却改变了文法和分析树。这种改变确实导致了 分析程序变得复杂起来(对于分析程序设计人员而言,也更困难了)。例如在前面作为标准示 例的简单表达式文法中,该文法是左递归的,表达运算的结合性的表达式也是左递归。若要像 在例4.1中一样消除直接左递归,就可得到图 4-1中所给出的文法。 exp → term exp′ exp′ → addop term exp′ | addop → + | term → factor term′ term′ → mulop factor term′ | mulop → * factor → ( exp ) | number 图4-1 消除了左递归的简单算术表达式文法 120 编译原理及实践 现在考虑表达式 3-4-5的分析树: 这个树不再表达减法的左结合性了,然而,分析程序应仍构造出恰当的左结合语法树: 使用新文法来完成它不是完全微不足道的。为了看清原因,可先考虑利用给出的分析树来代替 计算表达式的值这样略为简单的问题。为了做到这一点,必须将值 3从根exp节点传送到它的右 子节点exp′。之后,这个exp′ 节点必须减去4,并将新值-1向下传到它的最右边的子节点(另一 个exp′)。这个exp′ 节点按顺序也必须减去 5并将值-6传送给最后的exp′ 节点。这个节点仅有一 个 子节点,且它仅仅是将值 -6传回来。接着,该值被向上返回到树的根 exp节点,它就是表达 式的最终值。 考虑它在递归下降分析程序中是如何工作的。其左递归消除的文法将产生如下过程 exp 和exp′: procedure exp ; begin term ; exp′ ; end exp ; procedure exp′ ; begin case token of + : match (+) ; term ; exp′ ; - : match (-) ; 121 第 4章 自顶向下的分析 term ; exp′ ; end case ; end exp′ ; 为了使这些过程真正计算表达式的值,应将其如下所示重写: function exp : integer ; var temp : integer ; begin temp := term ; return exp′(temp) ; end exp ; function exp′ ( valsofar : integer ) : integer ; begin if token = + or token = - then case token of + : match (+) ; valsofar := valsofar + term ; - : match (-) ; valsofar := valsofar - term ; end case ; return exp′ (valsofar) ; else return valsofar ; end exp′ ; 请注意exp′ 过程现在是如何需要一个来自 exp过程的参数。若这些过程将返回一个(左结合的) 语法树,则会发生类似的情况。在4.1节里,给出的代码使用了基于EBNF的更为简单的解法中, 它并不要求额外的参数。 最后,我们留意到程序清单 4-1中的新表达式文法实际上是一个 LL(1)文法。LL(1)分析表 在表4-4中给出了。正如对前面表格的处理一样,我们将在下一节再谈到它的构造。 表4-4 程序清单 4-1中文法的LL(1)分析表 M [N, T ] exp exp′ addop ( exp → term exp′ number ) exp → term exp′ exp′→ + - * exp′→ addop term exp′ addop → + exp′→ addop term exp′ addop → - $ exp′→ 122 编译原理及实践 M [N, T ] term term′ ( term → factor term′ number term → factor term′ ) term′ → + term′ mulop factor factor → ( exp ) factor → number (续) - * $ term′ → term′ → mulop factor term′ mulop → * term′ → 2) 提取左因子 当两个或更多文法规则选择共享一个通用前缀串时,需要提取左因子。如 A→αβ | αγ 以下是语句序列的右递归示例(第 3章的例3.7): stmt-sequence → stmt ; stmt-sequence | stmt stmt → s 以下是if语句的随后版本: if-stmt → if ( exp ) statement | if ( exp ) statement else statement 很明显,LL(1)分析程序不能区分这种情况中的产生式选择。这个简单情况的解法是将左边的 α 分解出来,并将该规则重写为两个规则 A →αA′ A′ →β | γ (如想用括号作为文法规则中的元符号,则也可写作 A→α(β | γ),它看起来很像算术中的因子 分解)。为了使提取左因子能够正常进行,就必须确保 α 实际上是与右边共享的最长串。这里 还可能有享有相同前缀的超过两个的选择。程序清单 4-4中的是普通算法,之后还有一些示例。 请注意,在处理算法时,每个共享相同前缀的非终结符的产生式的选择数目,每一步至少减少 一个,这样算法才能够保证终止。 程序清单4-4 提取左因子文法的算法 123 第 4章 自顶向下的分析 例4.4 考虑语句序列的文法,它写在右递归格式中就是: stmt-sequence → stmt ; stmt-sequence | stmt stmt → s stmt-sequence的文法规则有一个共享的前缀,它可按如下提取左因子: stmt-sequence → stmt stmt-seq′ stmt-seq′ → ; stmt-sequecne | 请注意,如果已经是左递归,而不是右递归地写出了 stmt-sequence规则 stmt-sequence → stmt-sequence ; stmt | stmt 则消除直接左递归将会导致规则 stmt-sequence → stmt stmt -seq′ stmt-seq′ → ; stmt stmt-seq′ | 这与从提取左因子中得到的结果几乎一样,在最后的规则中将 stmt stmt-seq ′替换成 stmtsequence就使得两个结果一样。 例4.5 考虑if语句的如下(部分)文法: if-stmt → if ( exp ) statement | if ( exp ) statement else statement 在这个文法中,提取了左因子的格式是 if-stmt → if ( exp ) statement else-part else-part → else statement | 这正是在4.2.2节中所用到的格式(参见表 4-2)。 例4.6 假设写出一个算术表达式文法,在其中给出了算术运算右结合性,而并非左结合性 (这里用 +仅是作为一个具体示例): exp → term + exp | term 这个文法需要提取左因子,则得到规则 exp → term exp′ exp′ → + exp | 现在继续做例4.4,假设在第2个规则中将exp替换为term exp′(由于这个扩展无论如何都会在推 导中的下一步发生,所以这是正规的)。接着就得到 exp → term exp′ exp′ → + term exp′ | 这与通过消除左递归而从左递归规则中得到的文法相同。因此,提取左因子和消除左递归都可 导致语言结构的语义晦涩(在这种情况下,它们都会阻碍结合性)。 124 编译原理及实践 例如,若要保存来自上面文法规则的右结合性(在别的格式中),就必须将每个 +运算安排 在结尾而不是开头。请读者为它写出一个递归下降程序过程。 例4.7 因为过程调用和赋值均以标识符开头,所以这是程序设计语言文法不能成为 LL(1)文法 的典型情况。我们写出这个问题的以下表示: statement → assign-stmt | call-stmt | other assign-stmt → identifier := exp call-stmt → identifier ( exp-list ) 因为identifier作为assign-stmt 和call-stmt 共享的第1个记号,所以这个文法不是LL(1);而 identifier也就是两个的先行记号。不幸的是,该文法不是位于一个可提取左因子的格式中。 我们必须要做的是首先将 assign-stmt 和call-stmt 用它们的定义产生式的右边代替,如下: statement → identifier := exp | identifier ( exp-list ) | other 接着提取左因子以得到 statement → identifier statement′ | other statement′ → := exp | ( exp-list ) 请注意它如何通过从真正的调用或赋值动作(由 statement′代表)中分隔标识符(被赋予的值 或被调用的过程)而使调用的语义和赋值变得晦涩。 LL(1)分析程序必须通过使标识符以某种 方式(如一个参数)对调用或赋值步骤有用,或调整语法树来弥补它。 最后,应指出在所有的例子中,我们已使提取左因子确实在转换之后变成了 LL(1)文法。 下一节将为其中的一些构造 LL(1)分析表,另一些则留在练习中。 4.2.4 在LL(1)分析中构造语法树 我们还需探讨LL(1)分析如何适应于构造语法树而不是分析树(在 4.2.1节中已描述了如何 利用分析栈构造分析树)。我们看到有关适用于语法树构造方法的递归下降程序分析的章节相 对简单,而 LL(1)分析程序却难以适用。其部分原因在于,正如我们所看到的,语法树的结构 (如左结合性)会因提取左因子和消除左递归而变得晦涩。但是它的主要原因却是分析栈所代 表的仅是预测的结构,而不是已经看到的结构。因此,语法树节点的构造必须推迟到将结构从 分析栈中移走时,而不是当它们首次被压入时。一般而言,这就要求使用一个额外的栈来记录 语法树节点,并在分析栈中放入“动作”标记来指出什么动作何时将在树栈中发生。自底向上 分析程序(下一章)则易于适应使用了分析栈的语法树的构造,而且因此也就倾向于基于栈的 表驱动分析方法。所以我们只给出一个简要的示例来解释它是如何用于 LL(1)分析的。 例4.8 我们使用一个仅带有一个加法运算地表达式文法。其 BNF是 E→E+ n|n 这样就使得加法为左结合了。相应地带有消除左递归的 LL(1)文法是 E → n E′ E′ → + n E′ | 125 第 4章 自顶向下的分析 现在说明如何使用这个文法来计算表达式的算术值(语法树的构造是类似的)。 为了计算一个表达式的值,就要用一个单独的栈来存储计算的中间值,这个栈称作值栈 (value stack)。在这个栈上必须安排两个运算。第 1个运算是在输入中匹配数时将它压入,第 2 个是在栈中两数相加的运算。第 1个运算由match过程完成(基于它匹配的记号),第2个需要被 安排在分析栈上。它是通过将一个特殊符号压入分析栈中,而当其弹出时,则表示完成了一个 加法运算。此处所用的符号是 (#)。这个符号现在成为一个新的栈符号,而且必须也被添加到 匹配一个“+”的文法规则中,即E′ 规则: E′ → + n # E′ | 请注意,将加法放在紧随下一个数之后的位置上,但是却在处理任何其他的 E′非终结符之前。 这就保证了是左结合。现在来看看如何计算表达式 3+4+5的值。如前所述指出分析栈、输入和 动作,但却将值栈放在右边(它向左增长)。表4-5是分析程序的动作。 表4-5 例4.8的带有值栈动作的分析栈 分析栈 $E $E′ n $E $E #n+ $E #n $E # $E $E #n+ $E #n $E # $E $ 输入 3 + 4 + 5$ 3 + 4 + 5$ + 4 + 5$ + 4 + 5$ 4 + 5$ + 5$ + 5$ + 5$ 5$ $ $ $ 动作 E→nE 匹配/压入 E →+n#E 匹配 匹配/压入 加法栈 E →+n#E 匹配 匹配/压入 加法栈 E →e 接受 值栈 $ $ 3$ 3$ 3$ 43$ 7$ 7$ 7$ 57$ 12 $ 12 $ 请注意,当发生一个加法时,操作数按相反顺序位于栈中。这是这种基于栈的赋值的典型 安排。 4.3 First集合和Follow集合 为了完成 LL(1)分析算法,我们开发了一个构造 LL(1)分析表的算法。正如早先已指出的, 它涉及到计算 First集合和 Follow集合,本节将给出这些集合的定义和构造,之后再准确描述 LL(1)分析表的构造。本节最后简要地介绍如何将该构造扩展为多于一个向前看符号。 4.3.1 First集合 定义:令X为一个文法符号(一个终结符或非终结符)或 ,则集合First (X) 由终结符 组成,此外可能还有 ,它的定义如下: 1. 若X是终结符或 ,则First (X) = {X}。 2. 若X是非终结符,则对于每个产生式 X→X X . . . X ,First (X)都包含了 First 12 n (X ) - { }。若对于某个 i < n,所有的集合 First (X ), . . . , First (X ) 都包括了 1 1 i 126 编译原理及实践 ,则First (X) 也包括了First (X ) - { }。若所有集合 First (X ), . . . , First (X i +1 1 n )包括了 ,则First (X)也包括 。 现在为任意串α = X X . . . X (终结符和非终结符的串)定义 First ( ),如下所 12 n 示:First (α)包括First (X ) - { }。对于每个 i = 2, . . . , n,如果对于所有的 k = 1, . . . , 1 i -1,First (X ) 包括了 ,则First (α)就包括了First (X ) - { }。最后,如果对于所有 k i 的i =1, . . . , n,First (X ) 包括了 ,则First (α)也包括了 。 i 这个定义可很容易地转化为一个算法。实际上,唯一困难的是为每个非终结符 A计算First (A),这是因为终结符的First 集合是很简单的,并且串 α的First 集合从几乎n 个单个符号的First 集合建立,其中 n 是在α中的符号数。因此只有在非终结符时才为算法写成伪代码,如程序清 单4-5所示。 程序清单 4-5 为所有的非终结符 A计算First (A)的算法 也可以很容易看出如何在没有 产生式的情况下解释这个定义:只需不断为每个非终结符 A 和产生式选择A→X . . . ,向First (A)增加First (X )一直到再没有增加什么。换而言之,只需考 1 1 虑程序清单4-5中k = 1的情况,而不需要while 内循环。我们将这个算法单独写在程序清单4-6中。 当存在 产生式时,情况就复杂一些了,这是因为必须查清 是否在First (X )中,若是则为X 继 1 2 续相同的处理,等等。该处理将一直延续到有穷步骤之后才结束;但是实际上,这个处理不但 计算可作为从非终结符派生的串的第 1个符号而出现的终结符,而且它还决定非终结符是否可 派生空串(即:消失)。这样的非终结符称为可空的: 程序清单 4-6 当没有 产生式时,程序清单 4-5的简化了的算法 定义:当存在一个推导A⇒* 时,非终结符A称作可空的(nullable)。 现在给出以下的法则。 定理:当且仅当First ( A)包含 时,非终结符A为可空的。 证明:下面证明若A是可空的,则First (A)包含了 。其逆命题也可用相似的方法得到证 明。我们利用对产生式长度的归纳来推导。若 A ⇒ ,则必有一个产生式 A→ ,且由 定义可得First (A)包含了First ( ) ={ }。现在假设对于长度< n 的推导的语句为真,并令 A ⇒ X . . . X ⇒ * 是长度为n 的一个推导(利用产生式选择A→X . . . X )。如果任何 1 k 1 k 127 第 4章 自顶向下的分析 一个X 都是终结符,则它们都不能派生 ,所以所有的X 都必须是非终结符。实际上, i i 上面的推导并不完整,它意味着每个 X ⇒ * ,且推导少于n 个步骤。因此,通过归纳 i 假设,对于每个i,First (X ) 都包含了 。最后,由定义可得First (A) 必须包含 。 i 下面给出一些非终结符的 First 集合的计算示例。 例4.9 考虑简单整型表达式文法 : exp → exp addop term | term addop → + | term → term mulop factor | factor mulop → * factor → ( exp ) | number 我们分别写出每个选择,这样就可按顺序思考它们了(还可为了引用作上编号): (1) exp → exp addop term (2) exp → term (3) addop → + (4) addop → (5) term → term mulop factor (6) term → factor (7) mulop → * (8) factor → ( exp ) (9) factor → number 这个文法并未包括 产生式,所以可使用程序清单 4-6中简化了的算法。我们还注意到左递归规 则1和5都未向First集合的计算增加任何东西 。例如,文法规则 1规定只有那个 First (exp)应被 添加到First (exp)中。因此,可从计算中删除这些产生式。但在这个例子中,为了表示清楚仍 把它们保留在列表中。 现在应用程序清单 4-6的算法,并同时按照刚才给出的顺序考虑产生式。产生式 1未做任何 变化。产生式2将First (term)的内容增加到First ( exp),但是First ( term)当前为空,所以它也没有 任何变化。规则3和4分别给First (addop)增添“+”和“-”,所以First (addop) = {+,-}。规则5 并未增加任何东西。规则 6将First (factor)增添到First (term)中,但是First (factor)当前仍为空, 所以也未发生任何改变。规则 7向First (mulop)增添*,所以First (mulop) = {*}。规则8将(增加 到First (factor)中,而规则9将number增加到First (factor)中,所以First (factor) = { (, number }。现在再从规则 1开始,这是由于它并未有任何变化。现在规则 1到5都未有任何变化 (First(term)仍为空)。规则6将First (factor)增加到First (term)中,且First (factor) = {(,number },所以现在也有First (term) = {(,number }。规则8和9未带来任何变化。我们必须再一次从 规则1开始,这是因为有一个集合改变了,规则 2最后将First (term)的内容增加到First (exp)中, 且First (exp) = {(,number }。需要使用多遍文法规则,直到再没有改变发生,所以在4遍之后, 这个文法具有左递归且不是 LL(1),所以我们不能为它建立一个 LL(1)分析表。但是它仍是一个对于解释如何 计算First集合的有用示例。 当存在 产生式时,左递归规则可用于 First集合。 128 编译原理及实践 就已计算出以下的 First集合: First (exp) = {(, number } First (term) = {(, number } First (factor) = {(, number } First ( addop) = { +, - } First (mulop) = {*} (请注意,如果是一开始而不是最后为 factor列出文法规则,则可将文法规则使用遍数由 4减少 为 2)。表 4-6 列出了这个计算。在这个表中,只是在它们发生的恰当位置中将记录改变。空的 项目表示在该步骤中串没有发生变化。由于没有改变发生,所以最后一遍不再进行。 表4-6 为例4.9中的文法计算 First集合 文法规则 exp → exp a d d o pt e r m exp → term addop → + addop → - term → term mulop factor term → factor mulop → * factor → ( exp ) factor → number 第1遍 First (addop) = {+} First (addop) = {+, -} First (mulop) = {*} First (factor) = {(} First (factor) = {(, number} 第2遍 第3遍 First (exp) = {(, number} First(term) = {(, number} 例4.10 考虑if语句(例4.5)的(提取左因子)文法: statement → if-stmt | other if-stmt → if ( exp ) statement else-part else-part → else statement | exp → 0 | 1 这个文法确实有 产生式,但只有 else-part非终结符是可空的,所以计算的难度最小。由于没 有一个是以其 First 集合包括了 的非终结符开头,所以其实只需要在某一步中增加 ,并保持 其他步骤不受影响。这对于真正的程序设计语言文法是非常典型的,其中的 产生式几乎总是 非常有限的,且极少显示在普通情况中的复杂性。 同前面的一样,单独写出各个文法规则选择,并给其编号: 129 第 4章 自顶向下的分析 (1) statement → if-stmt (2) statement → other (3) if-stmt → if ( exp ) statement else-part (4) else-part → else statement (5) else-part → (6) exp → 0 (7) exp → 1 我们再次一个一个地运行产生式选择的步骤,一旦一个 First 集合在前一遍中有了改变,就制造 出新的一遍来。因为 First (if-stmt)为空,所以文法规则 1开始时没有变化。规则 2将终结符 other 增加到First (if-stmt)中,所以First (statement) = {other}。规则3将if增加到First (ifstmt)中,所以First (if-stmt) = {if}。规则4在First (else-part)中增加else,所以First (else-part) = {else}。规则5在First (else-part)中增加 ,所以First (else-part) = {else, }。规则6和规则7 分别将0和1增加到First (exp)中,所以 First (exp) = {0, 1}。现在从规则 1开始做另一遍。由于 First (if-stmt)包括了if终结符,所以现在该规则又将 if增加到First (statement)中。由此, First (statement) = { if,other}。第2遍中再没有其他改变了,且第 3遍也无任何改变。因此,我们 计算出以下 First集合: First (statement) = {if, other} First (if-stmt) = {if} First (else-part) = {else, } First (exp) = {0, 1} 表4-7 以与表 4-6类似的方式展示了这个计算。同前面一样,该表只显示改变,且不显示最后一 遍(因为在其中没有改变)。 表4-7 为例4.10中的文法计算First集合 文法规则 statement → if-stmt statement → other if-stmt → if ( exp ) statement else-part else-part → else statement else-part→ exp → 0 exp → 1 第1遍 First ( statement) = {other} First ( if-stmt) = {if} First ( else-part) = {else} First ( else-part) = {else, } First ( exp) = { 0} First (exp) = { 0, 1} 第2遍 First (statement) = {if, other} 例4.11 考虑下面的语句序列文法(参见例 4.4): stmt-sequence → stmt stmt-seq′ stmt-seq → ; stmt-sequence | 130 编译原理及实践 stmt → s 我们再一次单独列出每个产生式选择: (1) stmt-sequence → stmt stmt-seq′ (2) stmt-seq′ → ; stmt-sequence (3) stmt-seq′ → (4) stmt → s 在第1遍中,规则1并未增加任何东西。规则 2和规则3导致First (stmt-seq′) = {;, }。规则4导致 First (stmt) = {s}。在第2遍中,规则1这次使First (stmt-sequence) = First (stmt) = {s}。除此之外 就再也没有其他改变了。第 3遍也没有任何改变。我们计算出以下的 First集合: First (stmt-ewquence) = {s} First (stmt) = {s} First ( stmt-seq′ ) = {;, } 请读者自己构造一个与表 4-6和表4-7类似的表格。 4.3.2 Follow 集合 定义:给出一个非终结符 A,那么集合 Follow(A)则是由终结符组成,此外可能还有 $。 集合Follow(A)的定义如下: 1. 若A是开始符号,则$就在Follow(A)中。 2. 若存在产生式B→αAγ,则First ( γ) - { }在Follow(A)中。 3. 若存在产生式B→αAγ,且 在First(γ)中,则Follow(A)包括Follow(B)。 首先检查这个定义的内容,之后为由此引出的 Follow集合的计算写出算法。读者首先应注 意到用作标记输入结束的“ $”,它就像是Follow集合计算中的一个记号。若没有它,那么在整 个被匹配的串之后就没有符号了。由于这样的串是由文法的开始符号生成的,所以 $必须总是 要增加到开始符号的 Follow集合中(若开始符号从不出现在产生式的右边,那么它就是开始符 号的Follow集合的唯一成分)。 读者其次需要注意的是:空的“伪记号” 永远也不是Follow集合的元素,它之所以有意 义是因为 在First集合中是被仅仅用来标记那些可消失的串。在输入中它不能真正地被识别出 来。而另一方面, Follow符号则总是相对于现存的输入(包括 $符号,它将匹配由扫描程序生 成的EOF)来匹配的。 大家还应注意到Follow集合仅是针对非终结符定义的,然而 First 集合却还可为终结符以及 终结符串和非终结符串定义。我们可将 Follow集合的定义扩展到符号串,但由于在构造 LL(1) 分析表时,仅需要非终结符的 Follow集合,所以这是不必要的。 最后,我们还要留意 Follow集合的定义在产生式的“右边”起作用,而 First集合的定义却 是在“左边”起作用。由此就可说若 α不包括A,则产生式 A→α 就没有任何有关 A的Follow集 合的信息。只有当A出现在产生式的右边时,才可得到 Follow (A)。所以一般地,每个文法规则 选择都可得到右边的每个非终结符的 Follow 集合。与在First 集合中的情况不同,每个文法规 则选择添加唯一的非终结符(在左边的那个)的 First 集合。 此外,假设有文法规则 A→αB,那么 Follow (B) 将通过定义中的情况 (3)包含Follow (A)。 这是因为在任何包括了 A的串中, A可被αB代替(这是动作中的“上下文无关”)。这个特性在 131 第 4章 自顶向下的分析 某种意义上与 First 集合的情况相反:若有 A→Bα,那么First (A) 就包括了 First (B)(除了 的 可能)。 程序清单4-7给出了计算由Follow集合的定义得出的Follow集合的算法。利用这个算法为相 同的3个文法计算Follow集合,我们曾在前面为这 3个文法计算了First 集合(同在First集合中一 样,当没有 产生式时,算法就简化了,我们把它留给读者)。 程序清单4-7 计算Follow集合的算法 例4.12 我们再考虑一下在例 4.9中曾计算过的First集合的简单表达式文法,如下所示: First ( exp) = {(, number } First (term) = {(, number } First (factor) = {(, number } First ( addop) = { +, - } First (mulop) = {*} 再次写出带有编号的产生式: (1) exp → exp addop term (2) exp → term (3) addop → + (4) addop → (5) term → term mulop factor (6) term → factor (7) mulop → * (8) factor → ( exp ) (9) factor → number 规则3、规则4、规则7和规则9的右边均无非终结符,所以它们未向 Follow集合的计算增加任何 东西。我们再按顺序考虑其他规则。在开始之前,先设 Follow (exp) = {$};其他的Follow集合 都先设置为空。 规则1影响了3个非终结符的 Follow集合:exp、addop和term。将First (addop)增加到Follow (exp),所以现在Follow (exp) = {$, +, -}。接着,将First (term)增加到Follow (addop)中,所以 现在Follow (addop) = { (, number}。最后,将Follow (exp)增加到Follow ( term)中,所以Follow (term) = {$, +, -}。 规则2再次导致 Follow (exp)被添加到 Follow (term)中,但是这刚才已由规则 1完成,所以 F o l l o w集合就不再发生什么改变了。 132 编译原理及实践 规则5有3个影响:将First (mulop)添加到Follow ( term)中,所以Follow (term) = {$, +, -, *}。 接着,将First (factor)被添加到Follow (mulop)中,所以Follow (mulop) = {(, number}。最后将 Follow (term)增加到Follow (factor)中,所以Follow (factor) = {$, +, -, *}。 规则6导致的结果与规则5的最后一步相同,所以也就没有任何改变了。 最后,规则8将First ( ) ) = {)}增加到Follow ( exp)中,所以Follow (exp) = {$, +, -, )}。 在第2遍中,规则1将)增加到Follow (term)中(所以Follow (term) = {$, +, -, *, )});规则5 将)增加到Follow (factor)中(所以Follow (factor) = {$, +, -, *, )})。第3遍未引起任何改变,所 以算法结束。这样我们就计算了以下的 Follow集合: Follow (exp) = {$, +, -, )} Follow (addop) = {(, number} Follow (term) = {$, +, -, *,)} Follow (mulop) = {(, number} Follow (factor) = {$, +, -, *, )} 同在计算 First集合中的一样,我们将计算过程放在表 4-8中。同前面一样,在该表中省略 了结束的遍而只指出当改变发生时 Follow集合的变化。我们还省略了对计算不可能有影响的 4 个文法规则选择(之所以包括了两个规则 exp → term和term → factor,是因为它们虽然没有真 正的影响,但仍存在可能的影响)。 表4-8 为例4.12中的文法计算 Follow集合 文法规则 exp → exp addop term exp → term term → term mulop factor term → factor factor → ( exp ) 第1 遍 Follow ( exp) = {$, +, -} Follow ( addop) = {(, number} Follow ( term) = {$, +, -} Follow ( term) = {$, +, -, *} Follow ( mulop) = {(, number} Follow ( factor) = {$, +, -, *} Follow (exp) = {$, +, -, )} 第2遍 Follow (term) = {$, +, -, *, )} Follow ( factor) = {$, +, -, *, )} 例4.13 再次考虑if语句简化了的文法,在例4.10中已计算了它的First集合,如下: First ( statement) = {if, other} First (if-stmt) = {if} First (else-part) = {else, } 133 第 4章 自顶向下的分析 First (exp) = {0, 1} 这里再重复一下带有编号的产生式: (1) statement → if-stmt (2) statement → other (3) if-stmt → if ( exp ) statement else-part (4) else-part → else statement (5) else-part → (6) exp → 0 (7) exp → 1 规则2、规则5、规则6和规则7对Follow集合的计算没有影响,所以忽略它们。 首先设Follow (statement) = {$},并将其他非终结符的 Follow集合初始化为空。规则 1现在 将Follow (statement) 添加到Follow (if-stmt)中,所以Follow (if-stmt) = {$}。规则3影响到了exp、 statement和else-part的Follow集合。首先,Follow (exp)得到First ()) = {)},所以Follow (exp) = {)}。接着,Follow ( statement) 得到First (else-part) - { },所以Follow (statement) = {$, else}。 最后,将Follow (if-stmt) 添加到Follow ( else-part) 和Follow (statement) 中(这是因为if-stmt会消 失)。第1个加法得到了Follow (else-part) = {$},但第2个却无任何变化。最后,规则 4将Follow (else-part)添加到Follow (statement)中,同样也没有任何改变。 在第2遍中,规则 1再次将 Follow (statement)添加到Follow (if-stmt)中,导致了 Follow (ifstmt) = {$, else}。规则3现在将终结符 else增加到Follow (else-part)中,所以 Follow (elsepart) = {$, else}。最后,规则 4并未带来任何改变。第 3遍同样也无任何变化,所以计算出以 下的Follow集合: Follow (statement) = {$, else} Follow ( if-stmt) = {$, else} Follow (else-part) = {$, else} Follow ( exp) = {)} 请读者像上例一样为这个计算构造一个表。 例4.14 为例4.11中简化了的语句序列文法(并且带有文法规则选择)计算 Follow集合: (1) stmt-sequence → stmt stmt-seq′ (2) stmt-seq′ → ; stmt-sequence (3) stmt-seq′ → (4) stmt → s 在例4.11中,计算了以下的 First集合: First (stmt-ewquence) = {s} First (stmt) = {s} First (stmt-seq′) = {;, } 文法规则3和规则4并未对Follow集合的计算有任何影响。我们从 Follow ( stmt-sequence) = {$}开 始,并使其他Follow集合为空。规则1导致了Follow (stmt) = {;}和Follow (stmt-seq′) = {$}。规 则2未带来任何影响。另一遍也未产生更多的改变。所以计算出 Follow集合 Follow (stmt-eequence) = {$} 134 编译原理及实践 Follow (stmt) = {;} Follow ( stmt-seq′) = {$} 4.3.3 构造LL(1)分析表 现在考虑LL(1)分析表中各项的初始结构,如在 4.2.2节中给出的: 1) 如果A→α 是一个产生式选择,且有推导 α ⇒ ∗αβ 成立,其中α是一个记号,就将 A→α 添加到表项M [A, a]中。 2) 如果A→ 是 产生式,且有推导S$ ⇒ ∗ Aa β 成立,其中S是开始符号,a 是一个记号 (或$),就将A→ 添加到表项M [A, a] 中。 规则1中的记号a 很明显是在First (α) 中,且规则2的记号a 是在Follow (A)中,因此,就可 得到LL(1) 分析表的以下算法构造: LL(1) 分析表M[N, T] 的构造:为每个非终结符 A和产生式A→α 重复以下两个步骤: 1) 对于First (α)中的每个记号a,都将A→α添加到项目M [A, a]中。 2) 若 在First (α)中,则对于 Follow ( A) 的每个元素 a(记号或是 $),都将 A→α 添加到 M [A, a]中。 下面的定理基本上是 LL(1)文法的定义和刚才给出的分析表构造的直接结果,它的证明留 在了练习中: 定理:若满足以下条件,则 BNF中的文法就是LL(1)文法(LL(1) grammar)。 1. 在每个产生式A→α | α |...|α 中,对于所有的i 和j:1≤i,j≤n,i≠j,First (α ) 1 2 n i ∩First (α )为空。 j 2. 若对于每个非终结符A都有First (A) 包含了 ,那么First ( A)∩Follow ( A)为空。 现在针对前面的文法来看一些分析表的例子。 例4.15 考虑在本章中一直作为标准示例的简单表达式文法。该文法如最开始给出的(参见例 4.9)一样为左递归。在前一节用消除左递归写出了一个相等的文法,如下: exp → term exp′ exp′ → addop term exp′ | addop → + | term → factor term′ term′ → mulop factor term′ | mulop → * factor → ( exp ) | number 必须为这个文法的非终结符计算 First集合和Follow集合。我们把这个计算留到了练习中,这里 只是给出结果: First (exp) = {(, number} First ( exp′) = {+, -, } First (addop) = {+, -} First (term) = {(, number} First (term′) = {*, } First (mulop) = {*} Follow (exp) = {$, )} Follow (exp′) = {$, )} Follow (addop) = { (, number} Follow (term) = {$, ), +, -} Follow (term′) = {$, ), +, -} Follow (mulop) = { (, number} 135 第 4章 自顶向下的分析 First (factor) = {(, number} Follow (factor) = {$, ), +, -, *} 应用刚才描述的LL(1)分析表就可得到如表4-4的表格了。 例4.16 考虑if 语句的简化了的文法: statement → if-stmt | other if-stmt → if ( exp ) statement else-part else-part → else statement | exp → 0 | 1 该文法的 First集合和 Follow集合分别在例 4.10和例4.13中已做过了计算。这里再把它们列出来: First (statement) = {if, other} First (if-stmt) = {if} First (else-part) = {else, } First (exp) = {0, 1} Follow (statement) = {$, else} Follow (if-stmt) = {$, else} Follow ( else-part) = {$, else} Follow (exp) = {)} 构造出的 LL(1)分析表在表 4-3中。 例4.17 考虑例4.4中的文法(带有了提取左因子 ): stmt-sequence → stmt stmt-seq′ stmt-seq′ → ; stmt-sequence | stmt → s 该文法有以下的First集合和Follow集合: First (stmt-ewquence) = {s} First (stmt) = {s} First (stmt-seq′) = {;, } Follow ( stmt-eequence) = {$} Follow (stmt) = {;, $} Follow ( stmt-seq′) = {$} 下面是LL(1)分析表。 M [N, T ] stmt-sequence stmt s t m t - s e q′ s stmt-sequence → stmt stmt-seq′ stmt → s ; $ stmt-seq′ → ; stmt-sequence stmt-seq′ → 4.3.4 再向前:LL(k)分析程序 前面的工作可推扩到先行k 个符号。例如,可定义First (α) = { w | α ⇒ * w },其中w 是一 k k 个记号串,且 w = w 的前k 个记号(或若 w 中的记号少于 k 个,则为 w)。类似地,还可定义 k Follow k (A) = { w k | S$ ⇒ * αAw}。虽然这些定义比起当 k = 1时的定义缺少一些“算法性”,但 是仍可开发出计算这些集合的算法来,且 LL(k) 分析表的构造也可像前面一样完成。 在LL(k) 分析中仍出现了一些复杂之处。首先,分析表变得大了许多,这是由于列数按 k 的指数次增加(对于一定的推扩,可利用表压缩方法算出来)。其次,分析表本身并不表达 LL(k) 分析的全部能力,这主要是由于所有的 Follow串并不在所有的上下文中发生。因此,使 136 编译原理及实践 用如前所构造的表的分析与 LL(k) 分析就不同了, LL(k) 分析被称作强LL(k) 分析(Strong LL(k) parsing)或SLL(k) 分析(SLL(k) parsing)。读者可查阅“注意与参考”一节以得到更多的信息。 LL(k) 分析程序和 SLL(k) 分析程序在使用中都不普遍,其部分原因在于它们增加了难度, 但主要原因还是在于对于任何的 k而言,在 LL(1)中失败的文法在实际应用中也不可能会有 LL(k) 。例如,无论k为多大,带有左递归的文法永远不会是 LL(k) 。但是,递归下降分析程序 却能在需要的时候选择使用更大的先行,甚至于如前所看到的对于任何 k,可使用特别的方法 来分析不是LL(k) 的文法。 4.4 TINY语言的递归下降分析程序 本节将讨论附录 B中列出的TINY语言的完整的递归下降程序。分析程序构造了如上一章的 3.7节所描述的语法树,除此之外,它还将语法树的表示打印到列表文件中。分析程序使用如 程序清单 4-8中所给出的 EBNF,它与第 3章的程序清单 3-1相对应。 程序清单 4-8 EBNF中TINY语言的文法 TINY分析程序完全按照 4.1节给出的递归下降程序的要点。这个分析程序包括两个代码文件: parse.h和parse.c。parse.h文件(附录 B的第850行到第 865行)极为简单:它由一个 声明 TreeNode * parse (void); 组成,它定义了主分析例程 parse,parse又返回一个指向由分析程序构造的语法树的指针。 parse.c文件在附录B的第900行到第1114行中,它由11个相互递归的过程组成,这些过程与 程序清单4-8中的EBNF文法直接对应:一个对应于stmt-sequence,一个对应于statement,5个分 别对应于5种不同的语句,另有 4个对应于表达式的不同优先层次。操作符非终结符并未包括到 过程之中,但却作为与它们相关的表达式被识别。这里也没有过程与 program相对应,这是因 为一个程序就是一个语句序列,所以 parse例程仅调用stmt_sequence。 分析程序代码还包括了一个保存向前看记号的静态变量 token以及一个查找特殊记号的 match过程,当它找到匹配时就调用 getToken,否则就声明出错;此外代码还包括将出错信 息打印到列表文件中的 syntaxError过程。主要的 parse过程将token初始化到输入的第 1 个记号中,同时调用 stmt_sequence,接着再在返回由stmt_sequence构造的树之前检查 源文件的末尾(如果在 stmt_sequence返回之后还有更多的记号,那么这就是一个错误)。 137 第 4章 自顶向下的分析 每个递归过程的内容应有相对的自身解释性, stmt_sequence却有可能是一个例外,它 可写在一个更为复杂的格式中以提高对出错的处理能力;本书将在出错处理的讨论中简要地解 释这一点。递归分析过程使用 3种实用程序过程,为了方便已将它们都放在了文件 util.c (附录B的第350行到第526行)中,此外它还带有接口 util.h(附录B的第300行到第335行)。 这些过程是: 1) newStmtNode(第405行到第421行),它取用一个指示语句种类的参数,它还在分配 该类的新语句节点的同时返回一个指向新分配的节点的指针。 2) newExpNode(第423行到第440行),它取用一个指示表达式种类的参数,它还在分配 该类新表达式节点的同时返回一个指向新分配的节点的指针。 3) copyString(第442行到第455行),它取用一个串参数,为拷贝分配足够的空间,并 拷贝串,同时返回一个指向新分配的拷贝的指针。 由于C语言不为串自动分配空间,且扫描程序会为它所识别的记号的串值(或词法)重复 使用相同的空间,所以 copyString过程是必要的。 util.c中也包括了过程 printTree(第473行到第506行),util.c将语法树的线性版 本写在了列表中,这样就能看到分析的结果了。这个过程在全程变量 traceParse控制之下从 主程序中调用。 通过打印节点信息以及在此之后缩排到孩子信息中来操作 printTree过程;从这个缩排 中可构造真正的树。样本 TINY程序的语法树(参见第 3章的程序清单 3-3 和图 3-6)由 traceParse打印到列表文件中,如程序清单 4-9所示。 程序清单4-9 由printTree过程显示一个 TINY语法树 4.5 自顶向下分析程序中的错误校正 分析程序对于语法错误的反应通常是编译器使用中的主要问题。在最低限度之下,分析程 序应能判断出一个程序在语句构成上是否正确。完成这项任务的分析程序被称作识别程序 138 编译原理及实践 (recognizer),这是因为它的工作是在由正讨论的程序设计语言生成的上下文无关语言中识别 串。值得一提的是:任何分析至少必须工作得像一个识别程序一样,即:若程序包括了一个语 法错误,则分析必须指出某个错误的存在;反之若程序中没有语法错误,则分析程序不应声称 有错误存在。 除了这个最低要求之外,分析程序可以显示出对于不同层次的错误的反应。通常地,分析 程序会试图给出一个有意义的错误信息,这至少是针对于遇到的第 1个错误,此外它还试图尽 可能地判断出错误发生的位置。一些分析程序甚至还可尝试着进行错误校正( error correction) (或可能更恰当一些,应说是错误修复( error repair)),在这里分析程序试图从给出的不正确 的程序中推断出正确的程序。若它能这样做,那么这种情况绝大多数遇到的都是简单问题,如 缺少了标点符号。在这里存在着一个算法体,它可被用来寻找在某种意义上与已给出的程序 (通常是指必须被插入的、被删除的或被改变的记号)最近的正确程序。这样的最小距离错误 校正(minimal distance error correction)当用于每个错误时通常效率就太低了,而且不管怎样 它也是与程序员真正希望的相去甚远。因此,在真正的分析程序中极少能看到它。编译器编写 者发现若不做错误校正则很难生成有意义的错误信息。 用于错误校正的大多数技术都很特别,在某种意义上说,它们需要特殊的语言和特殊的分析 算法,在许多特殊情况下还要求有单独的解法,对此很难得到一般原理。以下是一些重要的事项。 1) 分析程序应试着尽可能快地判断出错误的发生。在声明错误之前等待太久就意味着真正 的错误的位置可能已丢失了。 2) 在错误发生之后,分析程序必须挑选出一个可能的位置来恢复分析。分析程序应总是尝 试尽可能多地分析代码,这是为了在翻译中尽可能多地找到真实的错误。 3) 分析程序应避免出现错误级联问题( error cascade problem),在这里错误会产生一个冗 长的虚假的出错信息。 4) 分析程序必须避免错误的无限循环,此时不用任何输入都会产生一个出错信息的无限级联。 其中的一些目标自相矛盾,所以编译器的编写者必须在构造错误处理器时应作一些折衷。 例如,避免错误级联和无穷循环问题会引起分析程序跳过一些输入,它与处理尽可能多的输入 的目标相折衷。 4.5.1 在递归下降分析程序中的错误校正 递归下降分析程序中的错误校正的一个标准形式叫做应急方式( panic mode)。这个名称 是这样得来的:在复杂情况下,错误处理器将可能要试图在大量的记号中找到一个恢复分析的 位置(在最糟的情况中,它可能甚至会用完所有剩余的程序,此时只有在错误出现后退出了)。 但是,若在操作时小心一些,那么在进行错误校正时要比其名称的意味要好一些 。应急方式 还具有一个优点:它能真正保证在错误校正时,分析程序不会掉到无穷循环之中。 应急方式的基本机制是为每个递归过程提供一个额外的由同步记号( synchronizing token) 组成的参数。在分析处理时,作为同步记号的记号在每个调用发生时被添加到这个集合之中。 如若遇到错误,分析程序就向前扫描( scan ahead),并一直丢弃记号直到看到输入中记号的一 个同步集合为止,并从这里恢复分析。在做这种快速扫描时,通过不生成新的出错信息来(在 某种程度上)避免错误级联。 在进行该错误校正时需要作出一个重要的判断:在分析的每步中需添加哪些记号。一般地, 实际上,Wirth [1976] 将应急方式称作“不应急”的规则,这大概是试图要改善它的形象吧。 139 第 4章 自顶向下的分析 Follow集合是这样的同步记号中的重要一员。 Follow集合也可用来使错误处理器避免跳过开始 新的主要结构的重要记号(如语句或表达式)。First集合也很重要,这是因为它们允许递归下 降分析程序能在分析的早些时候检测出错误,它对于任何错误校正总是有用的。当编译器“知 道”何时不应着急时,应急方式工作得最好,掌握这一点很重要。例如,丢失了诸如分号或逗 号,甚至是右括号的标点符号不应总是致使错误处理器耗费记号。当然,必须留意要确保不能 发生无穷循环。 我们通过用伪代码在 4.1.2节中的递归下降计算器中勾画它的执行过程来讲解应急方式的错 误校正(参见程序清单 4-1)。除了保持基本相同(但错误再也不能立即退出了)的 match和 error这两个过程之外,还有两个过程,完成早期的先行检查的 checkinput,以及应急方式记号 耗费者拥有的scanto: procedure scanto ( synchset ) ; begin while not ( token in synchset ∪{ $ }) do getToken ; end scanto ; procedure checkinput ( firstset, followset ) ; begin if not ( token in firstset ) then error ; scanto ( firstset ∪ followset ) ; end if ; end; 这里的$指的是输入的结尾( EOF)。 这些过程如下所示地用于exp和factor过程中(它们现在得到了一个 synchset参数): procedure exp ( synchset ) ; begin checkinput ( { (, number }, synchset ) ; if not ( token in synchset ) then term ( synchset ) ; while token = + or token = - do match (token) ; term ( synchset ) ; end while ; checkinput ( synchset, { (, number }) ; end if; end exp ; procedure factor ( synchset ) ; begin checkinput ({(, number}, synchset ) ; if not ( token in synchset ) then 140 编译原理及实践 case token of ( : match (() ; exp ( { ) } ) ; match()) ; number : match(number) ; else error ; end case ; checkinput ( synchset, { (, number }) ; end if ; end factor ; 请读者注意,checkinput在每个过程中都被调用了两次:一次是核实 First 集合的记号是输入中 的下一个记号,另一次是核实 Follow集合(或synchset)的记号是退出的下一个记号。 应急方式的这种格式将产生合理的错误(有用的出错信息也可作为参数被添加到 checkinput 和error 中)。例如,输入串(2+-3)*4-+5将产生两个出错信息(一个在第 1个减号 上,另一个在第 2个加号上)。 一般地,我们注意到在递归调用中向下传送 synchset,同时也添加相应的新同步记号。在 factor的情况中,当看到一个左括号之后会出现了一个例外:只有在作为它的 Follow集合时, exp才与右括号一起被调用(丢弃 synchset)。这是一种伴随应急方式错误校正的典型特殊分析 (这样做了以后,例如表达式 (2+*)将不在右括号上生成一个虚假的错误信息。)我们将该代码 行为的分析以及其在 C中的实现留在练习中。不幸的是,为了得到最佳的出错信息和错误校正, 在实际中必须检查每个记号测试以看看是否需要做更一般的或更早的测试,而这样可能会增加 错误行为。 4.5.2 在LL(1)分析程序中的错误校正 应急方式错误校正也可在 LL(1)分析程序中以与在递归下降分析中相似的方式实现。由于 该算法是非递归的,所以就要求用一个新栈来保存 synchset参数,而且在算法生成每个动作之 前(当一个非终结符位于栈顶之时),算法必须安排一个对 checkinput的调用 。请读者注意, 在出现最初错误时,在栈顶部有一个非终结符 A,且当前的输入记号不在 First(A)中(或若 在 First(A)时,则为First (A))。记号位于栈的顶部且与当前输入记号不同的情况是不常见的,这是 因为就一般而言,当在输入中真正地看到记号时,它们只会被压入到栈中(表压缩方法可能会 和它折衷一点)。我们将把程序清单 4-2中的分析算法的修改留在练习里。 若不用一个额外的栈,也可静态地将同步记号的集合与 checkinput所采取的相应动作一起 建立到 LL(1)分析表中。假设有一个位于栈顶部的非终结符 A和一个不在 First(A)(或若 在 First(A)时,则为First ( A))中的输入记号,那么就有3种其他方法: 1) 由栈弹出A。 2) 看到一个为了它可重新开始分析的记号之后,成功地从输入中弹出记号。 3) 在栈中压入一个新的非终结符。 同在递归下降代码中一样,位于匹配末尾的向 checkinput的调用也能由一个特殊的栈符号以类似于第 4.2.4节 中值计算安排的风格来安排。 141 第 4章 自顶向下的分析 若当前输入记号是 $或是在 Follow(A)中时,就选择方法 1。若当前输入记号不是 $或不在 First(A)∪Follow(A)中,就选择方法2。在特殊情况中方法3有时会有用,但却很少是恰当的(后 面将简要地讨论另一种方法)。第 1个动作是通过记号 pop在分析表中指出,第 2个动作是通过记 号scan指出(请注意, pop动作与 产生式的归约相等价)。 有了这些惯例之后,LL(1)分析表(表4-4)看起来应同表4-9一样。例如:串 (2+*),使用 该表的 LL(1)分析程序的行为显示在表 4-10中。在这个表中,分析只显示为由第 1个错误开始 (所以前缀 (2+早已被成功地匹配了)。这里还是使用缩写词 E代表exp,用E′代表exp′等等。请 注意,在分析成功地再继续之前两个相邻的错误发生了移动。我们可以通过适当的安排来抑制 第2个错误的出错信息在第 1个错误之后移动,这样分析程序就可在产生任何新的错误信息之前 成功地移动一次或多次了。因此,就可避免出错信息级联了。 表4-9 带有错误校正项的 LL(1)分析表(表 4-4) M [N, T ] exp exp′ ( e x p→ term exp′ scan number exp→ term exp′ scan ) pop exp′→ addop term term′ pop term→ factor term′ scan pop t e r m→ factor t e r m′ scan scan pop term′→ mulop factor pop factor→ ( exp ) pop scan f a c t o r→ pop number + scan exp′→ addop term exp′ a d d o p→ + pop term′→ scan exp′→ addop term exp′ a d d o p→ - pop term′→ scan scan pop pop * $ scan pop scan exp′→ scan pop scan term′ → mulop factor t e r m′ mulop→* pop pop term′→ pop pop 分析栈 $ E′ T′ ) E′ T $ E′ T′ ) E′ T $ E′ T′ ) E′ $ E′ T′ ) $ E′ T′ $ E′ $ 表4-10 LL(1)分析程序使用表 4-9的移动 输入 * )$ )$ )$ )$ $ $ $ 动作 扫描(错误) 弹出(错误) E′ → 匹配 T′ → E′ → 接受 4.5.3 在TINY分析程序中的错误校正 正如在附录B中给出的一样, TINY分析程序的错误处理是极为初步的:它只需实现一个格 142 编译原理及实践 式非常原始的应急方式恢复,而无需同步集合。 match过程仅声明错误,说出它发现的是哪个 不希望的记号。除此之外,过程 statement和factor在没发现正确选择时就声明错误。若在 分析结束时,发现的是一个记号而不是文件的结束,则 parse过程就声明错误。产生的主要出 错信息是“非期望的记号”,它对于用户没有用处。此外,分析程序也不试着避免错误级联。 例如,在 write语句之后添加了一个分号的样本程序 ... 5: read x ; 6: if 0 < x then 7: fact := 1; 8: repeat 9: fact := fact * x; 10: x := x - 1 11: until x = 0 ; 12: write fact ; {<- - BAD SEMICOLON ! } 13: end 14: 产生了以下的两个出错信息(当只有一个错误已发生时): Syntax error at line 13: unexpected token -> reserved word: end Syntax error at line 14: unexpected token -> EOF 当在代码的第2行中删去小于号“<”时,上面的程序就是: ... 5: read x ; 6: if 0 x then { <- - COMPARISON MISSING HERE! } 7: fact := 1; 8: repeat 9: fact := fact * x; 10: x := x - 1 11: until x = 0 ; 12: write fact ; 13: end 14: 这样就打印出了 4个出错信息: Syntax error at line 6 : unexpected token -> ID, name = x Syntax error at line 6 : unexpected token -> reserved word: then Syntax error at line 6 : unexpected token -> reserved word: then Syntax error at line 7 : unexpected token -> ID, name = fact 另一方面, TINY的某些行为是恰当的。例如,一个丢失掉的(不是额外的)分号将只生成一 个出错信息,而分析程序则就如同分号一直在那儿一样继续建立正确的语法树,由此就完成了 错误校正的一个基本形式。这个行为是由两种代码引起的。第 1个是match过程并不耗费记号, 它引起的行为与插入丢失的记号一样。第 2个是已将 stmt_sequence过程写出来,这样就在 一个错误的情况下联结了尽可能多的语法树的匹配。特别地,必须留意在每当找到一个非零指 针时都应与属指针相联结(将分析程序过程设计为若发现错误则返回一个零语法树指针)。另 外,基于 EBNF statement(); while (token==SEMI) { match(SEMI); 143 第 4章 自顶向下的分析 statement(); } 的stmt_sequence体的明显书写方式是用带有一个更为复杂的循环测试代替: statement() ; while ((token!= ENDFILE) && (token!= END) && (token!= ELSE) && (token!= UNTIL)) { match(SEMI); statement(); } 读者会注意到在这个反面的测试中的 4个记号包含了 stmt_sequence的Follow集合。这并不是出 了什么错,而是因为测试将或是寻找 First集合中的一个记号(同 statement和factor的过程相同) 或是寻找不在Follow集合中的一个记号。因为若丢失了一个 First符号,分析就会停止,所以后 者在错误校正中尤为有效。我们在练习中描述了一个程序的行为,读者可看到若在第 1个格式 中给出了stmt_sequence,则丢失的分号确实将致使跳过程序的剩余部分。 最后,我们还应注意到分析程序的编写方式:当它遇到错误时,它不能进入一个无穷循环 (当match不损失一个不期望的记号时,读者会担心到这一个问题)。这是因为,在分析过程的 任意路径中,最终都会遇到 statement或factor的缺省情况,而两者都会在产生错误信息时 消耗掉一个记号。 练习 4.1 编写与4.1.2节中exp的伪代码相对应的 term和factor的伪代码,以使其可构造出简单算 术表达式的语法树。 4.2 若有文法A→(A) A | ,请写出由递归下降分析该文法的伪代码。 4.3 若有文法 statement → assign-stmt | call-stmt | other assign-stmt → identifier := exp call-stmt → identifier ( exp-list ) 请写出由递归下降分析该文法的伪代码。 4.4 若有文法 lexp → number | ( op lexp -seq ) op → + | - | * lexp -seq → lexp -seq lexp | lexp 请写出伪代码以通过递归下降来计算一个 exp的数值(参见第3章的练习3.13)。 4.5 利用表4-4识别以下算术表达式的LL(1)分析程序,请写出它们的动作: a. 3+4*5-6 b. 3*(4-5+6) c. 3-(4+5*6) 4.6 写出利用4.2.2节中第1个表的LL(1)分析程序来识别以下成对括号的串: a. (())() b. (()()) c. ()(()) 4.7 若有文法A →(A)A | , a. 为非终结符A构造First集合和Follow集合。 b. 说明该文法是LL(1)的文法。 4.8 考虑文法 144 编译原理及实践 lexp → atom | list atom → number | identifier list → ( lexp -seq ) lexp -seq → lexp -seq lexp | lexp a. 消除左递归。 b. 为得出的文法的非终结符构造First集合和Follow集合。 c. 说明所得的文法是 LL(1)文法。 d. 为所得的文法构造LL(1)分析表。 e. 假设有输入串 (a (b (2)) (c)) 请写出相对应的LL(1)分析程序的动作。 4.9 考虑以下的文法(与练习 4.8类似但不同): lexp → atom | list atom → number | identifier list → ( lexp - seq ) lexp -seq → lexp , lexp -seq | lexp a. 在该文法中提取左因子。 b. 为所得的文法的非终结符构造First集合和Follow集合。 c. 说明所得的文法是 LL(1)文法。 d. 为所得的文法构造LL(1)分析表。 e. 假设有输入串 (a, (b, (2)), (c)) 写出相对应的 LL(1)分析程序的动作。 4.10 考虑简化了的C声明的以下文法: declaration → type var -list type → int | float var-list → identifier, var-list | identifier a. 在该文法中提取左因子。 b. 为所得的文法的非终结符构造 First集合和Follow集合。 c. 说明所得的文法是LL(1)文法。 d. 为所得的文法构造 LL(1)分析表。 e. 假设有输入串 int x,y,z 写出相对应的 LL(1)分析程序的动作。 4.11 诸如表4-4中的LL(1)分析表一般总有许多表示错误的空项。在许多情况下,一行中的 所有空项可由一单个的缺省项(default entry)代替,由此就可将这个表大大地缩小。 当非终结符有一个单独的产生式选择或当它有一个 产生式时,在非终结符行就有可 能会出现缺省项。将该想法应用于表4-4,如果可以应用,有可能会出现怎样的问题? 4.12 a. LL(1)文法会有二义性吗?为什么? b. 二义性文法会是LL(1)文法吗?为什么? 145 第 4章 自顶向下的分析 c. 非二义性的文法一定是LL(1)文法吗?为什么? 4.13 说明左递归文法不会是LL(1)文法。 4.14 证明4.3.3节中由其First集合和Follow集合的两个条件所得文法的定理。 4.15 将在记号串的两个集合 S 和S 上的算符 ⊕ 定义为: S ⊕ S = { First (xy) | x∈S , 1 2 1 2 1 y∈S } 2 a. 说明对于任何两个非终结符 A和B,都有First(AB) = First (A) ⊕ First(B)。 b. 说明4.3.3节中定理的两个条件可由某单一条件:若 A→α 且A→β,则(First (α) ⊕ Follow(A))∩(First(β) ⊕ Follow(A)) 为空来代替。 4.16 若由开始符号到A出现的记号串没有推导,则非终结符 A是无用的。 a. 为该特性给出一个数学形式。 b. 程序设计语言有无可能具有一个无用的符号?请解释原因。 c. 若文法具有一个无用的符号,说明计算同在本章中给出的一样的 First集合和 Follow集合会生成比用于构造真实的 LL(1)分析表长得多的产生集合。 4.17 请给出无需改变被识别的语言就可从一个文法中删除无用非终结符(及相结合的产 生式)的一个算法(参见前一个练习)。 4.18 若文法没有无用的非终结符,说明 4.3.3节中的定理的反命题也是正确的(参见练习 4.16)。 4.19 给出例4.15中First集合和Follow集合的详细计算。 4.20 a. 为例4.7提取了左因子的文法中的非终结符构造 First集合和Follow集合。 b. 利用(a)部分的答案构造LL(1)分析表。 4.21 若有文法A → a A a | ε, a. 说明该文法不是 LL(1)文法。 b. 以下伪代码试图写出该文法的递归下降分析程序 procedure A ; begin if token = a then getToken ; A; if token = a then getToken ; else error ; else if token < > $ then error ; end A ; 说明这个过程不能正确地运行。 c. 可以写出该语言的带回溯( backtracking)递归下降分析程序,但这样却要求使用 将一个记号看作参数并将该记号返回到输入记号流前面的 unGettoken过程。它还 要求将过程 A写作返回成功或失败的布尔函数,这样当 A调用其自身时,它可在耗 费另一个记号前先测试一下是否能成功;因此当 A→a A a 选择失败,代码还可继 续尝试A→ 。根据以上所述请重新写出 (b)部分的伪代码,并描绘其在串 aaaa$上 的运算。 4.22 在程序清单 4-8的TINY文法中,并没有将布尔表达式和算术表达式清楚地区分开。 例如,以下是一个合乎语法的 TINY程序: 146 编译原理及实践 if 0 then write 1>0 else x := (x<1)+1 end 请重新写出TINY文法以便只允许布尔表达式作为 if语句或repeat语句的测试,且只允 许算术表达式出现在write语句或assign语句中或作为任何算符的操作数。 4.23 将布尔算符 and、or和not添加到程序清单 4-8的TINY文法中。并给其赋予练习 3.5 中所描述的特性以及比所有算术算符都低的优先权。确保任何表达式都可以是布尔 表达式或整型表达式。 4.24 对练习4.23中TINY语法的改变使得练习 4.22所描述的问题变得更糟了。将练习 4.23 的答案重新改写以使布尔表达式和算术表达式能够严格地区分开来,并与练习 4.22 的解法相合并。 4.25 如4.5.1节所述:用作简单算术表达式文法的应急方式错误校正仍有一些缺点。其中 之一是为算符测试的 while循环应在特定的条件下继续运行。例如,表达式 (2)(3) 就在因子之间丢掉了算符,但是错误处理器在未重新开始分析之前就耗费了第 2个因 子。请读者重写伪代码以改进这样的行为。 4.26 利用表4-9中给出的错误恢复描述在输入 (2+-3)*4-+5上的LL(1)分析程序的动作。 4.27 重写程序清单 4-2中的LL(1)分析算法以使应急方式错误校正保持完整,并描绘其在 输入(2+-3)*4-+5中的行为。 4.28 a. 描绘在TINY分析程序中的 stmt_sequence过程的一个运算,它用于核实构造以 下TINY程序的语法树(除了丢失的分号之外 )的正确性: x := 2 y := x + 2 b. 可为以下(不正确的)程序构造怎样的语法树: x2 y := x + 2 c. 假设用编写stmt_sequence过程来代替本章最后一节中的更为简单的代码: statement(); while(token = =SEMI) { match(SEMI); statement(); } 利用这种版本的 stmt_sequence过程可为 a部分和b部分中的程序构造怎样的语法 树呢? 编程练习 4.29 将以下所述添加到程序清单 4-1中的简单整型算术递归下降计算器内(确保它们具有 正确的结合性和优先权): a. 带有符号/的整型除法。 b. 带有符号%的整型模。 c. 带有符号^的整型求幂(警告:该算符比乘法优先,且是右结合)。 d. 带有符号-的一目减(参见练习3.12)。 4.30 重写程序清单4-1的递归下降计算器,使其可与浮点数而不单是整型一起计算。 4.31 重写程序清单 4-1的递归下降计算器,使其可区分浮点数和整型值,而不仅仅是将所 有项都作为整型或浮点数来计算(提示:现在的“值”是指带有指示它是个整型还 147 第 4章 自顶向下的分析 是浮点的标志的记录)。 4.32 a. 重写程序清单4-1的递归下降计算器,使其根据 3.3.2节的声明返回一个语法树。 b. 写出一个作为参数的函数,它得到由 a部分的代码生成的语法树,并由移动树来 返回计算的值。 4.33 为与程序清单 4-1相似的整型算法写出一个递归下降计算器,但要使用图 4-1中的 文法。 4.34 考虑以下的文法: lexp → number | ( op lexp -seq ) op → + | - | * lexp -seq → lexp -seq lexp | lexp 该文法可被看成是表示在类似 LISP前缀格式中的简单整型算术表达式。例如,表达 式34-3*42在该文法中写作 (- 34 (* 3 42)。) 为该文法给出的表达式写出一个递归下降计算器。 4.35 a. 为前一个练习的文法设计一个语法树结构,并为返回语法树的它写出一个递归下 降分析程序。 b. 写出一个作为参数的函数,它得到由 a部分的代码生成的语法树,并由编号树来 返回计算的值。 4.36 a. 为练习3.4的正则表达式使用(被恰当地消除了二义性)的文法以构造一个读取正 则表达式并执行NFA的Thompson结构(参见第2章)的递归下降分析程序。 b. 编写一个过程,它将NFA数据结构看作是由a部分的分析程序生成的,且根据子集 结构构造等价的DFA。 c. 编写一个过程,它将数据结构看作是由 (b)部分的过程生成的,并根据它所代表的 DFA在文本文件中寻找最长子串来进行匹配。(你的程序现在变成了 grep的一个 “编译过的”版本了!) 4.37 将比较算符<=(小于或等于)、>(大于)、>=(大于或等于)以及< >(不等于)添 加到TINY分析程序中(还将要求添加这些记号以及改变扫描程序,但是不应要求语 法树有所改变)。 4.38 将在练习4.22中的文法变化合并到 TINY分析程序中。 4.39 将在练习4.23中的文法变化合并到 TINY分析程序中。 4.40 a. 将程序清单4-1中的递归下降计算器程序重新改写以完成如在第 4.5.1中描述的那样 的应急方式错误校正。 b. 给a部分中你的错误处理添加有用的出错信息。 4.41 TINY分析程序只能产生极少的出错信息,其中的一个原因是 match过程被限制于在 错误出现时只打印当前记号,而不是打印当前记号与所希望的记号,而且从其调用 点没有特殊的出错信息被传送到 match过程。将 match过程重新改写以使它在打印 当前记号的同时也打印出所希望的记号,并可将出错信息传给 syntaxError过程。 这将要求重写 syntaxError过程,以及改变match调用使其包括恰当的出错信息。 4.42 按4.5.1节中所述方法,给TINY分析程序添加记号的同步集合和应急方式错误校正。 4.43 一个可分析LL(1)文法规则的任何集合的“一般的”递归下降分析程序可使用基于语 法图的(来自Wirth [1976] 的)数据结构。下面的C声明给出了一个适当的数据结构: 148 编译原理及实践 typedef struct rulerec { struct rulerec *next, *other; int isToken; union { Token name ; struct rulerec *rule ; } attr ; } Rulerec ; next域被用来指出文法规则中的下一个项目,而 other域则被用来指出由 |元符号 给出的替换项。因此,用于文法规则 factor → ( exp ) | number 的数据结构看起来如下所示: 指向 exp的结构 其中记录结构的域如下: a. 为图4-1中的其余文法规则画出数据结构(提示:这将需要在数据结构内部代表 的一个特殊记号)。 b. 写出使用这些数据结构识别输入串的一个普通分析过程。 c. 写出(从一个文件中或输入中)读取 BNF规则并生成前述的数据结构的分析程序 生成器。 注意与参考 用于分析程序构造的递归下降分析自从 20世纪60年代以及Algo160报告[Naur, 1963]的BNF 规则的介绍中就已有了一个标准方法。若读者希望看到这一方法的较早描述,可参看 Hoare [1962]。回溯递归下降分析程序最近已在诸如 Haskell和Miranda的极为典型的懒惰函数语言中 变得流行起来,其中这种递归下降分析的形式被称作组合器分析。读者可在 Peyton Jones 和 Lester [1992]或Hutton [1992]中找到这种方法的描述。在 Wirth [1976]中,将EBNF连同递归下 降分析一起使用已十分普通了。 LL(1)分析在 20世纪的 60年代和 70年代早期已得到了广泛的研究。在 Lewis 和 Stearns 149 第 4章 自顶向下的分析 [1968]中可看到它的早期描述。在 Fischer 和 LeBlanc [1991]中可找到对LL(k)分析的调查,其中 还有一个不是 SLL(2)的LL(2)文法的示例。Parr、Dietz和Cohen [1992]中有关于LL(k)分析的特 殊应用。 当然,除了在本章所学到的两个之外,还有很多自顶向下的分析方法。 Graham、Harrison 和 Ruzzo [1980]中有其他的更为普通的方法。 Wirth [1976]和Stirling [1985]中有关于应急方式错误校正的研究。 LL(k)分析程序中的错误 校正可在Burke 和 Fisher [1987]中找到。读者还可在Fischer and LeBlanc [1991]和Lyon [1974]中 学到更复杂的错误修改方法。 本章并未讨论到用于产生自顶向下的分析程序的自动工具,这主要是由于第 5章将讨论最 常用的工具 —Yacc。但是,好的自顶向下的分析程序生成器还是存在的。Antlr就是其中之一, 它是Purdue Compiler Construction Tool Set(PCCTS)的一部分。读者可参看 Parr、Dietz和and Cohen [1992]中的相关内容。 Antlr从EBNF中生成了一个递归下降分析程序。它具有大量有用 的特征,这其中就包括了用于构造语法树的内部机制。读者可参看 Fischer 和 LeBlanc [1991]中 有关称作 LLGen的LL(1)分析程序生成器的大致内容。 第5章 自底向上的分析 本章要点 • 自底向上分析概览 • LR(0)项的有穷自动机及LR(0)分析 • SLR(1) 分析 • 一般的LR(1)和LALR(1)分析 • Yacc: LALR(1)分析程序的生成器 • 使用Yacc生成TINY分析程序 • 自底向上分析程序中的错误校正 前一章涉及到了递归下降和预测分析的自顶向下分析的基本算法。在本章中,我们将描述 主要的自底向上的分析技术及其相关的构造。如在自顶向下的分析一样,我们将主要学习利用 最多一个先行符号的分析算法,此外再谈一下如何扩展这个算法。 与 L L ( 1 )分析程序的术语相类似,最普通的自底向上算法称作 L R ( 1 )分析 ( L R ( 1 ) parsing)(L表示由左向右处理输入, R表示生成了最右推导,而数字 1则表示使用了先行的一 个符号 )。自底向上分析的作用还表现在它使得 LR(0)分析(LR(0) parsing)也具有了意义,此 时在作出分析决定时没有考虑先行 (因为可在先行记号出现在分析栈上之后再检查它,而且 倘若是这样发生的,它也就不会被算作先行了,所以这是可能的 )。SLR(1)分析 (SLR(1) parsing,可简写为 LR(1)分析 )是对 LR(1)分析的改进,它使用了一些先行。 LALR(1)分析 (LALR(1) parsing ,意即先行 LR(1)分析)是比SLR(1)分析略微强大且比一般的 LR(1)分析简 单的方法。 在本章节中将会谈到以上每一个分析方法的必要构造。这将包括 LR(0)的DFA构造以及 LR(1)各项: SLR(1)、LR(1)和LALR(1)分析算法的描述,以及相关分析表的构造。我们还将描 述Yacc(一个LALR(1)分析程序生成器)的用法,并为 TINY语言使用Yacc生成一个分析程序,该 TINY语言构造出与在第 5章中由递归下降分析程序开发的相同的语法树。 一般而言,自底向上的分析算法的功能比自顶向下的方法强大 (例如,左递归在自底向上 分析中就不成问题 )。但是这些算法所涉及到的构造却更为复杂。因此,需要小心对它们的描 述,我们还需要利用文法的十分简单的示例来介绍它们。本章的开头先给出了这样的两个例 子,本章还会一直用到这两个例子。此外还会用到一些前章中的例子 (整型算术表达式、 if语 句等等 )。但是由于为全 TINY语言用手工执行任何的自底向上的分析算法非常复杂,所以我 们并不打算完成它。实际上,所有重要的自底向上方法对于手工编码而言都太复杂了,但是 对于诸如 Yacc的分析程序生成器却很合适。但是了解方法的操作却很重要,这样作为编译程 序的编写者就可对分析程序生成器的行为进行正确地分析了。由于分析程序生成器可以用 BNF中建议的语言语法来识别可能的问题,所以程序设计语言的设计者还可从这个信息中获 益不少。 为了掌握好自底向上的分析算法,读者还需要了解前面讲过的内容,这其中包括了有穷自 动机、从一个 NFA中构造DFA的子集(第2章的2.3节和2.4节)、上下文无关文法的一般特性、推 导和分析树(第3章的3.2节和3.3节)。有时也需要用到 Follow集合(第4章的4.3节)。这一章从自底 向上的分析的概况开始谈起。 151 第 5章 自底向上的分析 5.1 自底向上分析概览 自底向上的分析程序使用了显式栈来完成分析,这与非递归的自顶向下的分析程序相类似。 分析栈包括记号和非终结符,以及一些后面将讨论到的其他信息。自底向上的分析开始时栈是 空的,在成功分析的末尾还包括了开始符号。因此,可将自底向上的分析示意为: $ ... ... $ StartSymbol InputString $ ... ... $ accept 其中,分析栈在左边,输入位于正中间,而分析程序的动作则在右边 (此时,“接受”是所指出 的唯一动作 )。 自底向上的分析程序有两种可能的动作 (除“接受”之外): 1) 将终结符从输入的开头移进到栈的顶部。 2) 假设有BNF选择A→α,将栈顶部的串α归约为非终结符A。 因此自底向上的分析程序有时称作是移进-归约分析程序 。移进动作是由书写单词shift指 出的。归约动作则由书写 reduce单词给出且指出在归约中所用的 BNF选择 。自底向上的分析 程序的另一个特征是:出于后面要讲到的技术原因,总是将文法与一个新的开始符号一同扩充 (augmented)。这就意味着若 S是开始符号,那么就将新的开始符号 S′ 增加到文法中,同时还添 加一个单元产生式到前面的开始符号中: S′ → S 下面是两个示例,之后再讨论这两个例子所表现出的自底向上分析的一些特性。 例5.1 考虑以下用于成对括号的扩充文法: S′ → S S→(S)S| 表5-1给出了使用该文法的串( )的自底向上的分析。 表5-1 例5.1中文法的自底向上分析程序的分析动作 分析栈 输入 1 $ 2 $( 3 $ (S 4 $(S) 5 $ (S) S 6 $S 7 $ S′ ()$ )$ )$ $ $ $ $ 动作 移进 用S→ε 归约 移进 用S→ 归约 用S→ (S) S归约 用S′→S归约 接受 由于相同的原因,自顶向下的分析程序可被称作生成-匹配分析程序,但这并未成为惯例。 在归约的情况中,可如同在自顶向下分析中为一个生成动作所做的一样,仅仅由 BNF选择自身写出即可,但 是惯例却是添加 reduce。 152 编译原理及实践 例5.2 考虑以下基本算术表达式的扩充文法 (没有括号,只有一种运算 ): E′→ E E→E+n|n 表5-2是串n + n 使用这个文法的自底向上的分析。 表5-2 例5.2中文法的自底向上分析程序的分析动作 分析栈 输入 1 $ n+n$ 2 $n +n$ 3 $E +n$ 4 $E+ n$ 5 $ E+n $ 6 $E $ 7 $ E′ $ 动作 移进 用E→n 归约 移进 移进 用E→E + n 归约 用E′→ E归约 接受 在使用先行上,自底向上的分析程序比自顶向下的分析程序的困难要小。实际上,自底向 上的分析程序可将输入符号移进到栈里直到它判断出要执行的是何种动作为止 (假设判断一个 动作可以不要求将符号移进到输入中 )。但是为了判断要完成的动作,自底向上的分析程序会 需要在栈内看得更深,而不仅仅是顶部。例如,在表 5-1中的第5行,S位于栈的顶部,且分析 程序用产生式S→ (S) S归约,而第 6行在栈的顶部也有 S,但是分析程序却用 S′→S归约。为了 能够了解S→(S) S在第5步中是一个有效的归约,我们必须知道实际上栈在该点包含了串 (S) S。 因此,自底向上的分析要求任意的“栈先行”。由于分析程序本身建立了栈且可使恰当的信息 成为可用的,所以这几乎同输入先行一样重要。此时所用的机制是“项”的确定性的有穷自动 机,下一节将会讲到它。 当然,仅仅了解栈的内容并不足以可唯一地判断出移进-归约分析的下一步,还需将输入 中的记号作为一个先行来考虑。例如在表 5-2的第3行,E在栈中,且发生了一个移进;但在第 6 行中,E又在栈中,但却用了 E′→E 归约。两者的区别在于:在第 3行中,输入的下一个记号是 “+”;但在第6行中,下一个输入记号却是 $。因此,任何执行那个分析的算法必须使用下一个 输入记号 (先行)来判断出适当的动作。不同的移进-归约分析方法以不同的方式使用先行,而 这将导致具有不同能力和复杂性的分析程序。在看到单个算法之前,我们首先做一些普通的观 察来看看自底向上分析的直接步骤是如何表现特征的。 首先,我们再次注意到移进-归约分析程序描绘出输入串的最右推导,但推导步骤的顺序 却是颠倒的。在表 5-1中,共有 4个归约,它们与最右推导的 4个步骤对应的顺序相反: S′ ⇒ S ⇒ (S) ⇒ S (S) ⇒ ( ) 在表5-2中,相对应的推导是 E′ ⇒ E ⇒ E + n ⇒ n + n 我们将这样的推导中的终结符和非终结符的每个中间串都称作右句型 (right sentential form)。在移进-归约分析中,每个这样的句型都被分析栈和输入分隔开。例如,发生在前面推 导中的第3步的右句子格式 E + n 出现在表5-2的第3、第4和第5步。如果通过符号 || 指出每一时 刻栈的顶部位于何处 (即:当在栈和输入之间发生了分隔 ),则表5-2的第3步就由E || + n给出, 而其第4步则由E + || n给出。在每一种情况下,分析栈的符号序列都被称作右句型的可行前缀 153 第 5章 自底向上的分析 (viable prefix)。因此,E、E+ 和E + n 都是右句型E + n 的可行前缀,但右句子格式n + n 却使 和n 作为它的可行前缀 (表5-2的第1步和第2步)。请注意,n + 不是n + n 的可行前缀。 移进-归约分析程序将终结符从输入移进到栈直到它能执行一个归约以得到下一个右句子 格式。它发生在位于栈顶部的符号串匹配用于下一个归约的产生式的右边。这个串、它在右句 子格式中发生的位置以及用来归约它的产生式被称作右句型的句柄 (handle) 。例如,在右句子 格式n + n 中,它的句柄是由最左边的单个记号 n 与用来归约它以产生新的右句型 E + n的产生 式E→n 组成的串。这个新句型的句柄是整个串 E + n (一个可行的前缀 )以及产生式E→E + n。 有时由于表示法上的弊端,我们要用串本身来作为句柄。 判断分析中的下一个句柄是移进-归约分析程序的主要任务。请注意,句柄串总是为它的 产生式(在下一步的归约中使用的产生式 )构成一个完整的右部,而且当归约发生时,句柄串的 最右边的位置将与栈的顶部相对应。所以对于移进-归约分析程序将要基于产生式右边的位置 来判断它的动作这一点而言,就看起来有些似是而非了。当这些位置到达产生式的右边末端时, 这个产生式就有可能是一个归约,而且句柄还有可能位于栈的顶部。但为了成为句柄,串位于 栈的顶部来匹配产生式的右边并不够。实际上,若 产生式可用于归约的话(如在例 5.1中), 那么它的右边 (空串)总是位于栈的顶部。归约仅发生在结果串实际为一个右句型时。例如,在 表5-1的第3步中,可完成用 S→ 归约,但是得到的串 (S S)并不是右句子格式,所以 在句子 格式(S)中的这个位置也不是句柄。 5.2 LR(0)项的有穷自动机与 LR(0)分析 5.2.1 LR(0)项 上下文无关文法的 LR(0)项(LR(0) item)(或简写为项 (item))是在其右边带有区分位置的产 生式选择。我们可用一个句点 (当然它就变成了元符号,而不会与真正的记号相混淆 )来指出 这个区分的位置。所以若 A→α是产生式选择,且若 β和γ 是符号的任何两个串(包括空串 ), 且存在着 βγ =α,那么 A→β.γ 就是LR(0)项。之所以称作 LR(0)项是由于它们不包括先行的显 式引用。 例5.3 考虑例5.1的文法: S′→S S→( S ) S | 这个文法存在着3个产生式选择和8个项目: S′→.S S′→S. S→.( S ) S S→(.S ) S S→( S.)S S→( S ).S S→( S )S. 如果文法有二义性,那么由此就会存在多于一个的推导,则在右句型中就会有多于一个的句柄。如果文法没 有二义性,则句柄就是唯一的。 154 编译原理及实践 S→. 例5.4 例5.2的文法存在着以下的8个项目: E′→.E E′→E. E→.E + n E→E. + n E→E + .n E→E + n. E→.n E→n. 项目概念的思想就是指项目记录了特定文法规则右边识别中的中间步骤。特别地,项目 A →β.γ 是由文法规则选择 A→α构成(其中α=βγ),这一点意味着早已看到了β,且可能从下一个 输入记号中获取 γ。从分析栈的观点来看,这就意味着 β必须出现在栈的顶部。项目 A→.α意味 着将要利用文法规则选择 A→α识别A(将这样的项目称作初始项(initial item)。项目A→α.意味着 α现在位于分析栈的顶部,而且若 A→α在下一个归约中使用的话,它有可能就是句柄(将这样 的项目称作完整项(complete item))。 5.2.2 项目的有穷自动机 L R ( 0 )项可作为一个保持有关分析栈和移进-归约分析过程的信息的有穷自动机的状态来 使用。这将从作为非确定性的有穷自动机开始。从这个 LR(0)项的NFA中可利用第2章的子集构 造来构建出 LR(0)项集合的DFA。正如将要看到的一样,直接构造 LR(0)项集合的DFA也是很简 单的。 LR(0)项的NFA的转换是什么呢?若有项目 A→α.γ,且假设 γ 以符号X开始,其中 X可以是 记号或非终结符,所以该项目就可写作 A→αX.η。那么在符号 X上就有一个从由该项目代表的 状态到由项目A→αX.η代表的状态的转换。把它写在一般格式中,就是: 若X是一个记号,那么该转换与 X的一个在分析中从输入到栈顶部的移进相对应。另一方面, 若X是一个非终结符,因为 X永远不会作为输入符号出现,所以该转换的解释就复杂了。实际 上,这样的转换仍与在分析时将 X压入到栈中相对应,但是它只发生在由产生式 X→β形成的归 约时。由于这样的归约前面必须有一个 β的识别,而且由初始项 X→.β给出的状态代表了这个处 理的开始(句点指出将要识别一个 β),则对于每个项目 A→α.Xη,必须为X的每个产生式 X→β添 加一个 产生式, 它指示通过识别它的产生式的右边的任意匹配来产生 X。 这两种情况只表示 LR(0)项的NFA中的转换,它并未讨论到 NFA的初始状态和接受状态。 155 第 5章 自底向上的分析 NFA的初始状态应与分析程序的初始状态相对应:栈是空的,而且将要识别一个 S,其中S 是 文法的开始符号。因此,任何由 S的产生式选择构造的初始项 S→.α都可作为开始状态。不幸的 是, S可能有许多这样的产生式,如何知道该选择哪一个呢?实际上,我们是无法做到这一点 的。其解决办法是通过产生式 S′→S 扩充(augment)文法,其中 S′是一个新的非终结符。接着, S′ 成为扩充文法(augmented grammar)的开始状态,且初始项S′→.S 也成为NFA的开始状态。这 就是为什么扩充前面例子文法的原因。 在这个 NFA中,哪些状态将成为接受状态呢?读者此时必须记住 NFA的任务是用于了解 分析的状态,而不是如第 2章中自动机那样被设计为完全识别串。因此,分析程序本身将决定 何时接受,而 NFA则无需包含这一信息,所以 NFA实际上根本就没有接受状态 (NFA存在着有 关接受的一些信息,但并不是在接受状态中。在描述使用自动机制分析算法时还会讨论到这 一点)。 这样一来,LR(0)项的NFA的描述就完整了。现在来讨论前面两个例子的简单文法以及构 造它们的 LR(0)项的 NFA。 例5.5 5.3列出了例5.1的文法的8个LR(0)项,所以这个NFA就有8个状态,如图5-1所示。请注 意,图中在非终结符S之前带有一个句点的每个项目都有一个到 S的每个初始项的 - 转换。 图5-1 例5.5中文法的LR(0)项的NFA 例5.6 在例5.4中,我们列出了与例 5.2中的文法相关的 LR(0)项。图5-2是这些项目的NFA。请 注意,初始项 E→.E + n 有一个到它自身的 - 转换(这种情况会出现在带有直接左递归的所有 文法中 )。 为了完成项目用于了解分析状态的描述,我们必须根据第 2章的子集构造来构建与项目的 NFA相对应的项目集合的 DFA,这样之后就可说明 LR(0)分析算法了。我们将执行前面刚给出 的NFA的两个例子的子集构造。 例5.7 考虑图5-1的NFA。相关的DFA的开始状态是由项目S′→.S组成的集合的 -闭包,而这又 是3个项目的集合{ S′→.S, S→. ( S ) S, S →. }。由于S有一个从S′→ .S到S′→ S.的转换,所以也 就存在着一个从开始状态到DFA状态{ S′→ S. } 的对应转换(不存在从S′→ S.到任何其他项目的 156 编译原理及实践 转换)。在(上也有一个从开始状态到 DFA状态的转换{ S→ (. S ) S, S→.( S ) S, S→. }({ S→ (. S ) S }的 闭包)。DFA状态{ S→ (. S ) S, S→ . ( S ) S, S→ . }在(上有到其自身的转换,在 S上 也有到{ S→ ( S.) S }的转换。这个状态在(上有到状态{ S→ ( S ). S,S→ .( S ) S, S → . }的 转换。最后,这一最终状态在 (上有到前面所构造的状态 { S→ (. S ) S, S→ .( S ) S, S → . }的 转换。图5-3是完整的DFA,为了便于引用还给各个状态编了号 (按照惯例,状态0是开始状态)。 图5-2 例5.6中文法的LR(0)项的NFA 图5-3 与图5-1的NFA相对应的项目集合的DFA 例5.8 考虑图5-2的NFA。相关DFA的开始状态由3个项目的集合{E′→.E, E→.E + n, E →.n} 组成。在E上有一个从项目 E′→.E到项目E′→ E.的转换,但在E上还有一个从项目 E→.E + n 到 项目E→E. + n的转换。因此,在 E上就有一个从DFA的开始状态到集合 { E′→ E., E→E. + n} 的闭包的转换。由于没有任何一个来自这些项目的 转换,所以这个集合就是它自身的 闭包, 且构成了一个完整的 DFA状态。来自开始状态还有另一个转换,它与符号 n 上的从 E→.n到E → n.的转换相对应。因为没有来自项目 E→n.的 转换,所以这个项目是它自身的 闭包,且 构成了一个完整的 DFA状态{ E→ n. }。由于没有来自这个状态的转换,所以被计算的唯一转 换仍来自于集合 {E′→ E., E→E. + n}。从该集合开始只有一个转换,它与符号 +上的从项目E →E. + n 到项目E→E +.n 的转换相对应。项目 E→E +.n 也没有 转换,所以就在 DFA中构造 了一个单独的集合。最后, n 上有一个从集合 { E→E +.n}到集合{ E →E + n. }的转换。图 5-4 中是整个DFA。 157 第 5章 自底向上的分析 图5-4 与图5-2中的NFA相对应的项目集合的 DFA 在LR(0)项集合的DFA中,有时需要区分在 闭包步骤中添加到状态中的项目与引起状态作 为非 - 转换的目标的那些项目。前者被称作闭包项 (closure item),后者被称作核心项 (kernel item)。在图5-4的状态0中,E′→.E是(唯一的)核心项,而E→.E + n 和E→.n 则是闭包项。在图 5-3的状态2中,S→ (. S ) S是核心项,而S→. ( S ) S和S→. 是闭包项。根据项目的NFA的 转 换的定义,所有的闭包项都是初始项。 区分核心项与闭包项的重要性在于:若有一个文法,核心项唯一地判断出状态以及它的转 换,那么只需指出核心项就可以完整地表示出项目集合的 DFA来。因此,构造DFA的分析程序 生成器只报告核心项(例如对于Yacc而言,这也是正确的 )。 若项目集合的 DFA是直接运算的,这样就比首先计算项目的 NFA之后再应用子集构造要更 简化一些。从项目的集合确实可很容易地立即判断出什么是 -转换以及初始项指向哪里。因此, 如Yacc的分析程序生成器总是从文法直接计算 DFA,本章后面的内容也是这样做的。 5.2.3 LR(0)分析算法 现在开始讲述 LR(0)分析算法。由于该算法取决于要了解项目集合的 DFA的当前状态,所 以须修改分析栈以使不但能存储符号而且还能存储状态数。这是通过在压入一个符号之后再将 新的状态数压入到分析栈中完成的。实际上,状态本身就包含了有关符号的所有信息,所以可 完全将符号省掉而只在分析栈中保存状态数。但是为了方便和清晰起见,我们仍将在栈中保留 了符号。 为了能开始分析,我们将底标记 $和开始状态 0压入到栈中,所以分析在开始时的状况表 示为: 分析栈 $0 输入 InputString $ 现在假设下一步是将记号 n 移进到栈中并进入到状态 2(当DFA如在图5-4中所示一样,且 n 是输入中的下一个记号时,结果就是这样的 )。表示如下: 分析栈 $0n2 输入 InputString $ 的剩余部分 158 编译原理及实践 LR(0)分析算法根据当前的 DFA状态选择一个动作,这个状态总是出现在栈的顶部。 定义:LR(0)分析算法 (LR(0) parsing algorithm)。令s 为当前的状态 (位于分析栈的顶 部)。则动作定义如下: 1. 若状态s 包含了格式A→α.Xβ的任何项目,其中 X是一个终结符,则动作就是将 当前的输入记号移进到栈中。若这个记号是 X,且状态 s 包含了项目 A→α.Xβ, 则压入到栈中的新状态就是包含了项目 A→αX.β的状态。若由于位于刚才所描 述的格式的状态 s 中的某个项目,这个记号不是 X,则声明一个错误。 2. 若状态s 包含了任何完整的项目(格式A→α. 的一个项目),则动作是用规则A→α 归约。假设输入为空,用规则 S′→S归约(其中S是开始状态 )与接受相等价;若输 入不为空,则出现错误。在所有的其他情况中,新状态的计算如下:将串 α及它 的所有对应状态从分析栈中删去(根据DFA的构造方式,串α必须位于栈的顶部)。 相应地,在DFA中返回到由α开始构造的状态中(这须是由 α的删除所揭示的状 态)。由于构造DFA,这个状态就还须包含格式B→α.Aβ的一个项目。将A压入到 栈中,并压入包含了项目 B→αA.β的状态(作为新状态)。(请注意,由于正将 A 压入到栈中,所以这与在DFA中跟随A的转换相对应(这实际上是合理的)。 若以上的规则都是无歧义的,则文法就是 LR(0)文法(LR(0) grammar)。这就意味着若一个 状态包含了完整项目 A→α.,那么它就不能再包含其他项目了。实际上,若这样的状态还包含 了一个“移进的”项目 A→α.Xβ(X是一个终结符 ),就会出现一个到底是执行动作 (1)还是执 行动作 (2)的二义性。这种情况称作移进-归约冲突 (shift-reduce conflict)。类似地,如果这样 的状态包含了另一个完整项目 B→β.,那么也会出现一个关于为该归约使用哪个产生式 (A→α 或B→β)二义性。这种情况称作归约-归约冲突 (reduce-reduce conflict)。所以,当仅当每个 状态都是移进状态 (仅包含了“移进”项目的状态 )或包含了单个完整项目的归约时,该文法才 是LR(0)。 我们注意到上面例子中所用到的两个文法都不是 LR(0)文法。在图 5-3中,状态0、状态2和 状态4都包括了对于 LR(0)分析算法的移进-归约冲突;在图 5-4的DFA中,状态 1包含了一个移 进-归约冲突。由于几乎所有“真正的”文法都不是 LR(0),所以这并不奇怪。但下面将会给出 一个文法是 LR(0)的示例。 例5.9 考虑文法 A→ ( A ) | a 扩充的文法具有如图 5-5所示的项目集合的 DFA,而这就是 LR(0)。为了看清 LR(0)分析算法 是如何工作的,可考虑一下串 ((a))。该串的分析是根据表 5-3各步骤所给出的 LR(0)分析算 法进行。分析开始于状态 0,因为这个状态是一个移进状态,所以将第 1个记号 (移进到栈中。 接着,由于 DFA指出了在符号 (上从状态 0到状态 3的转换,所以将状态 3压入到栈中。状态 3 也是一个移进状态,所以下一个 (也被移进到栈中,而且在 (上的转换返回到状态 3。移进再 一次将 a放入到栈中,而且 a上的转换由状态 3进入到状态 2。现在位于表 5-3的第 4步,而且 已到达了第 1个归约状态。这里的状态 2和符号 a都是由栈弹出的,并回到处理中的状态 3。接 着,将 A压入到栈中,且得到由状态 3到状态 4的A转换。状态 4是一个移进状态,所以 )被移 进到栈中,且 )上的转换使分析转到状态 5。这里发生了一个由规则 A→ ( A ) 进行的归约, 它从栈中弹出状态 5、状态 4、状态 3以及符号 )、A和)。现在的分析位于状态 3中,而且 A和 159 第 5章 自底向上的分析 状态 4又被压入到栈中。 )再一次被移进到栈中,且压入状态 5。由A→ ( A ) 进行的另一个 归约从栈中 (向后地 )删除了串 ( 3 A 4 ) 5,而将分析留在状态 0中。现在 A已被压入且也得到 了由状态 0到状态 1的A转换。状态 1是接受状态。由于输入现在是空的,则分析算法承认它。 图5-5 例5.9的项目集合的 DFA 表5-3 例5.9的分析动作 分析栈 输入 1 $0 ( (a) ) $ 2 $0 (3 (a) ) $ 3 $0 ( 3(3 a) ) $ 4 $0 (3(3a2 ))$ 5 $0 ( 3 ( 3 A 4 ) )$ 6 $0 (3(3A4)5 )$ 7 $0 (3A4 )$ 8 $0 (3 A3 ) 5 $ 9 $0 A 1 $ 动作 移进 移进 移进 用A→ a归约 移进 用A→ ( A ) 归约 移进 用A→ ( A ) 归约 接受 也可将项目集合的 DFA以及由 LR(0)分析算法指定的动作合并到分析表中,所以 LR(0) 分析就变成一个表驱动的分析方法了。其典型结构是:表的每行用 DFA的状态标出,而每 一个列也如下标出。由于 LR(0)分析状态是“移进的”状态或“归约的”状态,所以专门利 用一列来为每个状态指出它。若是“归约的”状态,就用另一个列来指出在归约中所使用 的文法规则选择。若是移进的状态,将被移进的符号会判断出下一个状态 (通过 DFA),所 以每个记号都会有一个列,而这些记号的各项都是有关该记号移进后将移到的新状态。由 于不能在输入中真正地看到它们,所以尽管分析程序的行为好像是它们被移进了,非终结 符上的转换 (在归约时被压入 )仍代表着一个特殊情况。因此,在“移进的”状态中还需要 为每个非终结符有一个列,而且按照惯例,这些列被列入到称为 goto部分的表的一个单独 部分中。 表5-4就是这样的分析表的一个例子,它是例 5.9中文法的表格。读者可以证实这个表将引 出如在表 5-3中给出的示例的分析动作。 160 编译原理及实践 表5-4 例5.9中的文法的分析表 状态 动作 规则 输入 ( a ) 0 移进 3 2 1 归约 A′→ A 2 归约 A→ a 3 移进 3 2 4 移进 5 5 归约 A→ ( A ) Goto A 1 4 在这样的分析表中的空白项表示的是错误。当必须要进行错误恢复时,则需要准确地指出 分析程序要为每个这些空白项采取什么动作。后面一节将讨论这个问题。 5.3 SLR(1)分析 5.3.1 SLR(1)分析算法 简单LR(1)分析,或 SLR(1)分析,也如上一节中一样使用了 LR(0)项目集合的DFA。但是, 通过使用输入串中下一个记号来指导它的动作,它大大地提高了 LR(0)分析的能力。它通过两 种方法做到这一点。首先,它在一个移进之前先考虑输入记号以确保存在着一个恰当的 DFA。 其次,它使用如 4.3节所构造的非终结符的 Follow集合来决定是否应执行一个归约。令人吃惊 的是,先行的这个简单应用的能力强大得足以分析几乎所有的一般的语言构造。 定义:SLR(1)分析算法 (SLR(1) parsing algorithm)。令s 为当前状态 (位于分析栈的顶 部)。则动作可定义如下: 1. 若状态s 包含了格式A→α.Xβ的任意项目,其中 X是一个终结符,且 X是输入串 中的下一个记号,则动作将当前的输入记号移进到栈中,且被压入到栈中的新 状态是包含了项目 A→αX.β的状态。 2. 若状态s 包含了完整项目 A→γ.,则输入串中的下一个记号是在 Follow(A)中,所 以动作是用规则 A→γ 归约。用规则S′→S归约与接受等价,其中 S是开始状态; 只有当下一个输入记号是 $时,这才会发生 。在所有的其他情况中,新状态都 是如下计算的:删除串 α和所有它的来自分析栈中的对应状态。相对应地, DFA回到α开始构造的状态。通过构造,这个状态必须包括格式 B→γ. Aβ的一 个项目。将A压入到栈中,并将包含了项目 B→αA.β 的状态压入。 3. 若下一个输入记号都不是上面两种情况所提到的,则声明一个错误。 若上述的SLR(1)分析规则并不导致二义性,则文法为 SLR(1)文法(SLR(1) grammar)。特别 地,当且仅当对于任何状态 s,以下的两个条件: 1) 对于在s 中的任何项目A→α.Xβ,当X是一个终结符,且 X在Follow ( B) 中时,s 中没有完 整的项目 B→γ.。 2) 对于在s 中的任何两个完整项目A→α.和B→β.,Follow(A) Follow(B)为空。 实际上,任何文法扩充的开始状态 S′ 的Follow集合总是只由$组成,这是因为 S′ 只出现在文法规则 S′ →S中。 161 第 5章 自底向上的分析 均满足时,文法为SLR(1)。 若第1个条件不满足,就表示这是一个移进-归约冲突 (shift-reduce conflict)。若第2个条件 不满足,就表示这是一个归约-归约冲突 (reduce-reduce conflict)。 这两个条件同前一章中所述的 LL(1)分析的两个条件在本质上是类似的。但是如同使用所 有的移进-归约分析方法一样,可将决定使用哪个文法规则推迟到最后,同时还可考虑一个更 强大的分析程序。 SLR(1)分析的分析表也可以用与前一节所述的 LR(0)分析的分析表的类似方式构造。两者 的差别如下:由于状态在 SLR(1)分析程序中可以具有移进和归约 (取决于先行 ),输入部分中的 每项现在必须要有一个“移进”或“归约”的标签,而且文法规则选择也必须被放在标有“归 约”的项中。这还使得动作和规则列成为多余。由于输入结束符号 $也可成为一个合法的先行, 所以必须为这个符号在输入部分建立一个新的列。我们将 SLR(1)分析表的构造放在 SLR(1)分析 的第1个示例中。 例5.10 考虑例5.8中的文法,它的项目集合的 DFA已列在了图 5-4中。正如前面所述的,这个 文法不是LR(0),而是SLR(1)。非终结符的Follow集合是Follow(E′) = {$}和Follow (E) = {$, +}。 表5-5是SLR(1)分析表。在表中,移进由表项中的字母 s 指出,归约由字母r 指出。因此,在输 入+的状态 1中,指出了一个移进,以及一个到状态 3的转换。另一方面,在输入 +的状态2中, 指出了利用产生式 E→n 归约。在输入$的状态1中还用动作“接受”代替了 r ( E→E )。 表5-5 例5.10的SLR(1)分析表 状态 n 0 s2 1 2 3 s4 4 输入 + s3 r (E→n) r ( E→E + n) $ 接受 r (E→n) r (E→E + n) Goto E 1 这个示例的最后是串 n + n + n 的分析。表5-6是它的分析步骤。该图的步骤 1以输入记号n 的状态 0开始,接着分析表指出动作“ s2”,即:将记号移进到栈中并进入到状态 2。在表5-6中, 将它与阶段“shift 2”一起指出来。在该图的步骤 2中,分析程序是在状态 2中且带有输入记号 +,表还指出了用规则 E→n 归约。此时,从栈中弹出状态 2和记号n。使状态0曝露出来。将符 号E压入且将E的Goto从状态0带到状态1。第3步中的分析程序是带有输入记号 +的状态1,且表 还指出了移进以及指向状态 3的转换。在输入 n 的状态3中,表也指出了一个移进和到状态 4的 转换。在输入 +的状态4中,表指出用规则 E→E + n归约。这个归约是由将串 E + n和与它相结 合的来自栈的状态弹出来完成的,并再一次暴露状态 0,将E压入并将 Goto带到状态1中。分析 的其他步骤是类似的。 表5-6 例5.10的分析动作 分析栈 1 $0 输入 n+n+n$ 动作 移进2 162 编译原理及实践 分析栈 2 $0 n2 3 $0 E 1 4 $0 E1 + 3 5 $0 E1 + 3n4 6 $0 E 1 7 $0 E 1 + 3 8 $0 E 1 + 3n 4 9 $0 E 1 输入 +n+n$ +n+n$ n+n$ +n$ +n$ n$ $ $ (续) 动作 用E→n归约 移进3 移进4 用E→E + n 归约 移进3 移进4 用E→E + n 归约 接受 例5.11 考虑成对括号的文法,图 5-3已给出了它的 LR(0)项的DFA。一个直接的运算生成了 Follow (S′) = {$}和Follow (S) = {$,)}。表5-7给出了它的SLR(1)分析表。请读者注意,非LR(0) 状态0、2和4是如何通过 - 产生式S→ 具有移进和归约动作的。表 5-8给出了SLR(1)分析算法 用来分析串( ) ( )的步骤。请注意,栈如何继续扩展到最终的归约。这是自底向上的分析程序 在诸如S→ ( S ) S的右递归规则中的一个特征。因此,右递归可引起栈的溢出,所以若可能的 话应尽量避免。 表5-7 例5.11的SLR(1)分析表 状态 ( 0 s2 1 2 s2 3 4 s2 5 输入 ) r (S→ ) r (S→ ) s4 r (S→ ) r (S→ ( S ) S) $ r (S→ ) 接受 r (S→ ) r (S→ ) r (S→ ( S ) S) Goto S 1 3 5 表5-8 例5.11的分析动作 分析栈 输入 1 $0 2 $0 (2 3 $0 (2S3 4 $0 (2S3)4 5 $0 (2S3)4(2 6 $0 (2S3)4(2S3 7 $0 (2S3)4(2S3)4 8 $0 (2S3)4(2S3)4S5 9 $0 (2S3)4S5 10 $0 S1 ()()$ )()$ ()$ ()$ )$ )$ $ $ $ $ 动作 移进2 用S→ 归约 移进4 移进2 用S→ 归约 移进4 用S→ 归约 用S→ ( S ) S归约 用S→ ( S ) S归约 接受 163 第 5章 自底向上的分析 5.3.2 用于分析冲突的消除二义性规则 SLR(1)分析中以及所有的移进-归约分析方法中的分析冲突都可分为两类:移进-归约冲 突和归约-归约冲突。在移进-归约冲突中,有一个自然的消除二义性规则,它总是选取移进 而不是归约,因此大多数的移进-归约分析程序通过选择移进来取代归约,也就自动地解决了 移进-归约冲突。但是归约-归约冲突就要复杂一些了:这样的冲突通常 (但并不是总是 )指出文 法设计中的一个错误 (后面将给出这样冲突的示例 )。在移进-归约冲突中选取移进取代归约自 动地合并了用于在if语句中的悬挂else二义性的最近嵌套规则,例 5.12就是这样的一个例子。这 是为什么在程序设计语言的文法中仍保留有二义性的一个原因。 例5.12 考虑在前面章节中所用到的简化了的 if语句的文法(例如可参见第3章的3.4.3节): statement → if-stmt | other if-stmt → if ( exp ) statement | if ( exp ) statement else statement exp → 0 | 1 由于这是一个有二义性的文法,所以在任何的分析算法中都会出现分析冲突。为了在 SLR(1)分 析程序中看到这一点,我们将文法再简化一些使得项目集合的 DFA的构造更易处理。甚至还可 将测试表达式全部省略,那么文法就如下所示 (它仍包含了悬挂else二义性): S → I | other I → if S | if S else S 图5-6是项目集合的DFA。构造SLR(1)分析动作需要S 和I 的Follow集合,它们是 图5-6 例5.12中LR(0)项目集合的DFA 164 编译原理及实践 Follow (S) = Follow (I ) = {$, else } 现在就可以看到由悬挂 else问题引起的分析冲突了。当发生在 DFA的状态5中时,其中的完整项 目I → if S.指出规则I → if S的归约将发生在输入else和$中,但项目I → if S. else S 却指 出输入记号的一个移进将发生在 else上。因此悬挂else将导致在SLR(1)分析表的移进-归约冲 突。很明显,用移进取代归约的消除二义性的规则可以消除这个冲突,并会根据最近嵌套规则 作出分析 (若用归约取代移进,就没有办法在 DFA中输入状态 6或状态7,这将导致虚假的分析 错误)。 表5-9是由该文法引出的 SLR(1)分析表。在该表中为归约动作中的文法规则选择使用了编 号,用它来代替写出规则本身。编号如下: (1) S → I (2) S → other (3) I → if S (4) I → if S else S 请注意,无需为扩充产生式 S′→S编号,这是由于用该规则实现的归约与接受相对应,且在表 中已被写作“接受”了。 读者应注意到在归约项中使用的产生式编号容易引起与在移进和 Goto项中所用到的编号混 淆。例如,在表 5-9的表的状态 5中,输入else下的项目是 s6,它指出一个移进以及到状态 6的 转换,但在输入 $下的项目却是r3,它指出用产生式编号3实现的归约(即:I→if S)。 表5-9还为了移进而删除了移进-归约冲突。我们将表中的项目渐渐减少以显示出在何处发 生了冲突。 表5-9 例5.12的SLR(1)分析表(删除了分析冲突 ) 状态 if 0 s4 1 2 3 4 s4 5 s6 6 s4 7 输入 Else other s3 r1 r2 s3 r3 s3 r4 $ 接受 r1 r2 r4 Goto S I 1 2 5 2 7 2 5.3.3 SLR(1)分析能力的局限性 SLR(1)分析是LR(0)分析的一个简单但有效的扩展,而 LR(0)分析的能力足以处理几乎所有 实际的语言结构。不幸的是,在有些情况下, SLR(1)分析能力并不太强,而正由于这个原因, 我们还需要学习更强大的一般的 LR(1)和LALR(1)分析。下一个例子是 SLR(1)分析失败的典型 情况。 例5.13 考虑语句的以下文法,它是从 Pascal 中抽取和简化而得来的(在 C中也有类似的情况 165 第 5章 自底向上的分析 发生): stmt → call-stmt | assign-stmt call-stmt → identifier assign-stmt →var := exp var → var [ exp ] | identifier exp → var | number 这个文法模块语句既可是调用无参数的过程也可以是对变量的表达式的赋值。请注意,赋值和 过程调用都是以一个标识符开头。只有到看到语句的结尾或记号 := 时,分析才会决定该语句 是一个赋值还是一个调用。将这种情形简化成以下的文法,在其中删去了变量的替换选择,并 将语句选项简化为无需改变基本的情况: S→ id | V := E V→ id E→ V | n 为了显示这个文法在 SLR(1) 分析中是如何引起一个分析冲突,请考虑项目集合的 DFA的开始 状态: S′→.S S→.id S→.V := E V→.id 这个状态在id 上有一个到状态 S→id. V→id. 的转换。现在有Follow (S) = {$}和Follow ( V) = { :=, $ }(由于有规则V→V := E所以有:=,又由 于E可以是V,所以有$)。因此,SLR(1)分析算法要求在这个状态中有一个在输入符号$下的利用 规则S→id 和规则V→id 实现的归约(这是一个归约-归约冲突)。这个分析冲突实际上是一个由 SLR(1)方法的缺点所引起的“假冒”问题。实际上当输入为$时,用V→id 实现的归约永远也不 应该在这个状态中,这是由于只有到看到记号:=和被移进后,变量才会出现在语句的末端。 在下面的两节中,读者将会看到如何利用更强大的分析方法来解决这个分析问题。 5.3.4 SLR(k)文法 同其他分析算法一样, SLR(1)分析算法可被扩展为 SLR(k)分析,其中的分析动作是基于 k≥1个先行的符号之上。利用上一章定义的集合 Firstk 和Followk,Slr(k)分析程序使用以下两个 规则: 1) 若状态s 包含了格式A→α.Xβ(X是一个记号),且Xw ∈ First (Xβ)是输入串中之后的 k 个 k 记号,那么该动作就是将当前输入记号移进到栈中,而且被压入到栈中的新状态是包含了项目 A→αX.β的状态。 2) 若状态s 包含了完整项目 A→α.,且w ∈ Followk (A)是输入串中之后的 k 个记号,则动作 用规则A→α归约。 当k >1时,SLR(k)分析比 SLR(1)分析更强大,但由于分析表的大小将按 k的指数倍增长, 166 编译原理及实践 所以它又要复杂许多。非 SLR(1)的典型语言构造可利用 LRLA(1)分析程序处理得更好一些,它 可使用标准的消除二义性的规则,或将文法重写。虽然例 5.13的简单非SLR(1)文法确实也可出 现在SLR(2)中,但对于为任意值的k 而言,它所来自的程序设计语言问题却不是 SLR(k)。 5.4 一般的LR(1)和LALR(1)分析 本节将研究 LR(1)分析的最一般格式,有时它称作 LR(1)规范(canonical)分析。这种方法解 决了上一节最后所提到的 SLR(1)分析中出现的问题,但它却复杂得多。实际上在绝大多数情况 下,通常地,一般的 LR(1)分析太复杂以至于不能在大多数情况下的分析程序的构造中使用。 幸运的是,一般的 LR(1)分析的一个修正——称作 LRLA(1)(即“先行” LR分析)在保留了LR(1) 分析的大多数优点之外还保留了 SLR(1)方法的有效性。LALR(1)方法已成为诸如用于诸如 Yacc 这样的分析程序生成器所选用的方法,本节稍后将会研究到它。但为了理解这个方法,首先应 学习普通方法。 5.4.1 LR(1)项的有穷自动机 SLR(1)中的困难在于它在 LR(0)项的DFA的构造之后提供先行,而构造却又忽略了先行。 一般的LR(1)方法的功能在于它使用了一个从开始时就将先行构建在它的构造中的新 DFA。这 个 DFA 使用了 LR(0) 项的扩展的项目。由于它们包括了每个项目中的一个先行记号,所以就称 作LR(1)项(LR(1) item)。说得更准确一些就是: LR(1)项应是由 LR(0)项和一个先行记号组成 的对。利用中括号将 LR(1)项写作 [A→α.β a ] 其中A→α.β是一个LR(0)项,而a 则是一个记号(先行)。 为了完成一般 LR(1)分析所用的自动机的定义,我们需要首先定义 LR(1)项之间的转换。它 们与LR(0)转换相类似,但它们还知道先行。同 LR(0)项一样,它们包括了 - 转换,此外还需建 立一个DFA,它的状态是项目为 - 闭包的集合。 LR(0)自动机和LR(1)自动机的主要差别在于 转换的定义。我们首先给出较简单的情况下 (非 -转换)的定义,它们与 LR(0)的情况基本一致。 定义: LR(1)转换(第1部分)的定义 (definition of LR(1) transitions (part 1))。假设有 LR(1)项目[A→α.Xγ, a],其中X是任意符号 (终结符或非终结符 ),那么X就有一个到项 目[A→αX.γ, a]的转换。 请注意在这种情形下,两个项目中都出现了相同的先行 a,所以这些转换并不会引起新的 先行的出现。只有 - 转换才“创建”新的先行,如下所示。 定义: LR(1)转换(第2部分)的定义 (definition of LR(1) transitions (part 2)) 。假设有 LR(1)项目[A→α.Bγ,a],其中B是一个非终结符,那么对于每个产生式 B→β和在First (γa) 中的每个记号b都有到项目[B→.β,b] 的 - 转换。 请读者留意,这些 - 转换是如何跟踪在其中结构 B需要被识别的上下文。实际上项目 [A→ α.Bγ,a]说明了在分析的这一点上可能要识别 B,但这只有是当这个 B后跟有一个从串 γa 派生出 的串时,且这样的串须以一个在 First (γa)中的记号开始才可能。由于串 γ 跟随在位于产生式A→ αBγ 中的B之后,所以若a是被构造在Follow (A)中,那么有First (γa) Follow (B),且在项目[B 167 第 5章 自底向上的分析 →.β,b] 中的b 将总是在Follow (B)中。一般的LR(1)方法的功能在于集合First (γ a) 可能是Follow (B)的一个恰当的子集(SLR(1)分析程序本质上从整个 Follow集合中得到先行 b)。请读者再注意, 仅当γ 可派生出空串时,最初的先行 a 才可作为b 中的一个元素。在大多数情况下 (尤其是在实 际情况中),只有γ 当本身是 时它才发生,此时从格式 [A→α.B,a]到[B→.β, a]可得到 - 转换的 特殊情况。 对LR(1)项目集合的DFA的构造还包括指定开始状态。这是通过如同在 LR(0)中一样用新的 开始符号S′ 和新的产生式S′→S(其中S是最初的开始符号)来扩充文法。接着, LR(1)项目的NFA 的开始符号就成为了项目 [S′→.S,$],其中$代表结尾标记(且是Follow (S′ ) 中的唯一符号)。这 就有效地说明了是由识别一个从 S派生出的串开始,其后则是$符号。 现在来看一下有关LR(1)项目的DFA的构造的一些示例。 例5.14 考虑例5.9中的文法 A→ ( A ) | a 首先通过扩充文法以及构造初始的 LR(1)项目[A′→.A, $]来构建它的LR(1)项目集合的DFA。这 个项目的 - 闭包是DFA的开始状态。由于在这个项目中没有任何符号跟随在 A之后(按照前面所 讨论的有关转换的术语,串γ 就是 ),就有到项目[A→. ( A ), $] 和[A→.a, $] 的 - 转换(按照前 面讨论过的有关术语,即为First (γ$) = {$})。接着,开始状态(状态0)就是这3个项目的集合: 状态0: [A′→.A, $] [A→. ( A ), $] [A→.a, $] 在A上有从这个状态到包含了项目 [A′→A., $]的集合的闭包的转换,而且由于没有来自完整项目 的转换,所以这个状态仅包含了单个项目 [A′→A., $]。除此之外再也没有来自这个状态的转换 了,所以将它编号为状态 1: 状态1: [A′→A., $] (这是LR(1)分析算法将从中生成接受动作的状态 )。 再回到状态0上,在记号(之上也有一个到由项目 [A→. ( A ), $]组成的集合的闭包。由于 有来自这个项目的 -转换,这个闭包也就并不是微不足道的了。实际上从这个项目上也有到项 目[A→.( A ), )]和[A→.a, )]的 -转换。这是因为在项目[A→ (.A ), $]中,在括号的上下文的 右边识别A。也就是,右边A的Follow是First ( )$ ) = { )},因此在这种情况下,就得到了一个 新的先行记号,而且新的 DFA状态是由以下的项目组成: 状态2: [A→ ( .A ), $ ] [A→.( A ), ) ] [A→.a, ) ] 再回到状态0,在其中找到由项目 [A→a., $ ]生成的状态的最后转换。由于这是一个完整的项 目,所以它是一个项目的状态: 状态3: [A→a., $ ] 现在回到状态2。在A上有一个从这个状态到[A→ ( A . ), $]的 - 闭包的转换,它是带有一 个项目的状态: 状态4: [A→ ( A. ), $] 168 编译原理及实践 在(上也有一个到[A→ ( . A ), ) ]的 -闭包的转换。在这里也可根据与在状态 2的构造中相同的 原理,生成闭包项目[A→.( A ), ) ]和[A→.a, ) ]。所以就得到新的状态: 状态5: [A→ ( . A ), ) ] [A→ . ( A ), ) ] [A→.a, ) ] 请注意,这个状态除了第 1个项目的先行之外,其他都与状态 2相同。 最后,在记号a上有一个从状态2到状态6的转换: 状态6: [A→a., ) ] 请读者再次注意,这与状态 3几乎一样,只在先行上略有不同。 具有一个转换的下一个状态是状态 4,它有一个在记号 ) 上到状态 状态7: [A→ ( A )., $] 的转换。 再回到状态5,这里在 ( 上有一个从这个状态到它本身的转换,还有一个在 A上的到状态 状态8: [A→ ( A . ), ) ] 的转换,以及一个在 a上到早已构造好的状态 6的转换。 最后,在)上有一个从状态8到 状态9: [A→ ( A )., ) ] 的转换。 因此,这个文法的 LR(1)项目的DFA具有10个状态。图 5-7是完整的DFA。将它与相同文法 的LR(0)项目集合的 DFA相比较(参见图 5-5),我们发现 LR(1)项目的 DFA几乎是它的两倍大小。这 也是正常的。实际上通过在带有多个先行记号的复杂情形中的 10因子,LR(1)状态的数量可超 过LR(0)状态的数量。 图5-7 例5.14的LR(1)项目集合的DFA 169 第 5章 自底向上的分析 5.4.2 LR(1)分析算法 在考虑其他示例之前,我们需要先根据新的 DFA构造通过重新叙述分析算法而将一般的 LR(1)分析进行完善。由于仅需重述 SLR(1)分析算法而无需使用 LR(1)项目中的先行记号代替 Follow集合,所以这是容易办到的。 一般的LR(1)分析算法 令s 为当前状态(位于分析栈的顶部),则动作定义如下: ① 若状态s 包含了格式 [A→α.Xβ, a]的任意LR(1)项目,其中 X是一个终结符且是输入串中 的下一个记号,则动作就是将输入记号移进到栈中,且被压入到栈中的新状态是包含了 LR(1) 项目[A→αX.β, a]的状态。 ② 若状态s 包含了完整的LR(1)项目[A→α., a],且输入串中的下一个记号是 a,则动作就是 用规则A→α归约。用规则 S′→S (其中S是开始状态)实现的归约等价于接受 (只有当下一个输入 记号是$时才发生 )。在其他情况下,新状态的计算如下:将串 α以及与它对应的所有状态从分 析栈中删去。相应地 DFA返回到 α开始构造的状态。通过构造,这个状态必须包括格式 [B→ α.Aβ, b]的LR(1)项目。将A压入到栈中,并压入包含了项目 [B→αA.β, b] 的状态。 ③ 若下一个输入记号不是上面所述的任何一种情况,则声明一个错误。 同使用前面的方法一样,若前面的一般 LR(1)分析规则的应用程序不引起二义性,则该文 法就是LR(1)文法(LR(1) grammar)。特别地,当且仅当对于任何状态s,能够满足以下两个条件, 该文法才是 LR(1)文法: ① 对于在s 中的任何项目[A→α.Xβ, a],且X是一个终结符,则在 s 中没有格式 [B→β., X ] 的项目(否则就有一个移进-归约冲突 )。 ② 在s 中没有格式[A→α., a]和[B→β., a]的两个项目(否则就有一个归约-归约冲突 )。 我们还注意到可从表达一般 LR(1)分析算法的LR(1)项目集合的DFA中构造出一个分析表。 该表具有与 SLR(1)分析程序的表格完全相同的格式,如下例所示。 例5.15 为表5-10中的例5.14的文法给出一般的 LR(1)分析表。它可从图 5-7的DFA中很方便地 构造出来。在该表中,我们为在归约动作中的文法规则选择使用了以下的编号: (1) A→(A) (2) A→a 因此在状态3中带有先行$的项r2指出了规则A→a 实现的归约。 表5-10 例5.14的一般LR(1)分析表 状态 ( 0 s2 1 2 s5 3 4 5 s5 6 7 8 9 输入 a ) $ s3 s6 s7 s6 r2 r1 s9 r1 A 1 接受 r2 Goto 4 8 170 编译原理及实践 因为在一般的LR(1)分析中的特定串的分析步骤与它们在 SLR(1)分析或LR(0)分析中的步骤 相同,所以我们省略了一个这样的分析示例。由于也可从 DFA中很方便地得到,所以本节随后 的例子也将省掉分析表的构造。 在实际中除非是有二义性的,几乎所有合理的程序设计语言的文法都是 LR(1)文法。当然 也有可能构造出不能成为 LR(1)文法的非二义性文法的示例来,但在这里就不这样做了 (参见练 习)。在实践中人们总是试图避免它。实际上,程序设计语言甚至很少需要一般的 LR(1)分析所 提供的能力。我们前面用来介绍 LR(1)分析的示例 (例5.14)其实早已是一个LR(0)文法了(而且因 此也就是 SLR(1)文法了 )。 下面的示例表明一般的LR(1)分析解决了例5.13中不能成为SLR(1)的文法的先行问题。 例5.16 例5.13的文法在简化了的格式中是: S→ id | V := E V→ id E→ V | n 为这个文法构造 LR(1)项目集合的 DFA。其开始状态是项目 [S′→.S, $]的闭包,它包括了项目 [S →.id, $]和[S→.V := E, $]。因为S→.V := E 指出可能识别了一个V,但是这只有是当它后面 是一个赋值记号时,所以这个最后的项目还引起了闭包项目 [V→.id, :=]。因此,记号:=作为 初始项目V→.id 的先行出现。开始状态可总结为由以下的 LR(1)项目组成: 状态0: [S′→.S, $] [S →.id, $] [S →.V := E, $] [V →.id, :=] 在S上有一个从这个状态到一个项目的状态 状态1: [S′→.S, $] 的转换,且在 id上有一个到两个项目的状态 状态2: [S→.id, $] [V →.id, .= ] 的转换。在 V上还有一个从状态 0到一个项目的状态 状态3: [S →.V .:= E, $] 的转换。从状态 1和状态2没有转换,但在 :=上从状态3到项目[S →V :=. E, $] 的闭包有一个转 换。由于在这个项目中 E之后没有符号,所以这个闭包包括了项目 [E→.V, $]和[E →.n, $]。最 后,由于此时V之后也没有符号,项目[E→.V, $]可引起闭包项目[V →.id,$] (这与在状态0中的 情形不同,在状态 0中V后有一个赋值。而由于此时已经看到了一个赋值记号,所以这里的 V后 不能有一个赋值 )。那么最后的状态就是 状态4: [S →V := .E, $] [E →.V, $] [E →.n, $] [V →.id, $] 后面的状态和转换能够很容易地构造出来,我们将它留给读者。图 5-8中是LR(1)项目集合的完 整的DFA。 171 第 5章 自底向上的分析 图5-8 例5.16中LR(1)项目集合的DFA 现在来考虑状态 2。这是引起SLR(1)分析冲突的状态。LR(1)项目可由它的先行清晰地区分 出两个归约:在 :=上用规则S→.id 实现的归约和用 V→.id 实现的归约。因此这个文法就是 LR(1)文法。 5.4.3 LALR(1)分析 LALR(1)分析是基于以下的观察:在许多情况下, LR(1)项目集合的 DFA的大小应部分地 归于许多不同状态的存在,在这些状态的项目 (LR(0)项目)中,第1个成分的集合相同,只有第 2个成分(先行符号 )不同。例如,图 5-7的LR(1)项目的DFA有10个状态,而相应的 LR(0)项目的 DFA(图5-5)只有 6个。实际上在图 5-7 的状态 2和5、状态 4和8、状态 7和9、状态 3和6中,每对中 的状态与其他的区别只在于它的项目的先行成分。例如状态 2和5:这两个状态只在其第 1个项 目上不同,且仅在那个项目的先行上:状态 2有第1个项目[A→ ( .A ), $],且$作为它的先行; 而状态5有第1个项目[A→ ( .A ), )],且)作为它的先行。 LALR(1)分析算法表明了它使得标识所有这样的状态和组合它们的先行有意义。在这样做 时,我们总是必须以一个与 LR(0)项目中的 DFA相同的DFA作为结尾,但是每个状态都是以带 有先行集合的项目组成。在完整项目的情况下,这些先行集合通常比相应的 Follow集合小;因 此,LRLA(1)分析保留了LR(1)分析优于SLR(1)分析的一些特征,但是仍具有在 LR(0)项目中的 D FA尺寸较小的特点。 更正式地,在 LR(1)项目的DFA中,状态的核心(core)是由状态中的所有 LR(1)项目的第1个 成分组成的 LR(0)项目的集合。由于 LR(1)项目的DFA的构造使用与在 LR(0)项目的DFA的构造 中相同的转换,但是它们在项目的先行部分上的作用不同,我们得到以下两个事实,它们构成 了LALR(1)分析构造的基础。 (1) LALR(1)分析的第1个原则 LR(1)项目的DFA的状态核心是LR(0)项目的DFA的一个状态。 172 编译原理及实践 (2) LALR(1)分析的第2个原则 若有具有相同核心的 LR(1)项目的DFA的两个状态 s 和s ,假设在符号X上有一个从 s 到状 1 2 1 态t 的转换,那么在 X上就还有一个从状态 s 到一个状态 t 的转换,且状态 t 和t 具有相同的 1 2 2 1 2 核心。 总之,这两个原则允许我们构造 LALR(1)项目的DFA(DFA of LALR(1) items),它是通过识 别具有相同核心的所有状态以及为每个 LR(0)项目构造出先行符号的并,而从 LR(1)项目的DFA 构造出来。因此,这个 DFA的每个LALR(1)项目都将一个LR(0)项目作为它的第 1个成分,并将 一个先行记号的集合作为它的第 2个成分 。在后面的示例中将在先行之间写一个 /来表示多重 先行。因此,LALR(1)项目[A→α. β, a / b / c] 具有一个由符号a、b 和c 组成的先行集合。 我们给出一个展示这个构造的示例。 例5.17 考虑例5.14的文法,它的LR(1)项目的DFA在图5-7中。通过识别状态 2和5、状态4和8、 状态7和9、状态3和6,就可得出图 5-9中的LALR(1)项目的 DFA。在那个图中,我们保留了状 态2、3、4和7的编号,并从状态 5、6、8和9添加了先行。正如所期望的,除了先行之外,这个 DFA与LR(0)项目的DFA相同。 图5-9 例5.17的LALR(1)项目集合的DFA 使用了LALR(1)项目的压缩了的 DFA的LALR(1)分析算法与上一节所描述的一般的 LR(1) 分析算法相同。同前所述,若在 LALR(1)分析算法中没有出现分析冲突则称这个文法为 LALR(1)文法(LALR(1) grammar)。在LALR(1)构造中,有可能制造出在一般的 LR(1)分析中不 存在的分析冲突来,但这在实际中却很少发生。实际上,若一个文法是 LR(1)文法,则 LALR(1)分析表就不能有任何的移进-归约冲突了;但是却有可能有归约-归约冲突 (参见练 习)。然而,若一个文法是 SLR(1),那么它肯定就是 LALR(1)文法,而且 LALR(1)分析程序在 消除发生在 SLR(1)分析中的典型冲突时通常与一般的 LR(1)分析程序所做的相同。例如,例 5.16的非SLR(1)文法是LALR(1):图5-8的LR(1)项目的DFA也是LALR(1)项目的DFA。如在这 个示例中一样,如果文法已经是 LALR(1)了,则使用 LALR(1)分析取代一般的 LR分析的唯一 结果是:在声明错误之前会作出一些虚假的归约。例如,从图 5-9中可看出,若有错误的输入 串a),则LALR(1)分析程序将在声明错误之前执行归约 A→a,但是一般的 LR(1)分析程序将在 LR(1)项目的DFA实际上还可以利用先行符号的集合代表共享它们的第 1个成分的相同状态中的多重项目,但 是我们发现为 LALR(1)构造使用这种表示法也很方便,在这里它最适合。 173 第 5章 自底向上的分析 移进记号 a之后立即声明错误。 将LR(1)状态组合到 LALR(1)项目的DFA解决了分析表尺寸较大的问题,但它仍要求计算 LR(1)项目的整个 DFA。实际上,通过传播先行 (propagating lookahead)的处理从 LR(0)项目的 DFA直接计算出 LALR(1)项目的DFA是有可能的。尽管我们对此并不着意进行描述,但看看如 何相对简单地做到它仍是不无好处的。 观察图5-9中的LALR(1)DFA。首先通过将结尾标记 $添加到状态 0中的扩充项目 A′→.A 的 先行中($先行被称作被自发生成的 (spontaneously generated))。接着再通过 - 闭包的规则,将 $ 传播到两个闭包项目 (核心项目 A′→.A 的右边的 A后是空串 )。通过跟随状态 0的3个转换,将 $ 传播到状态 1、3和2的核心项目。继续状态 2,闭包项目得到先行 ),再一次通过自发生成 (因为 位于核心项目A′→ (.A ) 上的A在右括号之前)。现在在 a上的到状态 3的转换使得将)传送到那 个状态中项目的先行 (上的从状态 2到它本身的转换也使得 ) 。传送到核心项目的先行 (这就是 为什么核心项目在它的先行集合中有 $和的原因)。现在先行集合 $/传送到了状态 4,之后再是 到了状态7。因此通过这个处理就可直接从 LR(0)项目的DFA得到图5-9中的LALR(1)的DFA了。 5.5 Yacc:一个LALR(1)分析程序的生成器 分析程序生成器(parser generator)是一个指定某个格式中的一种语言的语法作为它的输入, 并为该种语言产生分析过程以作为它的输出的程序。在历史上,分析程序生成器被称作编译 编译程序 (compiler-compiler),这是由于按照规律可将所有的编译步骤作为包含在分析程序中 的动作来执行。现在的观点是将分析程序仅考虑为编译处理的一个部分,所以这个术语也就有 些过时了。合并 LALR(1)分析算法是一种常用的分析生成器,它被称作 Yacc(yet another compiler-compiler)。本节将给出 Yacc的概貌来,下一节将使用 Yacc为TINY语言开发一个分析 程序。由于Yacc有许多不同的实现以及存在着通常称作 Bison的若干个公共领域版本 ,所以在 它的运算细节中有许多变化,而这又可能与这里所用的版本有些不同 。 5.5.1 Yacc基础 Yacc取到一个说明文件 (通常带有一个 .y后缀)并产生一个由分析程序的 C源代码组成的输 出文件(通常是在一个称作 y.tab.c或ytab.c或更新一些的<文件名>.tab.c的文件中,而< 文件名 >.y则是输入文件 )。Yacc说明文件具有基本格式 {definitions} %% {rules} %% {auxiliary routines} 因此就有 3个被包含了双百分号的行分隔开来的部分——定义部分、规则部分和辅助程序 部分。 定义部分包括了 Yacc需要用来建立分析程序的有关记号、数据类型以及文法规则的信息。 它还包括了必须在它的开始时直接进入输出文件的任何 C 代码 (主要是其他源代码文件的 #include指示)。说明文件的这个部分可以是空的。 一个流行版本—— Gnu Bison——是由Free Software Foundation发布的Gnu软件的一个部分,请参见“注意与 参考”部分。 实际上,我们已使用了若干个不同的版本来生成后面的示例。 174 编译原理及实践 规则部分包括修改的 BNF格式中的文法规则以及将在识别出相关的文法规则时被执行的 C 代码中的动作 (即:根据 LALR(1)分析算法,在归约中使用 )。文法规则中使用的元符号惯例如 下:通常,竖线被用作替换 (也可分别写出替换项 )。用来分隔文法规则的左右两边的箭头符号 →在Yacc中被一个冒号取代了,而且必须用分号来结束每个文法规则。 第3点:辅助程序部分包括了过程和函数声明,除非通过 #include文件,否则它们会不 适用,此外还需要被用来完成分析程序和 /或编译程序。这个部分也可为空,此时第 2个双百分 号元符号可从说明文件中省略掉。因此,最小的 Yacc说明文件可仅由后面带有文法规则和动作 (若仅是要分析文法也可省掉动作,本节稍后再讲到它 )的%%组成。 Yacc还允许将 C-风格的注解插入到说明文件的任何不妨碍基本格式的地方。 利用一个简单的示例,我们可将 Yacc说明文件的内容解释得更详细一些。这个示例是带有 文法 exp → exp addop term | term addop→ + | term → term mulop factor | factor mulop → * factor → ( exp ) | number 的简单整型算术表达式的计算器。这个文法在前面的章节中已用得很多了。在 4.1.2节中,我们 为这个文法开发了一个递归下降计算器程序。程序清单 5-1给出了完全等价的 Yacc说明。下面 按顺序讨论这个说明中 3个部分的内容。 程序清单 5-1的定义部分有两个项目。第1个项目由要在 Yacc输出的开始处插入的代码组成。 这个代码是由两个典型的 #include指示组成,且由在其前后的分隔符 %{和%}将它与这个部 分中的其他 Yacc说明分开(请注意百分号在括号之前 )。定义部分的第2个项目是记号 NUMBER的 声明,它代表数字的一个序列。 程序清单 5-1 一个简单计算器程序的 Yacc定义 175 第 5章 自底向上的分析 Yacc用两种方法来识别记号。首先,文法规则的单引号中的任何字符都可被识别为它本身。 因此,单字符记号就可直接被包含在这个风格的文法规则中,正如程序清单 5-1中的运算符记 号+、-和* (以及括号记号)。其次,可在Yacc的%记号(%token)中声明符号记号,如程序清单 5-1中的记号 NUMBER。这样的记号被 Yacc赋予了不会与任何字符值相冲突的数字值。典型地, Yacc开始用数字258给记号赋值。 Yacc将这些记号定义作为 #define语句插入到输入代码中。 因此,在输出文件中就可能会找到行 #define NUMBER 258 作为Yacc对说明文件中的 %token NUMBER声明的对应。Yacc坚持定义所有的符号记号本身, 而不是从别的地方引入一个定义。但是却有可能通过在记号声明中的记号名之后书写一个值来 指定将赋给记号的数字值。例如,写出 %token NUMBER 18 就将给NUMBER赋值18 (不是258)。 在程序清单5-1的规则部分中,我们看到非终结符 exp、term和factor的规则。由于还需要打 印出一个表达式的值,所以还有另外一个称为 command的规则,而且将其与打印动作相结合。 因为首先列出了command的规则,所以command则被作为文法的开始符号。若不这样,我们还 可在定义部分中包括行 %start command 此时就不必将command的规则放在开头了。 Yacc中的动作是由在每个文法规则中将其写作真正的 C代码(在花括号中 )来实现的。通常, 尽管也有可能在一个选择中写出嵌入动作 (embedded action)(稍后将讨论它),但动作代码仍是 放在每个文法规则选择的末尾 (但在竖线或分号之前 )。在书写动作时,可以享受到 Yacc伪变量 176 编译原理及实践 (pseudovariable)的好处。当识别一个文法规则时,规则中的每个符号都拥有一个值,除非它 被参数改变了,该值将被认为是一个整型 (稍后将会看到这种情况 )。这些值由 Yacc保存在一个 与分析栈保持平行的值栈 (value stack)中。每个在栈中的符号值都可通过使用以 $开始的伪变 量来引用。 $$代表刚才被识别出来的非终结符的值,也就是在文法规则左边的符号。伪变量 $1、$2、$3等等都代表了文法规则右边的每个连续的符号。因此在程序清单 5-1中,文法规 则和动作 exp : exp '+' term { $$ = $1 + $3; } 就意味着当识别规则 exp→exp + term时,就将左边的exp 的值作为exp 的值与右边的 term 的值 之和。 所有的非终结符都是通过这样的用户提供的动作来得到它们的值。记号也可被赋值,但这 是在扫描过程中实现的。 Yacc假设记号的值被赋给了由 Yacc内部定义的变量 yylval,且在识别 记号时必须给yylval赋值。因此,在文法和动作 factor : NUMBER { $$ = $1;} 中,值$1指的是当识别记号时已在前面被赋值为 yylval的NUMBER记号的值。 程序清单5-1的第3个部分(辅助程序部分 )包括了3个过程的定义。第1个是main的定义,之 所以包含它是因为 Yacc输出的结果可被直接编译为可执行的程序。过程 main调用yyparse, yyparse是Yacc给它所产生的分析过程起的名称。这个过程被声明是返回一个整型值。当分 析成功时,该值总为 0;当分析失败时,该值为 1(即发生一个错误,且还没有执行错误恢复 )。 Yacc生成的yyparse过程接着又调用一个扫描程序过程,该过程为了与 Lex扫描程序生成器相 兼容,所以就假设叫作 yylex (参见第2章)。因此,程序清单5-1中的Yacc说明还包括了yylex 的定义。在这个特定的情况下, yylex 过程非常简单。它所需要做的只有返回下一个非空字 符;但若这个字符是一个数字,此时就必须识别单个元字符记号 NUMBER并返回它在变量 yylval中的值。这里有一个例外:由于假设一行中输入了一个表达式,所以当扫描程序已到 达了输入的末尾时,输入的末尾将由一个新行字符(在 C中的‘\n’)指出。 Yacc希望输入的 末尾通过yylex由空值0标出(这也是Lex所共有的一个惯例)。最后就定义了一个yyerror过程。 当在分析时遇到错误时, Yacc就使用这个过程打印出一个错误信息 (典型地, Yacc打印串“语 法错误”,但这个行为可由用户改变 )。 5.5.2 Yacc选项 除了yylex和yyerror之外,Yacc通常需要访问许多辅助过程,而且它们经常是被放在 外置的文件之中而非直接放在 Yacc说明文件中。通过写出恰当的头文件以及将 #include指示 放在Yacc说明的定义部分中,就可以很容易地使 Yacc访问到这些过程。将 Yacc特定的定义应用 到其他文件上就要复杂一些了,在记号定义时尤为如此。此时正如前面所讲的, Yacc坚持自己 生成(而不是引入 ),但是它又必须适用于编译程序的许多其他部分 (尤其是扫描程序 )。正是由 于这个原因, Yacc就有一个可用的选项,自动产生包含了该信息的头文件,而这个头文件将被 包括在需要定义的任何其他文件中。这个头文件通常叫作 y.tab.h或ytab.h,并且它与-d选 项(用于heaDer文件)一起生成。 例如,若文件calc.y包括了程序清单5-1中的Yacc说明,则命令 yacc -d calc.y 将产生(除了文件y.tab.c文件之外)内容各异的文件y.tab.h(或相似的名称 ),但却总是包括 177 第 5章 自底向上的分析 下示的内容: #ifndef YYSTYPE #define YYSTYPE int #endif #define NUMBER 258 extern YYSTYPE yylval; (稍后将详细地描述 YYSTYPE的含义)。这个文件可被用来通过插入行 #include y.tab.h 到文件中而将yylex的代码放在另一个文件中 。 Yacc的另一个且极为有用的选项是详细选项 (verbose option),它是由命令行中的 -v标志激 活。这个选项也产生另一个名字为 y.output(或类似名称的)的文件。这个文件包括了被分析 程序使用的 LALR(1)分析表的文本描述。阅读这个文件将使得用户可以准确地判断出 Yacc生成 的分析程序在任何情况下将会采取的动作,而且这是处理文法中的二义性和不准确性的极为有 效的方法。在向说明添加动作或辅助过程之前, Yacc与这个选项一起在文法上运行以确保 Yacc 生成的分析程序将确实如希望的那样执行的确是一个好办法。 例如,程序清单 5-2中的Yacc说明。这是程序清单 5-1中Yacc说明的基本版本。当同时使用 Yacc和详细选项: yacc -v calc.y 程序清单 5-2 使用-V选项的Yacc说明提纲 时,这两个说明生成相同的输出文件。程序清单 5-3是该文法完整的典型 y.output文件 。下 面的段落将讨论如何解释这个文件。 Yacc输出文件列出了 DFA中的所有状态,此外还有内部统计的小结。状态由 0开始编号。 输出文件在每个状态的下面列出了核心项目 (并未列出闭包项目 ),其次是与各个先行对应的动 作,最后则是各个非终结符的 goto动作。Yacc尤其使用一个下划线字符 _来标出项目中的显著 Yacc的早期版本可能只将记号的定义 (但没有yylval的定义)放置在y.tab.h中。这可能将要求一个共同的 工作区或重新安排代码。 Bison的较新版本在输出文件中产生了一个根本不同的格式,但是内容却基本相同。 178 编译原理及实践 的位置,用它来代替本章所用到的句点,却用句点来指明缺省,或“不在意”每个状态列表的 动作部分中的先行记号。 程序清单 5-3 为程序清单 5-1中的Yacc说明使用详细选项生成的典型 y.output 文件 179 第 5章 自底向上的分析 Yacc通过列出扩充产生式的初始项目而从状态 0开始,而这通常在 DFA的开始状态中只有 核心项目。在上面示例的输出文件中,这个项目写作 $accept :_command $end 它与我们自己的术语中的项目 command′→.command 对应。Yacc为扩充的非终结符提供的名字 为$accept。它还将输入结尾的伪记号显式地列为 $end。 首先大致地看一下状态 0的动作部分,它后面是核心项目的列表: NUMBER shift 5 ( shift 6 . error command goto 1 exp goto 2 term goto 3 factor goto 4 上面的列表指出了 DFA移进到先行记号 NUMBER的状态5中、移进到先行记号 (的状态6中,并 且说明其他所有先行记号中的错误。此外为了在归约到所给出的非终结符中使用还列出了 4个 goto转换。这些动作与用这章中的方法手工构造分析表中的内容完全一样。 再看看状态 2,它有输出列表 state 2 command : exp_ (1) exp : exp_+ term exp : exp_- term + shift 7 - shift 8 . reduce 1 这里的核心项目是一个完整的项目,所以在动作部分中有一个用相关的产生式选择实现的归约。 为了提醒我们在归约中所使用的产生式的编号, Yacc在完整的项目之后列出了编号。在这种情 180 编译原理及实践 况下,产生式编号就是 1,而且有一个表示用产生式 command → exp实现的 reduce 1动作。 Yacc总是按照它们在说明文件中所列的顺序为产生式编号。在我们的示例中,有 8个产生式 (command的一个, exp的3个,term的两个以及 factor的两个 )。 请注意,在这个状态中的归约动作是一个缺省动作:一个将在任何不是 +或-的先行之上 的归约。这里的 Yacc与一个单纯的LALR(1)分析程序 (而且甚至是 SLR(1)分析程序 )的不同在于 它并不试着去检查归约上的合法先行 (而是在若干个归约中决定 )。Yacc分析程序将在最终声明 错误之前在错误之上作出多个归约 (这将是在任何的更多的移进发生之前的最后行为 )。这就意 味着错误信息可能不是像它们应该的那样有信息价值,但是分析表却会变得十分简单,这是因 为情况发生得更少了(这点将在5.7节中再次讨论到)。 在这个示例最后,我们从 Yacc输出文件中构造出一个分析表,该表与本章早些时候手工写 出的完全一样。表 5-11就是这个分析表。 表5-11 与程序清单 5-3的Yacc输出对应的分析表 状态 NUMBER ( 0 s5 s6 1 2 r1 r1 3 r4 r4 4 r6 r6 5 r7 r7 6 s5 s6 7 s5 s6 8 s5 s6 9 s5 s6 10 11 r2 r2 12 r3 r3 13 r5 r5 14 r8 r8 输入 + - * s7 s8 r1 r4 r4 s9 r6 r6 r6 r7 r7 r7 s7 s8 r2 r2 s9 r3 r3 s9 r5 r5 r5 r8 r8 r8 Goto ) $ command exp term factor 1 2 3 4 accept r1 r1 r4 r4 r6 r6 r7 r7 10 3 4 11 4 12 4 13 S14 r2 r2 r3 r3 r5 r5 r8 r8 5.5.3 分析冲突与消除二义性的规则 详细选项的一个重要用处是 Yacc将在y.output文件中报告调查的分析冲突。 Yacc在其中 建立了消除二义性的规则,该规则将允许它甚至在分析冲突发生时产生一个分析程序 (因此, 甚至这也是用于二义性文法的 )。这些消除二义性的规则通常总能做对,但有时也会出错。对 y.output文件的检查使用户判断出分析冲突是什么,以及由 Yacc产生的分析程序是否可正确 地解决问题。 181 第 5章 自底向上的分析 程序清单5-1的示例中并没有分析冲突,在输出文件结尾的小结信息中, Yacc将它报告为 0 shift / reduce, 0 reduce / reduce conflicts reported 例5.12中的二义性悬挂 else文法是一个更为有趣的示例。在表 5-9中,我们为这个文法给出 了SLR(1)分析表,在其中通过选取移进而不是归约将状态 5中的移进-归约冲突消除了 (它与最 近嵌套消除二义性的规则对应 )。Yacc以完全相同的术语报告了二义性,并且通过相同的消除 二义性的规则解决了二义性。除了 Yacc插入到错误项中的缺省归约之外,由 Yacc报告的分析表 确实与表5-9相同。例如,Yacc将状态5的动作在y.output文件中报告如下 (记号在说明文件中 的定义用小写字母写出,这样就避免了与 C的保留字相冲突 ): 5 : s h i f t / r e d u c e c o n f l i c t ( s h i f t 6 ,n r3e d) o n E L S E state 5 I : IF S_(3) I : IF S_ELSE S ELSE shift 6 . reduce 3 在小结信息中,Yacc还报告了一个移进-归约冲突: 1 shift / reduce, 0 reduce / reduce conflicts reported 在归约-归约冲突的情况下, Yacc 通过执行由文法规则在说明文件中首先列出的归约来消 除二义性。尽管它也会带来正确的分析程序,这仍与在文法中的错误很相似。下面是一个简单 的示例。 例5.18 考虑以下的文法: S→A | B A→a B→a 由于单个合法串a 有两个派生:S ⇒ A ⇒ a 和S ⇒ B ⇒ a,所以这是一个有二义性的文法。程 序清单5-4是这个文法完整的 y.output文件。请注意,在状态 4中的归约-归约冲突,它由在 规则B→a 之前执行规则A→a 来解决。这样就导致了后面的这个规则永远不会用在归约之中 (它 很明确地指出文法的一个问题 )。Yacc在最后报告了这项情况以及行 Rule not reduced : B : a 程序清单 5-4 例5.18中文法的Yacc输出文件 182 编译原理及实践 除了前面已提到过的消除二义性的规则之外, Yacc为指定与一个有二义性的文法相分隔开 的算符优先及结合性还具有特别的机制。它具有一些优点。首先,文法无需包括指定了结合性 和优先权的显式构造,而这就意味着文法可以短一些和简单一些了。其次,相结合的分析表也 可小一点且得出的分析程序更有效。 例如程序清单 5-5中的Yacc说明。在那个图中,文法是用没有算符的优先权和结合性的具 有二义性的格式书写。相反地,算符的优先权和结合性通过写出行 %left '+' '-' %left '*' 在定义部分中给出来。这些行向 Yacc指出算符 +和-具有优先权且是左结合的,而且运算 符*是左结合且有比 +和-更高的优先权 (因为在说明中,它是列在这些算符之后 )。在Yacc中, 其他可能的算符说明是%right 和%nonassoc ("nonassoc" 意味着重复的算符不允许出现在相同的 层次上)。 程序清单 5-5 带有二义性文法和算符的优先权及结合性规则的简单计算器的 Yacc说明 183 第 5章 自底向上的分析 5.5.4 描述Yacc分析程序的执行 除了在y.output文件中显示分析表的详细选项之外, Yacc生成的分析程序也可能打印出 它的执行过程,这其中包括了分析栈的一个描述以及与本章早先给出的描述类似的分析程序动 作。这是通过用符号定义的 YYDEBUG编译y.tab.c(例如通过使用 -DYYDEBUG编译选项)以及 在需要描述信息的地方将 Yacc整型变量 yydebug设置为1。例如假设表达式 2+3是输入,请将 下面的行添加到程序清单 5-1的main过程的开头 extern int yydebug; yydebug = 1; 将会使得分析程序产生一个与程序清单 5-6相似的输出。我们希望读者利用表 5-11的分析表手 工构造出分析程序的动作描述并将它与这个输出作一对比。 程序清单 5-6 假设有输入 2 + 3 ,利用yydebug为由程序清单 5-1生成的Yacc分析程序描绘输出 184 编译原理及实践 5.5.5 Yacc中的任意值类型 程序清单 5-1使用与文法规则中的每个文法符号相关的 Yacc伪变量指出计算器的动作。例 如,用写出$$ = $1 + $将3 一个表达式的值设置为它的两个子表达式值之和 (在文法规则exp → exp + term的右边的位置1和位置3)。由于这些值的Yacc缺省类型总是整型,所以只要处理的 值是整型就可以了。但是若要用浮点值计算一个计算器,那就不合适了。在这种情况下,必须 在说明文件中对 Ya c c伪变量的值类型进行重定义。这个数据类型总是由 C预处理器符号 YYSTYPE在Yacc中定义。重定义这个符号会恰当地改变 Yacc值栈的类型。因此若需要一个计 算浮点值的计算器,就必须在 Yacc说明文件的定义部分的括号 %{...%}中添加行 #define YYSTYPE double 在更复杂的情况下,不同的文法规则就有可能需要不同的值。例如,假设希望将算符挑选的识 别与用那些算符计算的规则分隔开,如在规则 exp → exp addop term | term addop → + | (它们实际上是表达式文法的原始规则,我们把它们改变了以直接识别程序清单 5-1中的exp规 则的算符 )。现在 addop必须返回算符 (一个字符 ),但exp必须返回计算的值 (即一个 double), 但这两个数据类型不同。我们所需要做的是将 YYSTYPE定义为 double与char的联合。有两 种方法可以办到。其一是在 Yacc说明中利用Yacc的%union声明来直接声明一个联合: %union { double val; char op; } 现在Yacc需要被告知每个非终结符的返回类型,这是利用定义部分中的 %type指示来实现的: %type exp term factor %type addop mulop 请注意,在Yacc的%type声明中,联合域的名称是怎样被尖括号括起来的。接着将程序清单 5-1 的Yacc说明修改成按下开始(我们将详细的写法留在练习中): ... %token NUMBER %union { double val; char op; } %type exp term factor NUMBER %type addop mulop 185 第 5章 自底向上的分析 %% comand : exp { printf ( " %d\n " , $1 ) ; } ; exp : exp op term { swithc ( $2 ) { case '+': $$ = $1 + $3; break; case '-': $$ = $1 - $3; break; } } | term { $$ = $1; } ; op : '+' { $$ '+'; } | '-'{ $$ '-'; } ; 第2种方法是在另一个包含文件 (例如,一个头文件 )中定义一个新的数据类型之后再将 YYSTYPE定义为这个类型。接着必须在相关的动作代码中手工地构造出恰当的值来。下一节 中的TINY分析程序是它的一个示例。 5.5.6 Yacc中嵌入的动作 在分析时,有时需要在完整地识别一个文法规则之前先执行某个代码。例如考虑简单声明 的情况: decl → typevar-list type → int | float var-list → var-list , id | id 当识别var-list时,用当前类型 (整型和浮点)将标签添加于每个变量标识符上。可按如下方法在 Yacc中完成: dec1 : type { current_type = $1 ; } var_list ; type : INT { $$ = INT_TYPE ; } | FLOAT { $$ = FLOAT_TYPE ; } ; var_list : var_list ';' ID { setType (tokenString, current_type);} | ID { setType (tokenString, current_type);} ; 请注意,在 decl规则中识别变量之前,一个嵌入的动作如何设置 current_type。读者将在 后面的章节中看到其他有关嵌入动作的示例。 Ya c c将一个嵌入动作 A : B { /* embedded action */ } C ; 解释为与一个新的占位符非终结符相等价,且与在被归约时,执行嵌入动作的非终结符的 - 产 生式等价: A:BEC; E : { /* embedded action */ } ; 186 编译原理及实践 最后,在表 5-12中小结了 Yacc的定义机制以及已讨论过的一些内置名称。 表5-12 Yacc内置名称和定义机制 Yacc的内置名称 y.tab.c y.tab.h yyparse yylval yyerror error yyerrok yychar YYSTYPE yydebug 含义/用处 Yacc输出文件名称 Yacc生成的头文件,包含了记号定义 Yacc分析例程 栈中当前记号的值 由Yacc使用的用户定义的错误信息打印机 Yacc错误伪记号 在错误之后重置分析程序的过程 包括导致错误的先行记号 定义分析栈的值类型的预处理器符号 变量,当由用户设置为 1时则导致生成有关分析动作的运行信息 Ya c c的定义机制 %token %start %union %type %left %right %nonassoc 含义/用处 定义记号预处理器符号 定义开始非终结符符号 定义和YYSTYPE,允许分析程序栈上的不同类型的值 定义由一个符号返回的和类型 定义算符的结合性和优先权 (由位置) 5.6 使用Yacc生成TINY分析程序 TINY的语法已在3.7节中给出,且在4.4节中也已给出了一个手写的分析程序;我们希望读 者能掌握这些内容。这里将描述 Yacc说明文件 tiny.y以及对全程定义 globals.h的修改(我 们已采用了一些方法将对其他文件的改变定为最小,这也将讲到 )。整个tiny.y文件都列在了 附录B的第4000行到第4162行中。 首先讨论 TINY的Yacc说明中的定义部分。稍后将谈到标志 YYPARSER(第4007行)。表示 了Yacc在程序中的任何地方都需要的信息有 4个#include文件(第4009行到第4012行)。定义部 分有4个其他声明。第 1个(第4014行)是YYSTYPE的定义,它定义了通过使 Yacc分析过程为指向 节点结构的指针返回的值 (TreeNode本身被定义在globals.h中),这样就允许了 Yacc分析程 序构造出一个语法树。第 2个是全程 savedName变量的定义,它被用作暂时储存要被插入到还 没构造出的树节点中的标识符串,而此时已能在输入中看到这些串了 (在TINY中只有在赋值中 才需要 )。变量 savedLineNo也是被用作相同目的,所以那个恰当的源代码行数也将与标识符 关联。最后, savedTree被用来暂时储存由 yyparse过程产生的语法树 (yyparse本身可以 仅返回一个整型标志 )。 下面讨论一下与 TINY的每个文法规则相结合的动作 (这些规则与第3章的程序清单 3-1中所 给出的 BNF略有不同 )。在绝大多数情况下,这些动作表示与该点上的分析树相对应的语法树 的构造。特别地,需要从 util包调用到newStmtNode和newExpNode来分配新的节点 (这些 已在 4.4节中讲述过了 ),而且也需要指派新树节点的合适的子节点。例如,与 TINY的 write_stmt(第4082ff行)相对应的动作如下所示: 187 第 5章 自底向上的分析 write_stmt : WRITE exp { $$ = newStmtNode (WriteK); $$ ->child [0] = $2; } ; 第1个指令调用newStmtNode并指派返回的值为write_stmt的值。接着exp (Yacc伪变量$2 是指向将要被打印的表达式的树节点的指针 )前面构造的值为 write语句的树节点的第 1个孩子。 其他的语句和表达式的动作代码十分类似。 program、stmt_seq和assign_stmt的动作处理与每个这些构造相关的小问题。在 program的文法规则中,相关的动作 (第4029行)是 {savedTree = $1;} 它将stmt_seq构造的树赋给了静态变量 savedTree。由于它使得语法树可由 parse过程之 后返回,所以这是必要的。 在assign_stmt的情况中,我们早已指出需要储存作为赋值目标的变量的标识符串,这 样当构造节点时 (以及为了便于今后的描绘,还编制了它的行号 )它就是恰当的了。通过使用 savedName和saveLineNO静态变量(第4067行)可以做到这一点: assign_stmt : ID { savedName = copyString ( tokenString ) ; savedLineNo = lineno ; } ASSIGN exp { $$ = newStmtNode ( AssignK ); $$ ->child [ 0 ] = $4 ; $$ ->attr.name = savedName ; $$ ->lineno = saveLineNo ; } ; 由于作为被匹配的新记号, tokenString和lineno的值都被扫描程序改变了,所以标识 符串和行号必须作为一个在 ASSIGN记号识别之前的一个嵌入动作储存起来。但是只有在识 别出 exp之后才能完全构造出赋值的新节点,因此就需要 savedName和saveLineNo。(实 用程序过程 copyString的使用确保了这些串没有共享存储。读者还需注意将 exp的值认为 是$4。这是因为 Yacc认为嵌入动作在文法规则的右边是一个额外的位置——参见上一节的 讨论 )。 在stmt_seq(第4031行到第4039行)的情况中,它的问题是:属指针 (而不是孩子指针 )将 语句在TINY语法树中排在一起。因为人们将语法序列的规则写成左递归的,这也就要求为了 在末尾附上当前的语句,代码应找出早先为左子集构造的属列表。这样做的效率并不高而且我 们还可以通过将规则重写为右递归来避免它,但是这个解决方法也有它自身的问题:只要处理 语句序列,其中的分析栈就会变得很大。 最后, Yacc说明的辅助过程部分 (第4144行到第 4162行)包括了 3个过程—— yyerror、 yylex和parse——的定义。 parse过程是由主程序调用,它将调用 Yacc定义的分析过程 yyparse并且返回保存的语法树。之所以需要 yylex过程是因为 Yacc假设这是扫描程序过程 的名称,而它又在外部被定义为 getToken。写出了这个定义就使得 Yacc生成的分析程序可在 对别的代码文件只作出最小的改变的情况下就可与 TINY编译程序一起工作。有人可能希望对 扫描程序作出恰当的改变并省掉这个定义,特别是在使用扫描程序的 Lex版本时。在出现错误 时,yyerror过程由Yacc调用:它将一定的有用信息 (如行号)打印到列表文件上。它使用的是 188 编译原理及实践 Yacc的内置变量 yychar,yychar包含了引起错误的记号的记号号码。 我们还需要描述对 TINY分析程序中的其他文件的改变,由于使用了 Yacc来产生分析程 序,所以这些改变也是必要的。正如前面已指出的,我们的目标是使这些改变成为最小的, 而且将所有的改变都限制为 globals.h文件的。修改过的文件列在了附录 B的第4200行到第 4320行中。其基本问题是由 Yacc生成的包含了记号定义的头文件必须被包括在大多数的其他 代码文件中,但它又不能被直接包括在 Yacc生成的分析程序中,因为这样做会重复内置的定 义。对上面问题的解决办法是用一个标志 YYPARSER(第4007行)的定义作为 Yacc说明部分的 开头,而 YYPARSER位于Yacc分析程序中且指出 C编译程序何时处于分析程序之中。我们使 用那个标志 (第4 2 2 6 行到第 4 2 3 6 行)有选择地将 Ya c c生成的头文件 y . t a b . h 包括在 g l o b a l s . h 中。 第2个问题出现在ENDFILE记号中,此时扫描程序要指出输入文件的结尾。 Yacc假设这个 记号总是存在着值 0,因此也就提供了这个记号的直接定义 (第4234行)并且将它包括在由 YYPARSER控制的有选择性的编译部分之中,这是因为 Yacc内部并不需要它。 因为所有的Yacc记号都有整型值,所以 globals.h文件最后的改变是将 TokenType重定 义为int的一个同义字 (第4252行)。这样就避免了无必要地替代前面所列的其他文件中的类型 TokenType。 5.7 自底向上分析程序中的错误校正 5.7.1 自底向上分析中的错误检测 当在分析表中检测到一个空 (或错误)项时,自底向上的分析程序将检测错误。尽可能快地 检测到错误显然是有意义的,这样的错误信息可以更有意义且是确定的。因此,分析表应有尽 可能多的空项。 不幸的是,这个目标与一个同等重要的目标相冲突,这就是缩小分析表的大小。我们早已 看到 (在表 5-11中)Yacc尽可能多地用缺省归约来填充表项,所以在声明错误之前,大量的归约 将占据分析栈。这样就会使错误的准确来源不清晰,而且会导致没有意义的错误信息。 自底向上的分析的另一个特征是所使用的特定算法的能力会影响到分析程序是否可早些检 测出错误的能力。例如一个 LR(1)分析程序能够比 LALR(1)分析程序或SLR(1)分析程序更早地 检测出错误来;而在这方面, LALR(1)分析程序和SLR(1)分析程序又比 LR(0)分析程序能力强 一些。例如,对比 LR(0)分析表(表5-4)和LR(1)分析表(表5-10)中的相同文法。假设存在着错误 的输入串( a $,表5-10中的LR(1)分析表就会将 (和a移进到栈中的状态6。由于在状态6中没有 项是位于$之下,所以就报告了一个错误。相反地, LR(0)算法(以及SLR(0)算法)在发现缺少右 括号之前先用A→a 归约。类似地,假设存在着错误串a ) $,则一般的LR(1)分析程序会移进 a, 接着再声明来自右括号上的状态 3的错误;而 LR(0)分析程序在声明错误之前先用 A→a 归约。 当然,任何的自底向上的分析程序总是可能会在若干个“错误的”归约之后最终报告出错误来。 这些分析程序都不会移进错误的记号。 5.7.2 应急方式错误校正 在自底向上的分析中,通过明智地从分析栈或输入或以上这两者中删除符号有可能会适 度地在自底向上的分析程序中得到较好的错误校正。与 LL(1)分析程序相似,它有可能完成 3 种动作: 189 第 5章 自底向上的分析 1) 从栈中弹出一个状态。 2) 在看到可重新开始分析的记号之后,就从输入中成功地弹出记号。 3) 将一个新的状态压入到栈中。 当发生错误时,用来选择以上哪个动作的特别有效的方法如下: 1) 在发现一个带有非空的Goto项的状态之后从分析栈中弹出状态。 2) 若在当前的输入记号上有一个来自 Goto状态的合法动作,则将这个状态压入到栈中,并 重新开始分析。如果存着在若干个这样的状态,则选择移进而不是归约。在归约动作中,则选 择其结合的非终结符为最不一般的那一个。 3) 若在当前输入记号上没有来自 Goto状态的合法动作,推进输入直到有一个合法的动作或 到达了输入的末尾。 这些规则能够强迫一个构造的识别,当错误发生时这个构造位于正被识别的处理中,并由 此立即重新开始分析。使用这些或类似规则的错误校正可被称作应急方式 (panic mode)错误校 正,这是因为它与在 4.5节中描述的自顶向下的应急方式类似。 不幸的是,由于步骤 2将新的状态压入栈中,所以这些规则将会导致一个无穷循环。此时 可有若干个可能的解决办法。其一是在步骤 2上坚持来自一个 Goto状态的移进动作;但是这可 能会有太大的限制。另一个办法是:若下一个合法的移动是归约,则设置一个引起分析程序跟 踪在下面归约中的状态序列的标志;若相同的状态重现时,则直到在错误发生时将初始的状态 删去之后再弹出栈状态,并且再次从步骤 1开始。若在任何时候都发生了移进,则分析程序重 新设置标志并开始正常的分析。 例5.19 在一个简单的算术表达式文法中 (它的Yacc分析表在表 5-11中给出),现在考虑错误的 输入(2+*)。在看到 *之前,分析一直是正常进行。此时,应急方式会导致在分析栈中发生以 下的动作: 分析栈 ... $ 0 ( 6 E 10+7 $ 0 ( 6 E 10+7 T 11 $ 0 ( 6 E 10+7 T 11 * 9 $ 0 ( 6 E 10+7 T 11 * 9 F 13 ... 输入 ... *)$ *)$ )$ )$ ... 动作 ... 错误: 压入T,goto 11 移进9 错误: 压入F,goto 13 用T→T * F 归约 ... 在第1个错误中,分析程序位于状态 7中,它有合法的 Goto状态11和4。由于状态11在下一个输 入记号*上存在着一个移进,而这正是 Goto倾向的,所以就将记号移进。此时分析程序位于状 态9中,在输入中它有一个右括号。而这又是一个错误。在状态 9中,有一个单个的 Goto项(到 状态11),且状态11在)上也确有一个合法的动作 (虽然是一个归约 )。分析接着就正常地继续它 的结论。 5.7.3 Yacc中的错误校正 不用应急方式,我们可以使用称作错误产生式 (error production)的方法。错误产生式就是 190 编译原理及实践 一个包括了伪记号 error作为它的右边的唯一符号的产生式。错误产生式标志着一个上下文, 直到看到恰当的同步记号时,在其中的错误记号才被删除,而此时又可重新开始分析。错误产 生式可有效地允许程序员用手写标记出其 Goto项将被用作错误校正的非终结符。 错误产生式是在Yacc中用于错误恢复的主要方法。当发生错误时, Yacc分析程序的行为以 及它处理错误产生式的行为如下所示。 1) 当分析程序在分析中检测到错误时 (即,它遇到分析表中的一个空项 ),它会从分析栈中 弹出状态直至到达一个其中的 error伪记号是合法的先行的状态。其结果是将输入丢弃到错误 的左边,并将输入看作是包括了 error伪记号。如果没有错误伪记号,则 error永远也不会是 移进的合法先行,而且分析栈也将为空,它使分析在第 1个错误处中断 (这是由程序清单 5-1的 输入 Yacc生成的分析程序行为 )。 2) 一旦分析程序找到了栈上的一个状态,在该状态中的 error就是一个合法的先行,它以 正常的风格继续移进和归约。其结果是好像在输入中看到了 error,它的后面是初始先行 (也 就是导致错误的先行 )。如若需要, Yacc宏yyclearin可被用来丢弃掉引起错误的记号,并将 它随后的记号作为下一个先行 (在error之后)。 3) 如果分析程序在一个错误发生之后发现了更多的错误,则直到将 3个成功的记号合法地 移进到分析栈中之后为止,引起错误的输入记号才会被无声地丢弃掉。此时认为分析程序是位 于一个“错误状态”之中。将这个行为设计用来避免由相同错误引起的错误信息级联。但这样 就会导致在分析程序退出错误状态之前丢掉大量的输入 (同在应急方式中一样 )。编译程序的编 写者可以利用 Yacc宏yyerrok将分析程序从错误状态中删除掉以不考虑这个行为,所以在没 有新的错误校正的情况下就不会丢掉更多的输入了。 根据程序清单 5-1中的 Yacc输入,我们再描述这个行为的几个简单示例。 例5.20 考虑程序清单5-1中command规则的以下替换: command : exp | error ; { printf ("%d\n"", $1);} { yyerror ("incorrect expression");} 再考虑错误的输入 2++3。这个串的分析在到达第 2个+之前一直是正常的。此时的分析是由以 下的分析栈和输入给出的 (虽然错误产生式的加法实际上将会导致分析表的少许变化,但我们 还是用表 5-11来描述它 ): PARSING STACK $0 exp 2 + 7 INPUT +3$ 现在分析程序输入错误“状态” (生成一个诸如“语法错误”的错误信息 ),从栈开始弹出状态 直到发现状态 0。此时,command的错误产生式提供 error为合法的先行,而且将它移进到分 析栈中,并立即归约到 command上,它执行了相结合的动作 (该动作打印出信息“不正确的表 达式”)。最后的状况如下所示: PARSING STACK $0 command 1 INPUT +3$ 此时唯一的合法先行是输入的末尾 (在这里由$指出,它与由yylex返回的0相对应),而且分析 程序在退出之前(但仍在“错误状态”中 )将删除输入记号+3的剩余部分。因此,除了现在能够 提供自己的错误信息之外,错误产生式的加法具有了同程序清单 5-1中版本相同的效果。 一个比这个更好的错误机制允许用户在错误输入之后重新输入行。此时需要一个同步的记 191 第 5章 自底向上的分析 号,而且行标记的末尾是唯一敏感的。因此,扫描程序必须经过修改而返回新行字符 (而不是 0),经过这个修改可以写出 (参见练习 5.32): command : exp ' \n ' { printf (' %d\n" , $1); exit (0);} | error ' \n ' { yyerrok ; printf ( "reenter expression : ");} command ; 这个代码的结果是:当发生错误且当它执行由 yyerrok表示的动作和 printf语句时,分析程 序将跳过所有的记号而到达一个新行。接着它将试图识别出另一个 command。这里需要一个向 yyerrok的调用来在看到新行之后删除“错误状态”;这是因为如若不然,则当新行的右边发 生新的错误时,Yacc在直到发现3个正确的记号序列之后才会无声地删除掉记号。 例5.21 若按以下的表示将一个错误产生式添加到程序清单 5-1的Yacc定义中,那么会出现怎 样的结果呢: factor : NUMBER {$$ = $1;} | '( ' exp ')' {$$ = $2;} | error {$$ = 0;} ; 首先考虑与前例相同的错误输入 2++3。(尽管加法错误产生式使得表发生了略微的改变,但我 们仍然使用表5-11。)分析程序与上述相同,将会到达以下的输入: PARSING STACK $0 exp 2 + 7 INPUT + 3$ 现在factor的错误产生式将提供 error为状态7中的一个合法先行,而且将 error立即移进到栈 中,并归约到引起返回值 0的factor上。现在分析程序已到达了以下的点: PARSING STACK $0 exp 2 + 7 factor 4 INPUT +3$ 这是一个正常的情况,而且分析程序将继续正常到执行的结束。其结果是将输入解释为 2+0+3, 将0放在两个 +符号之间是因为此处是 error伪记号插入的地方,而且通过错误产生式的动作, error被看作是与带有0值的一个因子等价。 现在考虑错误的输入 2 3(即缺少了运算符的两个数字)。分析程序到达了位置 PARSING STACK $0 exp 2 INPUT 3$ 此时(若command的规则并未改变)即使数字并不是command的合法后随符号,分析程序也将 (错 误地)用规则command → exp归约(并打印出值2)。接着,分析程序到达位置 PARSING STACK $0 command 1 INPUT 3$ 现在删除了一个错误,且在揭示状态 0的同时从分析栈中弹出状态 1。此点factor的错误产生式 允许为error成为一个来自状态 0的合法先行,而且 error被移进到分析栈中,并导致打印归 约的另一个级联以及值 0(由错误产生式返回的值 )。现在分析程序又回到了分析的相同位置, 而且数字 3仍在输入中!幸运地是,分析程序早已在“错误状态”中并不再移进 error了,但 192 编译原理及实践 是却扔掉了数字3,它揭示了状态1的正确先行,因此分析程序仍然存在 。其结果是分析程序 按如下所示打印 (在第1行中重复用户的输入): >23 2 syntax error incorrect expression 0 该行为大致给出了 Yacc中良好的错误恢复的困难 (参见练习中的更多练习 )。 5.7.4 TINY中的错误校正 附录B中的Yacc说明文件 tiny.y包括了两个错误产生式,一个是 stmt的(第4047行),另 一个是 factor的(第4139行),与之相关的动作将返回空的语法树。除了它不试图在错误中建 立重要的语法树之外,这些错误产生式提供一个与上一章中的递归下降 TINY分析程序中相似 的错误处理级别。这些错误产生式还不能为分析的重新开始提供同步,所以在多重错误中,会 跳过多个记号。 练习 5.1 考虑以下的文法: E→(L)|a L→L,E|E a. 为这个文法构造 LR(0)项目的DFA。 b. 构造SLR(1)分析表。 c. 显示分析栈和输入串 ((a), a, (a,a)) 的SLR(1)分析程序的动作。 d. 这个文法是不是 LR(0)文法?若不是,请描述出 LR(0)冲突。如果是,则构造 LR(0) 分析表,并描述一个分析如何可以与 SLR(1)分析不同。 5.2 考虑上面练习中的文法 a. 为这个文法构造 LR(1)项目的DFA。 b. 构造一般的LR(1)分析表。 c. 为这个文法构造LALR(1)项目的DFA。 d. 构造LALR(1)分析表。 e. 描述任何可能出现在一般的 LR(1)分析程序的动作和 LALR(1)分析程序的动作之间 的区别。 5.3 考虑以下的文法: A→ A ( A ) | a. 为这个文法构造 LR(0)项目的DFA。 b. 构造SLR(1)分析表。 Yacc的一些版本在删除任何输入之前先再次弹出分析栈。这样会导致更复杂的行为。参见练习。 193 第 5章 自底向上的分析 c. 显示分析栈和输入串 (()()) 的SLR(1)分析程序的动作。 d. 这个文法是不是 LR(0)文法?如果不是,请描述出 LR(0)冲突。如果是,则构造 LR(0)分析表,并描述一个分析如何与 SLR(1)分析相区别。 5.4 考虑上一个练习的文法 a. 为这个文法构造LR(1)项目的DFA。 b. 构造一般的LR(1)分析表。 c. 为这个文法构造LALR(1)项目的DFA。 d. 构造LALR(1)分析表。 e. 描述任何可能出现在一般的 LR(1)分析程序的动作和 LALR(1)分析程序的动作之间 的区别。 5.5 考虑简化了的语句序列的以下文法: stmt-sequence → stmt-sequence ; stmt | stmt stmt → s a. 为这个文法构造LR(0)项目的DFA。 b. 构造SLR(1)分析表。 c. 显示分析栈和输入串 s ; s; s 的SLR(1)分析程序的动作。 d. 这个文法是不是 LR(0)文法?如果不是,请描述出 LR(0)冲突。如果是,则构造 LR(0)分析表,并描述一个分析如何与一个 SLR(1)分析相区别。 5.6 考虑上一个练习的文法 a. 为这个文法构造LR(0)项目的DFA。 b. 构造一般的LR(1)分析表。 c. 为这个文法构造LALR(1)项目的DFA。 d. 构造LALR(1)分析表。 e. 描述任何可能出现在一般的 LR(1)分析程序的动作和 LALR(1)分析程序的动作之间 的区别。 5.7 考虑以下的文法: E→(L)|a L→EL|E a. 为这个文法构造LR(0)项目的DFA。 b. 构造SLR(1)分析表。 c. 显示分析栈和输入串 ((a)a(a a)) 的SLR(1)分析程序的动作。 d. 通过在LR(0)项目的DFA中传送先行来构造LALR(1)项目的DFA。 e. 构造LALR(1)分析表。 5.8 考虑以下的文法 194 编译原理及实践 declaration → type var-list type → int | float var-list → identifier, var-list | identifier a. 将它用一个更适于自底向上分析的格式重写一次。 b. 为重写的文法构造LR(0)项目的DFA。 c. 为重写的文法构造 SLR(1)分析表。 d. 利用c部分的表为输入串int x, y, z显示分析栈和SLR(1)分析程序的动作。 e. 通过在b部分中的LR(0)项目的DFA中传送先行来构造LALR(1)项目的DFA。 f. 为重写的文法构造LALR(1)分析表。 5.9 为通过在 LR(0)项目的 DFA中传送先行而构造 LALR(1)项目的DFA写出算法的正式描 述(在5.4.3节中已经非正式地描述过这个算法了 )。 5.10 本章显示的所有分析栈都包括了状态数和文法符号 (为了清楚起见 ),但是分析栈仅 需要储存状态数即可——无需将记号和非终结符存放在栈中。若只将状态数保存在 栈中,请描述出 SLR(1)分析算法。 5.11 a. 说明以下的文法不是 LR(1)文法: A→ a A a | b. 这个文法有二义性吗?为什么? 5.12 说明以下的文法是 LR(1)文法但不是LALR(1)文法: S→aAd|bBd|aBe|bAe A→ c B→ c 5.13 说明不是LALR(1)文法的LR(1)文法只能有归约-归约冲突。 5.14 说明当且仅当一个右句子格式的前缀不会扩展到句柄之外时,它才能是一个变量 前缀。 5.15 有没有一个SLR(1)文法不是LALR(1)文法的?为什么? 5.16 说明如果不能最终地将下一个输入记号移进,则一般的 LR(1)分析程序在声明一个错 误之前不会归约。 5.17 SLR(1)分析程序会不会在声明错误之前实现的归约比 LALR(1)分析程序实现的少? 请解释原因。 5.18 以下的有二义性的文法生成了与练习 5.3中的文法相同的串 (即:嵌套的括号的所 有串): A→ AA | ( A ) | 由Yacc生成的分析程序将使用这个文法识别所有的合法串吗?为什么? 5.19 假设有在其中有两个可能的归约 (在不同的先行上 )的状态, Yacc将选择其中的一个 归约作为它的缺省动作。请描述出 Yacc在作出这个选择时所使用的规则 (提示:使用 练习 5.16中的文法作为一个测试情况 )。 5.20 假设从程序清单 5-5的Yacc说明中删除了算符的结合性和优先权的指定 (因此就剩下 了一个有二义性的文法 )。请描述出 Yacc缺省的消除二义性的规则产生了怎样的结合 性和优先权。 5.21 同例5.21中脚注所描述的一样,有一些 Yacc分析程序在丢弃任何位于“错误状态” 195 第 5章 自底向上的分析 之中的输入之前会再次弹出分析栈。假设错误输入是 2 3,则请描述出例 5.21中的 Yacc说明对这样的分析程序的行为。 5.22 a. 利用分析表5-11和串(*2描绘出如5.7.1节所描述的应急方式错误校正机制。 b. 对a部分中的行为建议一个改善办法。 5.23 假设程序清单 5-1的Yacc计算器说明中的 command规则是由一个list开始的非终结 符替换的: list : | | ; list ' \n' { exit (0);} list exp ' \n' {printf ("%d\n", $2);} l i s t e r r o r ' \ n ' { y y e;r r}o k 且这个图中的 yylex过程将行 i f ( c == ' \ n ' ) r e t u r n 0 ; 删除掉了。 a. 解释简单计算器的这个版本的行为与程序清单 5-1中版本的行为之间的差别。 b. 解释这个规则最后一行的 yyerrok的原因。给出一个显示若它不在这里的情况的 示例。 5.24 a. 假设程序清单5-1中的Yacc计算器说明中的 command的规则被以下的规则 command : exp error { printf (" %d\n:, $1);} 替代了,则若输入为 2 3,请准确地解释出 Yacc分析程序的行为。 b. 假设程序清单5-1中的Yacc计算器的说明的 command的规则被以下的规则 command : error exp {printf (" %d\n", $2);} 替代了,则若输入为 2 3,请准确地解释出 Yacc分析程序的行为。 5.25 假设例5.21中的Yacc错误产生式被以下的规则 factor : NUMBER {$$ = $1;} | ' (' exp ')' {$$ = $2;} | error {yyerrok; $$ = 0;} ; 替换了。 a. 解释在错误的输入2++3中,Yacc分析程序中的行为。 b. 解释在错误的输入 2 3中,Yacc分析程序中的行为。 5.26 利用4.5.3节中的测试程序对比由 Yacc生成的TINY分析程序的错误校正和第 4章中的 递归下降分析程序。解释二者行为中的不同之处。 编程练习 5.27 重写程序清单 5-1中的Yacc说明以使用以下的文法规则 (而不是 scanf)计算出一个数 的值(以及,因此舍弃掉NUMBER记号): number → number digit | digit digit→0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 5.28 将以下添加到程序清单 5-1中的Yacc整型计算说明中 (确保它们具有正确的结合性和 优先权): a. 带有符号/的除法。 196 编译原理及实践 b. 带有符号%的整型模。 c. 带有符号^的整型求幂(警告:这个算符的优先权比乘法的高,且为右结合 )。 d. 带有符号-的一目减。 5.29 将程序清单 5-1中的Yacc计算器说明重新改写以使计算器会接受浮点数 (并执行浮点 计算)。 5.30 重写程序清单 5-1中的Yacc计算说明以使其可区别浮点值与整型值,而不是仅仅将任 何东西都作为整型数或浮点数来计算。 (提示:“值”现在是一个带有指出它是整型 还是浮点的标志 )。 5.31 a. 将程序清单 5-1中的Yacc计算器说明重写以使它根据 3.3.2节中的说明会返回一个语 法树。 b. 写出一个函数,由 a部分中的代码生成的语法树作为参数并返回由遍历语法树计 算出的值。 5.32 为例5.20中的计算器程序建议的简单错误校正技术有一个缺点:它在许多错误之后 可导致栈满溢。请改写它以解决这个问题。 5.33 重写程序清单5-1中的Yacc计算器说明以添加以下的有用的错误信息: 由串(2 +3 生成的“丢失右括号” 由串2+3)生成的“丢失左括号” 由串2 3生成的“丢失算符” 由串(2+)生成的“丢失操作数” 5.34 以下的文法表示在一个类似于 LISP的前缀表示法中的简单的算术表达式: lexp → number | ( op lexp-seq ) op → + | - | * lexp-seq → lexp-seq lexp | lexp 例如,表达式(* (- 2 ) 3 4具) 有值-24。为一个在这个语法中计算并打印表达式的 值的程序写出一个 Yacc说明(提示:这要求重写文法以及使用将算符分析为一个 lexpseq的机制)。 5.35 以下的文法代表了第2章曾讨论过的正则表达式: rexp → rexp " | " rexp | rexp rexp | rexp" * " | " ("rexp") " | letter a. 写出一个表达该运算的正确结合性和优先权的大致的 Yacc说明(即:没有动作)。 b. 将a部分中的说明扩展到包括产生一个“正则表达式编译程序”的所有动作和辅 助过程。也就是说,一个在编译时将正则表达式作为 C程序的输入和输出的程序 为第一次遇到的匹配正则表达式的子集搜索一个输入串 (提示:可将表或两维数组 代表状态和相关的 NFA的转换。接着可利用一个列来储存状态来模拟 NFA。只有 表才需被Yacc动作生成:剩余的代码将总是相同的,参见第 2章)。 5.36 将TINY的Yacc说明(附录B的第4000行到第4162行)用以下之一的方法在更简洁的格 式中重写一次: 197 第 5章 自底向上的分析 a. 通过为表达式使用有二义性的文法(和 Yacc对于优先权和结合性带有消除二义性 的规则) b. 通过将算符的识别成一个单一的规则,如在 exp → exp op term | . . . op → + | - | . . . 以及通过使用Y acc的%union声明(允许op返回算符,但 exp和其他的非终结符却返 回指向树节点的指针 )。确保你的分析程序产生了与前面相同的语法树。 5.37 将比较算符<= (小于或等于)、> (大于)、>= (大于或等到于),和<> (不等到于)添 加到TINY分析程序中的 Yacc说明(这将要求添加这些记号并改变扫描程序,却不要 求改变语法树)。 5.38 将布尔算符and、or和not添加到TINY分析程序的Yacc说明中。假设它们具有练习 3.5中描述的特性以及比所有的算术算符都低的优先权,确保任何表达式都可为布尔 或整型。 5.39 重写TINY分析程序的Yacc说明以使其可改进它的错误校正。 注意与参考 一般的LR是由Knuth [1965]发明的,但人们直到SLR和LALR技术被DeRemer [1969, 1971] 开发出来之前一直都是认为它是不实际的。我们已重复了 LR(1)分析程序对于实际应用非常复 杂的惯例。实际上,利用比 LALR(1)分析的技术更精致的状态结合技术可建立实际的 LR(1)分 析程序[ Pager, 1977]。然而却很少用到附加的能力。在 Aho 和 Ullman [1972]中可找到有关 LR 分析技术理论的完整研究。 Yacc是在20世纪70年代由Steve Johnson在AT&T的贝尔实验室开发并包括在大多数的 Unix 实践中[Johnson, 1975]。它被用来开发便捷的C编译程序 [Johnson,1978]以及其他许多编译程序。 Bison是由Richard Stallman及其他人一同开发的; Gnu Bison是Free Software Foundation的Gnu 软件分配的一部分且可在许多 Internet站点上得到。Yacc用法的一个示例是在 Kernighan 和Pike [1984]中开发的有用但简洁的计算器程序。在 Schreiner 和 Friedman [1985]中可找到有关Yacc用 法的完整研究。 LR错误校正技术是Graham、Haley 和 Joy [1979]、Penello 和 DeRemer [1978]和Burke 和 Fisher [1987]中的研究内容。在 Fischer 和 LeBlanc [1991]中描述了一个LR错误修正技术。5.7.2 节中描述的应急方式技术由Fischer 和 LeBlanc归结于James [1972]中。 第6章 语 义 分 析 • 属性和属性文法 • 属性计算算法 • 符号表 本章要点 • 数据类型和类型检查 • TINY语言的语义分析 本章要研究的编译程序阶段是计算编译过程所需的附加信息。这个阶段称作语义分析, 因为它包括计算上下文无关文法和标准分析算法以外的信息,因此,它不被看成是语法 。信 息的计算也与被翻译过程的最终含义或语义密切相关。因为编译器完成的分析是静态 (它在执 行之前发生 )定义的,这样,语义分析也可称作静态语义分析 (static semantic analysis)。在一 个典型的静态类型的语言 (如C语言)中,语义分析包括构造符号表、记录声明中建立的名字的 含义、在表达式和语句中进行类型推断和类型检查以及在语言的类型规则作用域内判断它们 的正确性。 语义分析可以分为两类。第 1类是程序的分析,要求根据编程语言的规则建立其正确性, 并保证其正确执行。对于不同的语言来说,语言定义所要求的这一类分析的总量变化很大。在 LISP和Smalltalk这类动态制导的语言中,可能完全没有静态语义分析;而在 Ada这类语言中就 有很强的需求,程序必须提交执行。其他的语言介于这两种极端情况之间 (例如Pascal语言,不 像Ada和C对静态语义分析的要求那样严格,也不像 LISP那样完全没有要求 )。 语义分析的第 2类是由编译程序执行的分析,用以提高翻译程序执行的效率。这一类分析 通常包括对“最优化”或代码改进技术的讨论。第 8章“代码生成”中将研究一些这样的方法, 而本章则集中讨论语言定义对正确性要求的一般分析。读者应该注意到,这里研究的技术对两 类情况都适用。这两类分析也不是相互排斥的,因为与没有正确性要求的语言相比,如静态类 型检查这样的正确性要求能使编译程序产生更加有效的代码。另外,值得注意的是,这里讨论 的正确性要求永远不能建立程序的完全正确性,正确性仅仅是部分的。但这样的要求仍然是有 用的,可以给编程人员提供一些信息,提高程序的安全性和有效性。 静态语义分析包括执行分析的描述 (description)和使用合适的算法对分析的实现 ( i m p l e m e n t a t i o n )。在这里,它和词法及语法分析相类似。例如,在语法分析中,我们使用 Backus-Naus范式(BNF)中的上下文无关文法描述语法结构,并用各种自顶向下和自底向上的分 析算法实现语法结构。在语义分析中,情形不是那么清晰,其部分原因是没有用标准的方法 (如BNF)来说明语言的静态语义;另一个原因是对于各种语言,静态语义分析的种类和总量的 变化范围很大。编译程序编写者过去常用的且实现得很好的一种描述语义分析方法是:确定语 言实体的属性(attribute)或特性,它们必须进行计算并写成属性等式 (attribute equation)或语义规 则(semantic rule),并描述这些属性的计算如何与语言的文法规则相关。这样的一组属性和等 式称作属性文法 (attribute grammar)。属性文法对遵循语法制导语义 (syntax-directed semantic)原 这一点在第 3章的3.6.3节进行了较为详细的讨论。 199 第 6章 语 义 分 析 理的语言最有用,它表明程序的语义内容与它的语法密切相关。所有的现代语言都有这个特性。 然而,编译程序的编写者通常必须根据语言手册手工构造属性文法,因为语言设计者很少为之 提供。更糟糕的是,由于坚持语言清晰的语法结构,属性文法的构造会有不必要的复杂性。语 义计算表达式的一种更好标准是抽象语法,就像抽象语法树表示的那样。但是抽象语法树的说 明通常也由语言设计者留给了编译程序编写者。 语义分析实现的算法也不像语法分析算法那样能清晰地表达。这部分原因也是因为在考虑 语义分析说明时,出现了刚刚提及的同样的问题。还有另外一个问题,它是由编译过程中分析 的时间选择引起的。如果语义分析可以推迟到所有的语法分析 (以及抽象语法树的构造 )完成之 后进行,那么实现语义分析的任务就相当容易,其本质上由指定对语法树遍历的一个顺序组成, 同时在遍历中每次遇到节点时进行计算。这就意味着编译程序必须是多遍的。另一方面,如果 必须要求编译程序在一遍中完成所有的操作 (包括代码生成),那么语义分析的实现就更加会变 成寻找计算语义信息的正确顺序和方法的特别的过程 (假定这样的顺序实际存在 )。当然,现代 的惯例越来越允许编译程序编写者使用多遍扫描简化语义分析和代码生成的过程。 尽管有点扰乱语义分析的状态,研究属性文法和规范发布仍是特别有用的,因为这能从写 出更加清晰、简练、不易出错的语义分析代码中得到补偿,同时代码也更加易懂。 因此,本章从研究属性和属性文法开始。接下来是通过属性文法说明实现计算的技术,包 括推断与树的遍历相连的计算顺序。随后的两节集中于语义分析的两个主要方面:符号表和类 型检查。最后一节讲述前一章介绍的 TINY编程语言的语义分析程序。 与第5章不同,本章没有包含对语义分析程序生成器 (semantic analyzer generator)或构造语 义分析程序的通用工具的描述。尽管已经构造了许多这样的工具,但没有一个能得到广泛的使 用且能用于 Lex或Yacc。在本章最后的“注意与参考”一节中,我们提及了几个这样的工具, 并为感兴趣的读者提供了参考文献。 6.1 属性和属性文法 属性(attribute)是编程语言结构的任意特性。属性在其包含的信息和复杂性等方面变化很大, 特别是当它们能确定时翻译 /执行过程的时间。属性的典型例子有: • 变量的数据类型。 • 表达式的值。 • 存储器中变量的位置。 • 程序的目标代码。 • 数的有效位数。 可以在复杂的处理 (甚至编译程序的构造 )之前确定属性。例如,一个数的有效位数可以根 据语言的定义确定 (或者至少给出一个最小值 )。属性也可以在程序执行期间才确定,如 (非常 数)表达式的值,或者动态分配的数据结构的位置。属性的计算及将计算值与正在讨论的语言 结构联系的过程称作属性的联编 (binding)。联编属性发生时编译 /执行过程的时间称作联编时间 (binding time)。不同的属性变化,甚至不同语言的相同属性都可能有完全不同的联编时间。在 执行之前联编的属性称作静态的 (static),而只在执行期间联编的属性是动态的 (dynamic)。对于 编译程序编写者而言,当然对那些在翻译时联编的动态属性感兴趣。 考虑先前给出的属性表的示例。我们讨论表中每个属性在编译时的联编时间和重要性。 • 在如C或Pascal这样的静态类型的语言中,变量或表达式的数据类型是一个重要的编译时 属性。类型检查器 (type checker)是一个语义分析程序,它计算定义数据类型的所有语言 200 编译原理及实践 实体的数据类型属性,并验证这些类型符合语言的类型规则。在如 C或Pascal这样的语言 中,类型检查是语义分析的一个重要部分。而在 LISP这样的语言中,数据类型是动态的, L I S P编译程序必须生成代码来计算类型,并在程序执行期间完成类型检查。 • 表达式的值通常是动态的,编译程序要在执行时生成代码来计算这些值。然而事实上, 一些表达式可能是常量 (例如3+4*5),语义分析程序可以选择在编译时求出它们的值 (这 个过程称作常量合并(constant folding))。 • 变量的分配可以是静态的也可以是动态的,这依赖于语言和变量自身的特性。例如,在 FORTRAN77中所有的变量都是静态分配的,而在 LISP中所有的变量是动态分配的。 C和 Pascal语言混合了静态和动态的两种变量分配。因为编译程序与变量分配联系的计算依 赖于运行时环境,有时还依赖于具体的目标机器,所以这些计算会一直推迟到代码生成 (第7章将更详细地探讨这一问题 )。 • 程序的目标代码无疑是一个静态属性。编译程序的代码生成器专门用于这个属性的计算。 • 数的有效位数在编译期间是一个不被明确探讨的属性。编译程序编写者实现值的表示是 不言而喻的,这通常被视为运行时环境的一部分,这将在第 7章中讨论。然而,甚至扫描 器也需要知道允许的有效位数并判断是否正确转换了常量。 正如从这些例子中看到的,属性计算变化极大。当它们在编译程序中显式出现时,可能在 编译过程的任意时刻发生:即使语义分析阶段与属性计算的联系最紧密,扫描器和语法分析程 序也都需要对它们有用的属性信息,在语法分析的同时也需要进行一些语义分析。在本章中, 我们集中讨论在代码生成前及语法分析后的典型计算 (语法分析期间的语义分析信息见 6.2.5 节)。直接应用于代码生成的属性分析在第 8章中讨论。 6.1.1 属性文法 在语法制导语义(syntax-directed semantics)中,属性直接与语言的文法符号相联系 (终结符 号或非终结符号 ) 。如果X是一个文法符号, a 是X的一个属性,那么我们把与 X关联的a的值 记作X.a。这个记号让人回忆起 Pascal语言的记录字段表示符或 (等价于)C语言中的结构成员操 作符。实际上,实现属性计算的一种典型方法是使用记录字段 (或结构成员)将属性值放到语法 树的节点中去。下一节将更详细地讨论这一点。 若有一个属性的集合a , . . . , a ,语法制导语义的原理应用于每个文法规则 X →X X . . . Xn 1 k 0 12 (这里X 是一个非终结符号,其他的 X 都是任意符号 ),每个文法符号X 的属性X .a 的值与规则 0 i i ij 中其他符号的属性值有关。如果同一个符号 X 在文法规则中出现不止一次,那么每次必须用合 i 适的下标与在其他地方出现的符号区分开来。每个关系用属性等式 (attribute equation)或语义规 则(semantics rule) 表示,形式如下 X .a = f (X .a , . . . , X .a ,X .a , . . . , X .a , . . . , X .a , . . . , X .a ) ij ij 0 1 0k 11 1k n1 nk 这里的f 是一个数学函数。属性a , . . . , a 的属性文法(attribute grammar)是对语言的所有文法 ij 1 k 规则的所有这类等式的集合。 在这个一般性情况中,属性文法显得相当复杂。在实际情况下,函数 f 通常非常简单。属 ij 性也很少依赖于大量的其他属性,因此可以将相互依赖的属性分割为较小的独立属性集,并对 语法制导语义可以简单地称作语义制导语法 (semantics-directed syntax),因为在大多数语言中语法是用已经 在头脑中建立的 (最终)语义设计的。 后面我们将看到语义规则比属性等式更加通用一些。这里读者可以先把它们看成是等效的。 201 第 6章 语 义 分 析 每个属性集单独写出一个属性文法。 一般地,将属性文法写成表格形式,每个文法规则用属性等式的集合或者相应规则的语义 规则列出,如下所示 : 文法规则 规则1 . . . 规则n 语义规则 相关的属性等式 . . . 相关的属性等式 接下来继续看几个例子。 例6.1 考虑下面简单的无符号数文法: number → number digit | digit digit →0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 一个数最重要的属性是它的值,我们将其命名为 val。每个数字都有一个值,可以用它表示的 实际数直接计算。因此,例如,文法规则 digit→0 表明在这个情况下 digit 的值为0。这可以用 属性等式digit.val = 0 表示,我们将这个等式和规则 digit→0 联系在一起。此外,每个数都有一 个基于它所包含的数字的值。如果一个数使用了下面的规则推导 number → digit 那么这个数就只包含了一个数字,其值就是这个数字的值。用属性等式表示为 number.val = digit.val 如果一个数包含的数字多于 1个,可以使用下列文法规则推导 number → number digit 我们必须表示出这个文法规则左边符号的值和右边符号的值之间的关系。请读者注意,在这个 文法规则中对number 的两次出现必须进行区分,因为右边的 number 和左边的number的值不相 同。我们使用下标进行区分,将这个文法写成如下形式: number →number digit 1 2 现在考虑一个数,如34。这个数的(最左)推导如下:number ⇒ number digit ⇒ digit digit ⇒ 3 digit ⇒ 34。考虑在这个推导的第一步文法规则 number →number digit 的使用。非终结符号 1 2 number 对应于数字 3,digit 对应于数字4。每个符号的值分别是 3和4。为了获得number 的值 2 1 (它为34),必须用number 的值乘以10,再加上digit 的值:34 = 3*10+4。换句话说,是把 3左 2 移一个十进制位再加上 4。相应的属性等式是 number .val = number .val * 10 + digit.val 1 2 完整的val 属性文法在表6-1中给出。 使用字符串的语法树可以形象化地表示特殊字符串的属性等式的意义。例如,图 6-1给出 了数345的语法树。在这个图中相应合适的属性等式的计算显示在每个内部节点的下面。在语 这些表中通常用表头“语义规则”代替“属性等式”,以便后面对语义规则进行更通用的解释。 202 编译原理及实践 法树中计算时观察属性等式对于计算属性值的算法是重要的,就像在下一节将看到的那样 。 在表 6-1和图 6-1中,通过使用不同的字体,我们强调了数字和值的语法表示或者数字语义 内容之间的不同。例如在文法规则 digit→0 中,数字 0是一个记号或特性,而 digit.val = 0的含 义是数字的数值为0。 表6-1 例6.1的属性文法 文法规则 Number → number digit 1 2 Number → digit digit → 0 digit → 1 digit → 2 digit → 3 digit → 4 digit → 5 digit → 6 digit → 7 digit → 8 digit → 9 语义规则 number .val = number .val * 10 + digit.val 1 2 number.val = digit.val digit.val = 0 digit.val = 1 digit.val = 2 digit.val = 3 digit.val = 4 digit.val = 5 digit.val = 6 digit.val = 7 digit.val = 8 digit.val = 9 图6-1 显示例6.1中属性计算的语法树 例6.2 考虑下列简单的整数算术表达式文法: exp → exp + term | exp - term | term term → term * factor | factor factor →(exp)| number 这个文法对第 5章广泛讨论的简单表达式文法稍微作了一些改动。 exp(或term或factor)的基本属 性是它的数字值,写作val。val 属性的属性等式在表6-2中给出。 事实上,扫描器通常认为数是标记,它们的数值也很容易计算。在此期间,扫描器很可能隐含地使用这里定 义的属性等式。 203 第 6章 语 义 分 析 表6-2 例6.2的属性文法 文法规则 exp → exp + term 1 2 exp → exp - term 1 2 exp → term term → term * factor 1 2 term → factor factor → (exp) factor → number 语义规则 exp .val = exp .val + term.val 1 2 exp .val = exp .val - term.val 1 2 exp.val = term.val term .val = term .val * factor.val 1 2 term.val = factor.val factor.val = exp.val factor.val = number.val 这些等式表示了表达式的语法和它所进行的算术运算的语义之间的关系。注意,在文法 规则 中语义符号 +(记号)和等式 exp → exp + term 1 2 exp .val = exp .val + term.val 1 2 中执行的算术加运算符 + 的不同。还要注意 number.val不会在等式的左边。就像在下一节将看 到的,这意味着必须在任意一个使用这个属性文法 (例如扫描器 )的语义分析之前计算 number.val。换句话说,如果想在属性文法中明确这个值,就必须在属性文法中加进文法规 则和属性等式(例如,例6.1中的等式)。 如例6.1一样,也可以通过在语法树的节点上附加等式来表示属性文法包含的计算。例如, 给定表达式(34-3)*42,可以用在其语法树上值的语义来表达,如图 6-2所示。 图6-2 (34-3)*42 的语法树,显示例 6.2中属性文法的val属性计算 204 编译原理及实践 例6.3 考虑下列类似C语法中变量声明的简单文法: decl → type var -list type → int | float var-list → id, var-list | id 我们要为在声明中标识符给出的变量定义一个数据类型属性,并写出一个等式来表示数据 类型属性是如何与声明的类型相关的。通过构造 dtype属性的一个属性文法可以实现这一点 (使 用名字dtype和非终结符type的属性进行区分 )。dtype的属性文法在表6-3中给出。在图中关于属 性等式我们做了以下标记。 首先,从{integer , real}集合中得出dtype的值,相应的记号为int和float。非终结符type 有一个它表示的记号给定的 dtype。通过decl文法规则的等式,这个 dtype对应于全体 var-list的 dtype。通过 var-list的等式,表中的每个 id都有相同的 dtype。注意,没有等式包含非终结符 decl 的dtype。实际上decl并不需要dtype,一个属性的值没有必要为所有的文法符号指定。 同前面一样,可以在一个语法树上显示属性等式。图 6-3给出了一个例子。 表6-3 例6.3的属性文法 文法规则 decl → type var-list type → int type → float var-list → id,var-list 1 2 var-list → id 语义规则 var-list.dtype = type.dtype type.dtype = integer type.dtype = real id.dtype = var-list .dtype 1 var-list .dtype = var-list .dtype 2 1 id.dtype = var-list.dtype 图6-3 字符串float x,y的语法树,显示表 6-3中属性文法指定的 dtype属性 到目前为止,所有的例子都只有一个属性,但属性文法可能会包含几个独立的属性。下一 个例子是一个有几个相互联系属性的简单情形。 例6.4 考虑对例6.1中数文法进行修改,数可以是八进制或十进制的。假设这通过一个字符的 后缀o (八进制)或d (十进制)来表示。这样就有下面的文法: based-num → num basechar basechar → o | d num → num digit | digit digit → 0 |1 | 2 | 3| 4 | 5 |6 | 7 | 8| 9 205 第 6章 语 义 分 析 在这种情况中,num和digit均需要一个新的属性base用来计算属性 val。base和val的属性文法在 表6-4中给出。 表6-4 例6.4的属性文法 文法规则 based-num → num basechar basechar → o basechar → d num →num digit 1 2 num → digit digit →0 digit →1 ... digit →7 digit →8 digit →9 语义规则 Based-num.val = num.val num.base = basechar.base basechar.base = 8 basechar.base = 10 num .val = 1 if digit.val = error or num .val = error 2 then error else num .val * num .base + digit.val 2 1 num .base = num .base 2 1 digit.base = num .base 1 num.val = digit.val digit.base = num.base digit.val = 0 digit.val = 1 ... digit.val = 7 digit.val = if digit.base = 8 then error else 8 digit.val = if digit.base = 8 then error else 9 在这个属性文法中必须注意两个新的特性。首先,这个 BNF文法在后缀为 o时自己不能排 除错误的 (非八进制 )数字8和9的组合。例如,按照上面的 BNF文法,字符串 189o在语法上是 图6-4 例6.4中属性计算的语法树 206 编译原理及实践 正确的,但不能赋予任何值。因此,对于这些情况需要一个新的 error值。另外,这个属性文法 必须能表示这样的事实,一个带有后缀 o的数中包括数字 8或9时将导致值为 error。最简单的方 法是在合适的属性等式函数中使用 if-then-else表达式。例如,等式 num .val = 1 if digit.val = error or num .val = error 2 then error else num .val * num .base + digit.val 2 1 对应于文法规则 num →num digit,表示如果num .val或digit.val为error,那么num .val也必须为 1 2 2 1 error,除此之外只有一种情况: num .val的值由公式num .val * num .base + digit.val给出。 1 2 1 总结这个例子,再在一个语法树上表示属性计算。图 6-4给出了数 345o的语法树,同时根 据表6-4的属性文法计算出属性值。 6.1.2 属性文法的简化和扩充 if-then-else表达式的使用扩充了表达式的种类,它们可以通过有用的途径出现在属性等式 中,在属性等式中,允许出现的表达式的集合称作属性文法的元语言 (metalanguage)。通常我 们希望元语言的内涵尽可能清晰,不致于引起其自身语义的混淆。我们还希望元语言接近于一 种实际使用的编程语言,因为就像我们即将看到的一样,在语义分析程序中需要把属性等式转 换成执行代码。在本书中,我们使用的元语言局限于算术式、逻辑式以及一些其他种类的表达 式,再加上 if-then-else表达式,偶尔还有 case或switch表达式。 定义属性等式的另一个有用的特征是在元语言中加入了函数的使用,函数的定义可以在别 处给出。例如,在数的文法中,我们对 digit 的每个选择都写出了属性等式。可以采用一个更简 洁的惯例来代替,为digit 写一个文法规则digit →D (这里D是数字中的一个),相应的属性等式是 digit.val = numval (D) 这里numval是函数,必须在别处说明其作为属性文法的补充的定义。例如,我们可以用 C代码 给出numval的定义: int numval ( char D) { return (int)D-(int)'0';} 在说明属性文法时更简化的有用方法是使用原始文法二义性的但简单的形式。事实上,因 为假定已经构造了分析程序,所有二义性在那个阶段都已经处理过了,属性文法可以自由地基 于二义性概念,而无须在结果属性中说明任何二义性,例如,例 6.2的算术表达式文法有以下 简单的但二义性的形式: exp → exp + exp | exp - exp | exp * exp | ( exp ) | number 使用这个文法,属性 val可以通过表 6-5 中的表定义 (与表 6-2相比较 )。 表6-5 使用二义性文法定义表达式的 val属性 文法规则 exp → exp + exp 1 2 3 exp → exp - exp 1 2 3 exp → exp * exp 1 2 3 exp → ( exp ) 1 2 exp → number 语义规则 exp .val = exp .val + exp .val 1 2 3 exp .val = exp .val - exp .val 1 2 3 exp .val = exp .val * exp .val 1 2 3 exp .val = exp .val 1 2 exp.val = number.val 207 第 6章 语 义 分 析 在显示属性值时,也可以通过使用抽象语法树代替分析树进行简化。抽象语法树通常必须 用足够的结构来表达属性文法定义的语义。例如表达式 (34-3)*42,其分析树和val属性在图 6-2中已给出,通过图 6-5的抽象语法树也能完全表达它的语义。 如下一个例子所示,语法树自身也可以用属性文法指定,这并不奇怪。 图6-5 (34-3)*42 的抽象语法树,显示表 6-2或表6-5中属性文法的val属性计算 例6.5 给定例6.2的简单整数表达式文法,通过表 6-6给出的属性文法可以定义表达式的抽象语 法树。在这个属性文法中,我们使用了两个辅助函数 mkOpNode和mkNumNode。mkOpNode函 数有3个参数(一个操作符记号和两个语法树 )并构成一个新的树节点,其操作符记号是第 1个参 数,子节点是第 2个和第3个参数。 mkNumNode函数有一个参数 (数字值)并构成一个叶子节点, 它表示具有这个值的一个数。在表 6-6中我们把数字值写作 number.lexval,表示它是由扫描器 构成的。事实上,根据不同的实现,这可以是实际的数字值,或用它的字符串表示 (比较表6-6 中的等式和附录B中TINY语法树递归下降构造)。 表6-6 简单整型算术表达式的抽象语法树的属性文法 文法规则 exp → exp + term 1 2 exp → exp - term 1 2 exp → term term → term * factor 1 2 term → factor factor → ( exp ) factor → number 语义规则 exp .tree = 1 mkOpNode (+, exp .tree, term. tree) 2 exp .tree = 1 mkOpNode (-, exp .tree,term. tree) 2 exp.tree = term.tree term .tree = 1 mkOpNode (*, term .tree,factor .tree) 2 term.tree = factor. tree factor.tree = exP. tree factor.tree = mkNumNode (number.lexval) 如何确保一个特定的属性文法是一致的和完整的,是使用属性文法的属性说明的一个主要 问题,也就是说,它能唯一定义给定的属性。简单的答案是到目前为止还不能做到。这个问题 与确定一个文法是否是二义的类似。实际上,这是用来确定一个文法充分性的分析算法,类似 的情形发生在属性文法的情况中。因此,下一节将研究属性计算的算法规则方法,确定一个属 性文法是否足够能定义属性值。 6.2 属性计算算法 这一节将以属性文法为基础,研究编译程序中如何计算和使用由属性文法的等式定义的属 208 编译原理及实践 性。基本上,这等于将属性等式转化为计算规则。因此,属性等式 X .a = f (X .a , . . . , X .a ,X .a , . . . , X .a ,. . . ,X .a , . . . , X .a ) ij ij 0 1 0k 11 1k n1 nk 被看作把右边的函数表达式的值赋给属性 X .a 。为达到这一点,在右边出现的所有属性值必须 ij 已经存在。在属性文法的说明中,这个要求可能被忽略,可以将等式写成任意顺序而不会影响 正确性。实现对应于属性文法的算法规则的问题,主要在于为赋值和属性分配寻找一个顺序, 并确保当每次进行计算时使用的所有属性值都是有效的。属性等式本身指示了属性计算时的顺 序约束,首先是使用指示图表示这些约束来明确顺序的约束。这些指示图叫做相关图。 6.2.1 相关图和赋值顺序 给定一个属性文法,每个文法规则选择有一个相关依赖图 (associated dependency graph)。 文法规则中的每个符号在这个图中都有用每个属性 X .a 标记的节点,对每个属性等式 ij X .a = f ( . . . , X .a , . . . ) ij ij mk 相关于文法规则从在右边的每个节点 X .a 到节点X .a 有一条边(表示X .a 对X .a 的依赖)。依据 mk ij ij mk 上下文无关文法,在语言产生时给定一个合法的字符串,这个字符串的依赖图 (dependency graph)就是字符串语法树中选择表示每个 (非叶子)节点文法规则依赖图的联合。 在绘制每个文法规则或字符串的依赖图时,与每个符号 X相关的节点均画在一组中,这样 依赖就可以看作是语法树的构造。 例6.6 考虑例6.1的文法,属性文法在表 6-1中给出。这里只有一个属性 val,因此每个符号在 每个依赖图中只有一个节点对应于其 val 属性。选择number → number .digit 的文法规则有单个 1 2 的相关属性等式 number .val = number .val * 10 + digit.val 1 2 这个文法规则选择的依赖图是 (在以后的依赖图中,因为图形化表示可以清楚地区分不同节点的不同出现,所以对重复的符 号将省略下标)。 类似地,属性等式 number.val = digit.val中文法规则number → digit的相关图是 n u m b e r. v a l ↑ digit.val 对于剩下的形如 digit→D的文法规则,因为 digit.val 可以从规则的右边直接计算,其相关图是无 足轻重的 (它们没有边 )。 最后,对应于语法树 (见图6-1),字符串 345的相关图如下: 209 第 6章 语 义 分 析 例6.7 考虑例6.3的文法,属性dtype的属性文法在表 6-3中给出。在这个例子中,文法规则 var- list →id , var-list 有两个相关的属性等式 1 2 id dtype = var-list .dtype 1 var-list .dtype = var-list .dtype 2 1 依赖图是 类似地,文法规则 var-list →id 的相关图是 v a r- l i s t.d t y p e id.dtype type →int 和type →float 两个规则的相关图无关紧要。 最后,与等式var-list.dtype = type.dtype相关的decl →type var-list 规则的相关图是 type.dtype ———→ var-list.dtype 在这种情况中,因为 decl不直接包含在相关图中,所以这个相关图相关的文法规则并不完全清 晰。正因为这一点 (稍后将讨论其他一些原因 ),我们通常重叠在相应的文法规则的语法树片段 上绘制相关图。这样,上面的相关图就可以画作 这就使和相关有关的文法规则更加清晰。还要注意,在画语法树节点时我们禁止使用属性的圆 点符号,而是通过写出与其相连的下一个节点来表示属性。这样,在这个例子中第一个相关图 也可以画作 最后,字符串float x,y的相关图是 210 编译原理及实践 例6.8 考虑例6.4基于数的文法,属性 base和val的属性文法在表6-4中给出。画出下面4条文法 规则 based-num → num basechar num → num digit num → digit digit → 9 和字符串 345o的相关图,其语法树在图 6-4中给出。 首先从文法规则based-num → num basechar的相关图开始: 这个图表示了based-num.val = num.val和num.base = basechar.base两个相关的等式的相关。 接下来再画出与文法规则num → num digit相对应的相关图: 这个图表示 3个属性等式的相关 num .val = 1 if digit.val = error or num .val = error 2 then error else num .val * num .base + digit.val 2 1 num .base = num .base 2 1 digit.base = num .base 1 文法规则num → digit的相关图也与此类似: 最后,画出文法规则digit → 9的相关图: 这个图表示由等式digit.val = if digit.base = 8 then error else 9创建的相关,也就是digit.val 与于 digit.base(这是if 表达式测试的一部分)相关。现在剩下画出字符串345o的相关图,如图6-6所示。 211 第 6章 语 义 分 析 图6-6 字符串345o 的相关图(例6.8) 假设现在要确定一个算法,使用属性等式作为计算规则来计算一个属性文法的属性。给定 要转换的一个特定的记号字符串,字符串语法树的相关图根据计算字符串属性的算法给出了一 系列顺序约束。实际上,任一个算法在试图计算任何后继节点的属性之前,必须计算相关图中 每个节点的属性。遵循这个限制的相关图遍历顺序称作拓扑排序 (topological sort),而且众所周 知,存在拓扑排序的充分必要条件是相关图必须是非循环 (acyclic)的。这样的图形称作确定非 循环图(directed acyclic graphs,DAGs)。 例6.9 图6-6的相关图是一个DAG。在图6-7中,给节点编上号 (为便于观察删除了下面的语法 树)。根据节点的编号给出了一个拓扑排序的顺序。另一个拓扑排序的顺序是 12 6 9 1 2 11 3 8 4 5 7 10 13 14 图6-7 字符串345o 的相关图(例6.9) 在图的根节点上如何找到属性值 (图的根节点是没有前驱的节点 )是在使用相关图的拓扑排 序计算属性值时产生的一个问题。在图 6-7中,节点1、6、9、12都是图的根节点 。这些节点 的属性值不依赖于任何其他属性,因此必须使用直接可用的信息计算。这个信息通常在相应的 语法树节点的子孙记号表中。例如,在图 6-7中,节点 6的val依赖于记号 3,它是对应于 val的 digit节点的子节点(见图6-6)。因此,节点6的属性值是3。所有这些根节点的值都需要在计算其 他任何属性值之前计算。这些计算通常由扫描器或语法分析程序来完成。 相关图的根节点不要和语法树的根节点混淆。 212 编译原理及实践 在编译以及随后的为了确定属性赋值的顺序而进行的相关图的拓扑排序过程中,基于一个 算法在相关图的构造上进行属性分析是可能的。因为在编译时相关图的构造是基于特定的语法 树的,这个方法有时称作分析树方法 (parse tree method)。在任何非循环 (noncircular)的属性文 法中能够对属性赋值,也就是说,对每一个可能的相关图属性文法是非循环的。 这种方法有几个问题。首先,在编译时构造相关图增加了额外的复杂性。其次,这种方法 在编译时确定相关图是否非循环时,它通常不恰当地等待发现一个环,直到到达编译时间,因 为在原始的属性文法中,环几乎都是表示错误。换句话说,属性文法必须预先进行无环测试。 有一个算法进行这个工作 (参见“注意与参考”一节 ),但这是一个时间指数级增长的算法。当 然,这个算法只需要在编译器构造时运行一次,因此这不能成为反对这个算法的压倒性的证据 (至少对编译器的构造而言 )。这种方法的复杂性就是更强的证据。 上述的属性赋值的另一种方法 (几乎每一个编译器都采用 )即编译器编写者在编译器构造时 分析属性文法,并固定一个属性赋值顺序。虽然这种方法仍然使用语法树作为属性赋值的指导, 但因为它依赖于对属性等式或语义规则的分析,所以它仍被称作基于规则的方法 (rule-based method)。在编译器构造时固定属性赋值顺序的这一类属性文法没有所有的非循环属性文法通 用,但在实际中,所有合理的属性文法都有这个特性。它们有时称作强非循环 (strongly noncircular)属性文法。在下面的例子后面,我们将对这类属性文法讨论基于规则的算法。 例6.10 再次考虑例6.8的相关图和例6.9中讨论的相关图的拓扑排序(见图6-7)。虽然图6-7中的 节点 6 、9 和1 2 是 D A G的根节点,并且因此都能够出现在拓扑排序的开始,但在基于规则的方 法中这是不可能的。其原因是如果相应的记号是 8或9,任何val 可能依赖于与它相关的digit 节 点的base。例如,对digit → 9相关图是 因此,在图 6-7中,节点 6可能依赖于节点 5,节点9可能依赖于节点 8,而节点 12可能依赖于节 点11。在基于规则的方法中,这些节点将在任何可能潜在依赖的节点之后被强制赋值。因此, 一种赋值顺序,首先赋值节点 12(见例6.9),这对图 6-7中特定的树是正确的,但对基于规则的 算法这是错误的顺序,因为它将破坏其他语法树的顺序。 6.2.2 合成和继承属性 基于规则的属性赋值依赖于分析树或语法树明确或不明确的遍历。不同种类的遍历处理的 属性相关,在项目和能力的种类上都不同。为了研究这些不同之处,首先必须根据它们具有的 相关的种类对属性分类。处理的最简单的依赖是综合属性,定义如下。 定义 一个属性是合成的 (synthesized),如果在语法树中它所有的相关都从子节点指 向父节点。等价地,一个属性 a 是合成的,如果给定一个文法规则 A →X X . . . X ,左 12 n 边仅有一个a 的相关属性等式有以下形式 A.a = f (X .a ,. . . ,X .a ,. . . ,X .a ,. . . ,X .a ) 11 1k n1 nk 一个属性文法中所有的属性都是合成的,就称作 S属性文法(S-attributed grammar)。 我们已经见过了一些合成属性和 S属性文法的例子。在例 6.1中数的val 属性是合成的(参见 213 第 6章 语 义 分 析 例6.6中的相关图),例6.2中简单整数算术表达式的 val属性也是一样。 给定由分析程序构造的分析树或语法树, S属性文法的属性值可以通过对树进行简单的自 底向上或后序遍历来计算。这可以用以下递归的属性赋值程序来表示: procedure PostEval (T : treenode); begin for each child C of T do PostEval (C); compute all synthesized attributes of T; end; 例6.11 用合成属性val 考虑例6.2中简单算术表达式的属性文法。给定下列语法树 (就像图6-5) 的结构 typedef enum { Plus, Minus, Times } OpKind; typedef enum { OpKind, ConstKind } ExpKind; typedef struct streenode { ExpKind kind; OpKind op; struct streenode *lchild, *rchild; int val; } STreeNode; typedef STreeNode *SyntaxTree; PostEval程序可以转换成程序清单 6-1中的C程序代码,进行从左向右的遍历。 程序清单 6-1 例6.11中后序属性赋值的 C程序代码 void postEval(SyntaxTree t) { int temp; if (t->kind = OpKind) { postEval(t->lchild); postEval(t->rchild); switch (t->op) { case Plus: t->val = t->lchild->val + t->rchild->val; break; case Minus: t->val = t->lchild->val - t->rchild->val; break; case Minus: t->val = t->lchild->val * t->rchild->val; break; } /* end switch */ } /* end if */ } /* end postEval */ 当然,不是所有的属性都是合成的。 定义 一个属性如果不是合成的,则称作继承 (inherited)属性。 214 编译原理及实践 我们已经见过继承属性的例子,包括例 6.3中的dtype属性和例 6.4中的base属性。无论在分 析树中(使用这个名字的理由)从祖先到子孙的继承属性还是从同属的继承属性都有依赖。图 6-8 a和 b说明了继承属性依赖的两种基本种类。这两种类型的依赖在例 6.7中的dtype属性中都出现 过。这两种分类都为继承属性的原因在于计算继承属性的算法,同属继承通常是通过把属性值 经过祖先在同属中传递实现的。实际上,如果语法树的边只从祖先指向子孙 (这样子孙不能直 接访问祖先或同属 )这也是必要的。另一方面,在语法树中如果一些结构经过同属指针实现, 那么同属继承可以直接沿着同属链进行,如图 6-8c 的描述。 a) b) c) 图6-8 不同种类的继承依赖 a) 从祖先到子孙的继承 b) 同属之间的继承 c) 通过同属指针的同属继承 现在回到继承属性赋值的算法方法。继承属性的计算可以通过对分析树或语法树的前序遍 历或前序 /中序遍历的组合来进行。这可以用下面的伪代码来示意表示: procedure PreEval (T: treenode); begin for each child C of T do compute all inherited attributes of C; PreEval ( C ); end; 与合成属性不同,子孙继承属性计算的顺序是重要的,因为在子孙的属性中继承属性可能有依 赖关系。因此在前面的伪代码中 T的每个子孙C的访问顺序必须满足这些依赖的任何要求。在 下面两个例子中,我们将使用前面例子中的 dtype和base继承属性来说明这一点。 例6.12 考虑例6.3的文法,它有继承属性 dtype,其相关图例6.7中给出(见表6-3的属性文法 )。 首先假设从文法明确构造了分析树,为便于参考重述如下: decl → type var-list type →int | float var-list →id, var-list | id 所有需要的节点 dtype属性的递归程序的伪代码如下: procedure EvalType (T: treenode); 215 第 6章 语 义 分 析 begin case nodekind of T of decl : EvalType (type child of T ); Assign dtype of type child of T to var-list child of T; EvalType (var-list child of T ); type : if child of T = int then T.dtype := integer else T.dtype := real; var-list : assign T.dtype to first child of T; if third child of T is not nil then assign T.dtype to third child; EvalType (third child of T ); end case: end EvalType; 注意,前序和中序操作是如何根据被处理的不同类型的节点混合的。例如,节点 decl在子节点 上递归调用EvalType之前,要求首先计算其第 1个子孙的dtype,然后分配到其第2个子孙;这是 一个中序处理。另一方面, var-list节点在进行任何递归调用之前分配 dtype给其子孙;这是一 个前序处理。 在图6-9中,我们与dtype属性的相关图一起显示了字符串 f l o a t x ,的y 分析树,并且按照 上面的伪代码计算 dtype的顺序给节点编了号。 图6-9 显示例6.12遍历顺序的分析树 为了给出这个例子一个完全具体的形式,我们把前面的伪代码转换成实际的 C语言代码。 并且,为了替代使用明确的分析树,假设已经构造了一个语法树, var-list用id节点的同属列 表表示。这样,像 float x,y这样的声明字符串语法树如下(与图6-9比较) decl节点的子孙赋值顺序为从左至右 (首先是节点 type,然后是节点 x,最后是节点 y)。注意, 216 编译原理及实践 在这个树中已经包括了 dtype节点和 type节点,假定在语法分析时已经预先计算了。 语法树的结构用下面的 C语言声明给出: typedef enum { decl, type, id } nodekind; typedef enum { integer, real } typekind; typedef struct treeNode { nodekind kind; s t r u c t t r e eoNde *lchild, *rchild, *sibling; typekind dtype; /* for type and id nodes */ char * name; /* for id nodes only */ } * SyntaxTree; 相应地EvalType程序的C代码如下: void evalType(SyntaxTree t) { switch (t->kind) { case decl: t->rchild->dtype = t->lchild->dtype; evalType(t->rchild); break; case id: if (t->sibling != NULL); { t->sibling->dtype = t->dtype; evalType(t->sibling); } break; } /* end switch */ } /* end evalType */ 这段代码可以用下列非递归程序简化,其操作全部在根节点 (decl)层次上进行: void evalType(SyntaxTree t) { if (t->kind == decl) { SyntaxTree p = t->rchild; p->dtype = t->lchild->dtype; while (p->sibling != NULL) { p->sibling->dtype = p->dtype; p = p->sibling; } } /* end if */ } /* end evalType */ 例6.13 考虑例6.4的文法,它具有继承属性 base (相关图在例 6.8中给出)。这里将那个例子的 文法重述如下: based-num → num basechar basechar → o | d num → num digit | digit 9|8|7|6|5d|4i|g3i|t2|→1|0 217 第 6章 语 义 分 析 这个文法有两个新特性。首先,它有两个属性,合成属性val以及val依赖的继承属性base。其 次,base属性是从based-num左边的子孙向右边的子孙继承的(即从basechar到num)。因此,在这种 情况下,我们必须从右向左而不是从左向右给based-num的子孙赋值。我们继续给出EvalWithBase 程序的伪代码用以计算base和val。对于这种情况,在一遍中base用前序遍历计算,而val则用后序 遍历计算(后面即将讨论多属性和多遍问题)。这段伪代码如下(参见表6-4的属性文法): procedure EvalWithBase (T: treenode); begin case nodekind of T of based-num: EvalWithBase ( right child of T ); assign base of right child of T to base of left child; EvalWithBase ( left child of T ); assign val of left child of T to T.val; num: assign T.base to base of left child of T; EvalWithBase ( left child of T ); if right child of T is not nil then assign T .base to base of right child of T; EvalWithBase ( right child of T ); if vals of left and right children≠error then T.val := T.base* ( val of left child ) + val of right child; else T.val := error; else T.val := val of left child; b a s e c h a r: if child of T = o then T.base := 8 else T.base := 10; d i g i t: if T.base := 8 and child of T = 8 or 9 then T.val := error else T.val := numval ( child of T ); end case; end EvalWithBase; 我们把相应的语法树 C语言声明的构造和将 EvalWithBase 伪代码转换为 C语言代码的工作留作 练习。 在组合合成和继承属性的属性文法中,如果合成属性依赖于继承属性 (及其他合成属性),但 继承属性不依赖于任何合成属性,那么就可能在分析树或语法树的一遍遍历中计算所有的属性。 上例就是这一点很好的一个例子,赋值顺序可以组合PostEval 和PreEval 伪代码程序进行概括: procedure CombinedEval ( T:treenode ); begin for each child C of T do compute all inherited attributes of C; 218 编译原理及实践 CombinedEval ( C ); compute all synthesized attributes of T; end; 继承属性依赖于合成属性的情形更加复杂,需要对分析树或语法树进行多遍遍历,如下一个例 子所示。 例6.14 考虑下列表达式文法的简化版本: exp → exp / exp | num | num.num 这个文法只有一个操作符——除,用记号 /表示。它还有两种数的形式,整型数由数字序列组 成,用记号 num表示,浮点数用记号 num.num表示。这个文法的思想是:根据操作数是浮点数 还是整型数,操作符可能会有不同的解释。特别对于除法,根据是否允许有小数部分而结果完 全不同。如果不允许,除法通常称作 div操作,5/4的值就是5 div 4 = 1。如果是浮点数除法, 5/4的值就是1.2。 现在假设一种编程语言要求混合表达式提供浮点计算能力,根据语义使用相应的操作。因 此,表达式5/2/2.0 (假定除法是左结合 )的含义是1.25,而5/2/2的含义是1 。描述这些语义 需要3个属性:一个合成的布尔值属性 isFloat,指示表达式的任何部分是否有浮点数值;一个 继承属性 etype,它有两个值 int 和float,它们给出每个子表达式的类型以及哪一个表达式依赖 isFloat;最后是每个子表达式计算的 val,它依赖继承属性 etype。这个情况也要求对顶层表达 式进行区分(这样我们知道没有更多的子表达式需要考虑 )。我们给文法增加一个开始符号: S → exp 属性等式在表6-7中给出。在文法规则exp → num 的等式中,我们使用Float ( num.val ) 表示将 整型值num.val 转换成浮点数值的函数。我们还使用“ /”表示浮点数除法,使用“ div”表示 整数除法。 在这个例子中,属性 isFloat、etype和val可以用对分析树或语法树的两遍遍历来计算。第 一遍通过后序遍历计算合成属性 isFloat。第二遍用前序和后序遍历的组合计算继承属性 etype和 合成属性 val。我们把这些遍的描述、表达式相应的属性等式以及在语法树上进行后续遍的伪 代码或C语言代码构造留作练习。 表6-7 例6.14的属性文法 文法规则 S →exp exp →exp / exp 1 2 3 语义规则 exp.etype = if exp.isFloat then float else int S.val = exp.val exp .isFloat = exp .isFloat or exp .isFloat 1 2 3 exp .etype = exp .etype 2 1 exp .etype = exp .etype 3 1 exp .val = 1 if exp .etype = int 1 then exp .val div exp .val 2 3 else exp .val / exp .val 2 3 这个规则与C语言中使用的规则不同。例如,在 C语言中,5/2/2.0的值是1.0,而不是1.25。 219 第 6章 语 义 分 析 文法规则 exp → num exp →num.num 语义规则 exp.isFloat = false exp.val = if exp.etype = int then num .val else Float (num.val) exp.isFloat = true exp.val = num.num.val (续) 6.2.3 作为参数和返回值的属性 通常在计算属性时,利用参数和返回的函数值与属性值进行通信,而不是把它们作为字段 存储在语法树的记录结构中,这样做是有意义的。当许多属性值都相同,或者仅仅在计算其他 属性值时临时使用,就尤为重要。对于这种情况,使用语法树的空间在每个节点存储属性值就 没什么意义了。事实上,递归遍历程序用前序计算继承属性,而用后序计算合成属性,在子节 点把继承属性作为参数传递给递归函数调用,并接收合成属性作为那些相同调用的返回值。前 几章已经出现了几个这样的例子。特别地,算术表达式合成属性值的计算能通过递归分析程序 返回当前表达式的值进行。类似地,在语法分析期间,因为直到它的构造完成,也没有用以记 录作为属性的自身的数据结构存在,所以语法树自身作为合成属性必须通过返回值计算。 对于更复杂的情况,例如当要返回不止一个合成属性时,就有必要使用记录结构或联合作 为返回值,或者针对不同的情况把递归程序分割成几个程序。我们用一个例子来说明这一点。 例6.15 考虑例6.13中的递归程序EvalWithBase。在这个程序中,一个数的 base属性只计算一 次,之后就用于随后的 val属性的计算。类似地,在一个完整的数的值的计算中,数的部分 val 属性只是临时使用。把 base转换成参数 (作为继承属性 )和把val转换成返回值是有意义的。将 E v a l Wi t h B a s e程序修改如下: function EvalWithBase (T: treenode; base: integer ): integer; var temp, temp2: integer; begin case nodekind of T of based-num: temp:= EvalWithBase ( right child of T ); retutn EvalWithBase ( left child of T, temp ); num: temp := EvalWithBase ( left child of T, base ); if right child of T is not nil then temp2 := EvalWithBase ( right child of T, base ); if temp≠error and temp2≠error then return base*temp + temp2 else return error; else return temp; 220 编译原理及实践 basechar: if child of T = o then return 8 else return 10; digit: if base = 8 and child of T = 8 or 9 then return error else return numval (child of T ); end case; end EvalWithBase; 当然,只有工作在 base属性和 val属性才有相同的 integer数据类型,因为在一种情况下 EvalWithBase返回base属性(当分析树节点是一个 basechar节点),在另一种情况下 EvalWithBase 返回val属性。在第1次调用EvalWithBase时也有些不规则 (在分析树根节点base-num节点),即使 它可能不存在,随后再忽略掉,此时也必须提供一个 base值。例如,启动计算时,必须进行这 样的调用 EvalWithBase (rootnode,0); 哑元的基值为0。因此,区分 3种情况:base_num、basechar和digit,并且对这3种情况写出 3个 独立的程序是合理的。伪代码如下: function EvalBasedNum (T: treenode ) : integer; (*only called on root node*) begin return EvalNum ( left child of T, EvalBase (right child of T) ); end EvalBasedNum; function EvalBase ( T: treenode ): integer; (*only called on basechar node*) begin if child of T = o then return 8 else return 10; end EvalBase; function EvalNum ( T: treenode; base: integer ): integer; var temp, temp2: integer; begin case nodekind of T of n u m: temp := EvalWithBase ( left child of T, base ); if right child of T is not nil then temp2 := EvalWithBase ( right child of T, base ); if temp≠error and temp2≠error then return base*temp + temp2 else return error; else return temp; 221 第 6章 语 义 分 析 d i g i t: if base = 8 and child of T = 8 or 9 then return error else return numval ( child of T ); end case; end EvalNum; 6.2.4 使用扩展数据结构存储属性值 对那些不能方便地把属性值作为参数或返回值使用的情况 (特别是当属性值有重要的结构 时,在翻译时可能专门需要 ),把属性值存储在语法树节点也是不合理的。在这些情况中,如 查表、图以及其他一些数据结构对于获得属性值的正确活动和可用性都是有用的。通过由到表 示用于维护属性值的相应的数据结构调用替换属性等式 (表示属性值的赋值 )就可反映出属性文 法自身可以被修改的这个需要。这导致语义规则不再表示一个属性文法,但在描述属性的语义 时仍然有用,而程序的操作清晰了。 例6.16 考虑前一个例子使用参数和返回值的 EvalWithBase程序。因为属性一旦设置,在值计 算过程中就固定了,我们可以使用一个非局部变量存储它的值,而不是在每次作为一个参数传 递(如果 base不固定,这样一个递归过程是危险的甚至是不正确的 )。因此,我们可以改变 E v a l Wi t h B a s e的伪代码如下: function EvalWithBase (T: treenode ):integer; var temp , temp2: integer; begin case nodekind of T of based-num: SetBase ( right child of T ); retutn EvalWithBase ( left child of T ); num: temp:= EvalWithBase ( left child of T ); if right child of T is not nil then temp2:= EvalWithBase ( right child of T ); if temp≠error and temp2≠error then return base*temp + temp2 else return error; else return temp; d i g i t: if base = 8 and child of T = 8 or 9 then return error else return numval ( child of T ); end case; end EvalWithBase; procedure SetBase (T:treenode ); begin if child of T = o then base:= 8 222 编译原理及实践 else base:= 10; end SetBase; 这里我们把向非局部变量 base赋值的过程分离到 SetBase程序中,它只在 basechar节点上调用。 EvalWithBase剩下的代码则只是直接引用 base,而不是作为一个参数传递。 也可以改变语义规则来反映非局部变量 base的使用。在这种情况下,规则就像下面的一样 使用赋值明确地指示非局部变量 base: 文法规则 based-num → num basechar basechar → o basechar → d num → num digit 1 2 etc. 语义规则 based-num.val = num.val base : = 8 base := 10 num1.val = if digit.val = error or num .val = error 2 then error else num .val *.base + digit.val 2 etc. 在这个意义上, base现在不再是一个到目前为止所使用的属性,语义规则也不再构成一个 属性文法。然而,如果将 base看作是具有相关特性的变量,这些规则就仍然为编译器编写者充 分定义了 base-num的语义。 对于语法树,外部数据结构最初的一个例子是符号表 (symbol table),结合程序中声明的常 量、变量和过程存储属性。符号表是一种目录数据结构,有 insert、lookup、delete这样的操作。 下一节讨论在一个典型的程序设计语言中符号表的问题。这一节介绍下面这个简单的例子。 例6.17 考虑表6-3中简单声明的属性文法,这个属性文法的属性赋值程序在例 6.12给出。一般 地,声明中信息使用声明标识符作为关键字插入到符号表中并存储在哪里,以供之后程序的其 他部分翻译使用。因此,假设对这个符号表所在的文法通过 insert程序而把标识符名、它声明 的数据类型和名数据类型插入到符号表中。声明如下: procedure insert (name : string ; dtype : typekind); 因此替代在语法树中存储每个变量的数据类型,使用这个程序把它插入到符号表中。而且因为 每个声明只有一个相关类型,所以就可在处理过程中使用一个全局变量存储每个声明的常量 dtype。结果的语义规则如下: 文法规则 decl →type var-list type → int type → float var-list → id, var-list 1 2 var-list → id 语义规则 dtype = integer dtype = real insert ( id.name,dtype ) insert ( id.name,dtype ) 在对insert的调用中,我们使用 id.name查阅标识符字符串,即假定由扫描器或分析程序 223 第 6章 语 义 分 析 要计算的。这些语义规则与相应的属性文法完全不同;事实上,对于文法规则 decl就完全没有 语义规则。因为对 insert的调用依赖于在type规则中设置的dtype,所以虽然很清楚在相关的 vatlist规则之前必须处理type规则,但依赖仍不像表达的那样清晰。 相应的属性赋值程序 EvalType的伪代码如下(与例6-12的代码相比较): procedure EvalType ( T: treenode ); begin case nodekind of T of d e c l: EvalType ( type child of T ); EvalType ( var-list child of T ); type: if child of T = int then dtype:= integer; else dtype:= real; var-list: insert ( name of first child of T,dtype ) if third child of T is not nil then EvalType ( third child of T ); end case; end EvalType; 6.2.5 语法分析时属性的计算 有一个很自然会出现的问题,即在分析阶段的同时要计算哪种扩展属性,而无须等到通过 语法树的递归遍历进行对源代码的多遍处理。这对于语法树自身特别重要,如果合成属性要被 后面的语义分析使用,它必须在语法分析期间构造。在历史上,因为注意的重点是编译器进行 一遍翻译的能力,所以对语法分析阶段计算所有属性的可能性产生了很大的兴趣。现在这一点 已不太重要了,因此我们没有对所有已经开发的特定技术提供详尽的分析。然而,这个思想和 要求总的来看是有价值的。 在一次分析中哪些属性能成功地计算在很大程度上要取决于使用分析方法的能力和性质。 这样一个重要的限制是所有主要的分析方法都从左向右处理输入程序 (这也是前面两章研究 LL 和LR分析技术的第 1个L 的内容 )。它等价于要求属性能通过从左向右遍历分析树进行赋值。对 于合成属性这不是一个限制,因为节点的子节点可以用任意顺序赋值,特别是从左向右。但是 对于继承属性,这就意味着在相关图中没有“向后”的依赖 (在分析树中依赖从右指向左 )。例 如,例6.4的属性文法违反了这个特性,因为 based-num通过后缀o或d给出其基于的进制,在字 符串结尾看到后缀并进行处理之前, val属性都不能进行计算。属性文法确保的这个特性称作 L-属性(从左向右 ),我们给出以下定义。 定义 属性a , . . . , a 的一个属性文法是 L-属性(L-attributed),如果对每个继承属性a 1 k j 和每个文法规则 a 的相关等式都有以下形式 j X →X X . . . X 0 12 n 224 编译原理及实践 X .a = f (X .a , . . . , X .a ,X .a , . . . , X .a ,. . . ,X .a , . . . , X .a ) ij ij 0 1 0k 11 1k i -1 1 i -1 k 也就是说,在X 处a 的值只依赖于在文法规则中X 左边出现的符号X , . . . , X 的属性。 i j i 0 i -1 作为一个特例,我们已经注意到 S- 属性文法是L- 属性文法。 给定一个 L- 属性文法,其继承属性不依赖于合成属性,如前所述,通过把继承属性转换 成参数以及把合成属性转换成返回值,递归下降的分析程序可以对所有的属性赋值。然而, LR分析程序,如Yacc产生的LALR(1)分析程序,适合于处理主要的合成属性。反过来说,其原 因在于LR分析程序的功能强于 LL分析程序。当已知在一个派生中使用的文法规则时,属性才 变成可计算的,这是因为只有在那时才能确定属性计算的等式。但是, LR分析程序将确定在 派生中使用的文法规则推迟到文法规则的右部完全形成时才使用。这使得使用继承属性十分困 难,除非它们的特性对于所有可能的右部选择都是固定的。我们将简要地讨论在 Yacc应用中最 通常的情况下使用分析栈来计算属性。更复杂的技术将在“注意与参考”一节讲述。 1) LR分析中合成属性的计算 对于LR分析程序而言这是一种简单的情况。 LR分析程序中 通常由一个值栈存储合成属性 (如果对每个文法符号有不止一个属性,可能是联合或结构 )。值 栈将和分析栈并行操作,根据属性等式每次在分析栈出现移进或规约来计算新值。我们用表 6- 8来说明这一点,属性文法在表 6-5中,这是简单算术表达式的二义性版本。为简单起见,我们 对文法使用缩写符号,并且忽略了表中 LR分析算法的一些细节。特别地,没有指明状态号、 没有显示增加的开始符号,也没有表达隐含的二义性消除规则。这个表除了通常的分析动作之 外,还有两个新栏:值栈和语义动作。语义动作指示当分析栈出现规约时值栈发生的计算 (移 进看作是把记号值同时推进分析栈和值栈,因此这和单独的分析程序不同 )。 作为语义动作的例子,考虑表 6-8的第10步。值栈包含整数值 12和5,由记号+分割开,5在 值栈的栈顶。分析动作通过 E → E + E 规约,根据表6-5,相应的语义动作是按照等式 E .val = 1 E .val + E .val计算。在栈顶分析程序相应的动作如下 (伪代码): 2 3 pop t3 pop {从值栈中取出E .val} 3 {丢弃+记号} pop t2 t1 = t2 + t3 {从值栈中取出E .val} 2 {加} push t1 {将结果压进值栈} 在Yacc中第10步的规约表示的情形可以写成如下规则: E :E + E { $ $ = $ 1 + ;$ 3} 这里伪变量 $i表示规则右部的内容被规约,并通过从右部向后计数转换成值栈的内容。因此, 可以在栈顶找到对应于最右端的 E的$3,而在栈顶下面的两个位置找到 $1。 表6-8 在LR分析中表达式 3*4+5 的语法和语义动作 分析栈 1 $ 2 $n 3 $E 4 $E* 5 $E*n 输入 3*4+5 $ *4+5 $ *4+5 $ 4+5 $ +5 $ 语法动作 移进 规约E → n 移进 移进 规约E → n 值栈 $ $n $3 $3 * $3 * n 语义动作 E.val = n .val E.val = n .val 225 第 6章 语 义 分 析 分析栈 6 $E*E 7 $E 8 $E+ 9 $E+n 10 $ E + E 11 $ E 输入 +5 $ +5 $ 5$ $ $ $ 语法动作 规约E →E * E 移进 移进 规约E → n 规约E → E + E 值栈 $3 * 4 $ 12 $ 12 + $ 12 + n $ 12 + 5 $ 17 (续) 语义动作 E 1.val = E2.val * E3.val E.val =n val E1.val = E2.val + E3.val 2) 在LR分析中继承前面计算的合成属性 因为LR分析使用从左向右的赋值策略,因为这 些值已经被压进了值栈,所以与规则右边非终止符相关的动作可以把符号的合成属性使用到规 则的左边。为了简要说明这一点,可考虑产生式选择 A→B C,假设C有一个继承属性 I 和以某 种方式依赖于B:C.i = f (B.s) 的合成属性s。通过在B 和C 之间引入一个 -产生式安排值栈栈顶 的存储,在识别C 之前的C.i 值可以存进一个变量中: 文法规则 A→BCD B→... D→ C→... 语义规则 {计算B.s } saved_i = f (valstack[top]) {现在saved_i 是可用的} Yacc中的这个处理十分容易,因为无须明确地引入 - 产生式。只需在调度的规则处写入存储计 算属性的动作就行了: A :B { saved_i = f($1); } C ; (这里伪变量$1指的是B的值,动作执行时它在栈顶 )。Yacc中这样的嵌入动作在第 4章5.5.6节中 已讲述过。 当总能预测出过去计算的合成属性的位置时,可以使用这个策略的另一方面。对于这种情 况,无须将值拷贝到变量中,在值栈中能够直接访问。例如,考虑下列具有继承的 dtype属性 的L- 属性文法: 文法规则 decl → type var-list type → int type → float var-list → var-list ,id 1 2 var-list → id 语义规则 var-list.dtype = type.dtype type.dtype = integer type.dtype = real insert (id. name,var-list .dtype) 1 var-list .dtype = var-list .dtype 2 1 insert (id. name.var-list.dtype) 在这种情况中,在第 1个var-list识别之前可以将 dtype属性作为非终结符 type的合成属性计 算进入到值栈中。然后当 var-list的每条规则规约时,在值栈中通过从栈顶向后计数就可以在固 定位置找到dtype。当var-list →id规约时,dtype就在栈顶的下面,而当 var-list →var-list ,id 1 2 规约时,dtype在栈顶下面的 3个位置。通过在上面的属性文法中为 dtype消除两个拷贝规则并直 接访问值栈,可以实现这个 LR分析程序。 226 编译原理及实践 文法规则 decl → type var-list type → int type → float var-list → var-list ,id 1 2 var-list → id 语义规则 type.dtype = integer type.dtype = real insert (id.name,valstack[top-3]) insert (id.name,valstack[top-1]) (注意:因为 var-list没有合成属性 dtype,分析程序必须在栈中压入一个虚值以保持栈中的正确 位置)。 这种方法存在几个问题。首先,它要求程序员在分析过程中直接访问值栈,这在自动产生 分析程序时是有风险的。例如,在当前的规则被认可之下 Yacc没有像上面的方法所要求的访问 值栈的伪变量转换。因此,要在 Yacc中实现这一方案就必须编写特定的代码。第 2个问题是这 个技术仅使用在前面计算的属性的位置能从文法中推断出来的情况下。例如,我们编写了上面 的声明文法,var-list是右递归的(就像例6.17中的),然后在栈中有 id 的一个仲裁数,而 dtype在 栈中的位置是未知的。 到目前为止,在 LR分析中处理继承属性最好的技术是使用外部数据结构,如符号表或非 局部变量,保存继承属性值,并增加 -产生式(或像在Yacc中的嵌入动作 ),考虑在适当的时刻 这些数据结构的变化。例如,刚讨论的 dtype问题的一种解决办法可以从对 Yacc的嵌入动作的 讨论中得到 (见5.5.6节)。 我们要认识到,即使后一种方法没有排除陷阱,在文法中增加 -产生式也可能会增加分析 冲突,因此对任意的变量 k,LALR(1)文法均能转变成非LR(k)文法(见练习6.15以及“注意与参 考”一节 )。在实际情况中,这很少发生。 6.2.6 语法中属性计算的相关性 作为本节最后一个主题,值得注意的是属性严重依赖于文法结构的特性。可能会有这样的 情况,不改变语言合法的字符串而修改文法会使属性的计算更简单或更复杂。当然,有以下 定理: 定理:(Knuth [1968])给定一个属性文法,通过适当地修改文法,而无须改变文法的语 言,所有的继承属性可以改变成合成属性。 我们给出一个例子,说明如何通过修改文法继承属性转换成合成属性。 例6.18 考虑前一个例子中简单声明的文法: decl → type var-list type → int | float var-list → id, var-list | id 表6-3的属性文法的dtype属性是继承属性。然而,如果重写这个文法如下: decl → var-list id var-list → var-list id,| type type → int | float 227 第 6章 语 义 分 析 那么产生了相同的字符串,但根据下面的属性文法,属性 dtype现在变成了合成属性: 文法规则 decl → var-list id var-list →var -list id , 1 2 var-list → type type → int type → float 语义规则 id .dtype = var-list.dtype id .dtype = var-list .dtype 2 var-list .dtype = var-list .dtype 1 2 var-list.dtype = type.dtype type. dtype = integer type.dtype = real 我们在图6-10中已说明了文法的这个改变对语法树和 dtype属性计算的影响怎样,它显示了 字符串float x,y语法树及其属性值和依赖。在图中父节点或兄弟节点值的两个 id.dtype值 的依赖用虚线画出。而这些依赖的出现违反了在这个属性文法中没有继承属性的要求,事实上 这些依赖总是留在语法树中 (即是非递归的),并且可能由相应的父节点的操作实现。因此,这 些操作不看成是继承的。 图6-10 字符串float x,y的语法树,显示例 6.18 中属性文法指定的 dtype 属性 事实上,状态理论并不像它看起来那样有用。为了把继承属性转换成合成属性而对文法进 行改变通常会使文法和语义规则更加复杂和难以理解。因此,我们不推荐使用这种方法来处理 计算继承属性的问题。另一方面,如果一个属性计算看起来非常困难,可能是因为这个文法用 了不适合于计算它的方法来定义,也就值得对这个文法进行修改。 6.3 符号表 符号表是编译器中的主要继承属性,并且在语法树之后,形成了主要的数据结构。虽然因 为一些例外我们推迟了对符号表的讨论,但它仍对语义分析阶段概念上的框架最适合,读者也 会意识到在实际的编译器中,符号表通常紧密地和语法分析程序甚至扫描器相关,它们可能需 要直接向符号表中输入信息,或者通过它消除二义性 (C语言中一个这样的例子可参见练习 6.22)。然而,在一种设计非常仔细的语言 (如Ada或Pascal)中,有可能甚至有理由将符号表的操 作推迟到一个完整的阶段之后,这时在语法上已知要翻译的程序是正确的。例如,在 TINY编 译器中就这样做了,其符号表将在本章的后面讨论。 符号表主要的操作有插入、查找和删除;其他的一些操作也是必要的。当处理新定义的名 228 编译原理及实践 字时,插入操作依赖存储这些名字定义所提供的信息。当相关的代码使用名字时,查找操作找 出与名字对应的信息。而当不再运用名字的定义时,需要用删除操作除去名字定义提供的信 息。 这些操作的特性由被翻译的编程语言的规则指定。特别地,在符号表中需要存储什么信 息是结构的功能和名字定义的目的。一般包括数据类型信息、应用区域信息 (作用域,下面将 讨论)以及在存储器中的最终定位信息。 这一节我们将首先讨论符号表数据结构的组织,以便快速而方便地进行访问。随后将描述 一些典型语言的要求和它们在符号表操作上的影响。最后,给出了一个使用属性文法符号表的 例子。 6.3.1 符号表的结构 在编译器中符号表是一个典型的目录数据结构。插入、查找和删除这 3种基本操作的效率 根据数据结构的组织的不同而变化很大。对不同组织结构效率的分析以及对好的组织策略的研 究是数据结构课程的一个主要主题。因此,本书对这个题目不详细讨论,但我们提请读者参考 本章最后的“注意与参考”一节提及的资料,获取更多的信息。这里,我们将给出编译器构造 中这些表格最有用的数据结构的概要。 目录结构的典型实现包括线性表、各种搜索树结构 (二叉搜索树、 AVL树、B树)以及杂凑 表(hash表)等。线性表是一种较好的基本数据结构,它能够提供3种基本操作方便而直接的实现, 即用恒定次数的插入操作 (通常插入在前面或后面 )以及查找和删除操作,表的大小是线性的。 这对编译器的实现是很好的,不必去关心编译的速度,就像一个原型或实验编译器,或者用于 非常小的程序的解释器。搜索树结构对符号表的用处稍微小一点,部分是因为它们没有提供最 好的效率,也因为删除操作的复杂性。杂凑表通常为符号表的实现提供了最好的选择,因为所 有3种操作都能在几乎恒定的时间内完成,在实践中也最常使用。因此,对杂凑表的讨论更加 详细一些。 杂凑表是一个入口数组,称作“桶 (bucket)”,使用一个整数范围的索引,通常从 0到表的 尺寸减1。杂凑函数(hash fuction)把索引键(在这种情况下是标识符名,组成一个字符串 )转换成 索引范围内的一个整数的杂凑值,对应于索引键的项存储在这个索引的“桶”中。必须非常小 心,杂凑函数在索引范围内尽可能一致地分配键索引,因为杂凑冲突 (collision)(两个键由杂凑 函数映射到相同的索引 )在查找和删除操作时将引起性能的下降。杂凑函数也需要在一个恒定 的时间内操作,或者至少根据键的尺寸时间是线性的 (如果键的尺寸有界,这可以计算恒定时 间)。我们不久将研究杂凑函数。 一个重要的问题是杂凑表如何处理冲突 (这称为冲突解决 (collision resolution))。一种方法 是在每个“桶”中对一个项分配刚好够的空间,通过在连续的“桶”中插入新项来解决冲突 (这有时称作开放寻址(open addressing))。在这种情况中,杂凑表的内容由表所使用的数组的大 小限制,当数组填写冲突越来越频繁时,就会引起性能的显著下降。这个方法进一步的问题是, 至少对编译器的结构而言实现删除操作比较困难,并且删除不会改进后继表的性能。 编译器结构最好的方案对应于开放寻址也许是另一种方法,称作分离链表 (separate chaining)。在这种方法中每个“桶”实际上是一个线性表,通过把新的项插入到“桶”表中来 解决冲突。图6-11给出了这种方案的一个简单的例子,其杂凑表的尺寸是 5 (小得很不现实,仅 作演示用)。在那个表中,假定插入了 4个标识符(i、j、size和temp),并且size和j有相同 与销毁这个信息相比,删除操作更象是从可视区移走,可能是存储到别处,或者把它标记成不活动的。 229 第 6章 语 义 分 析 的杂凑值 (命名为 1)。在图中我们看到,“桶” 索引 “桶” 项列表 号为 1的表中 size在j的前面;表中项的顺序 依赖于插入的顺序以及表维护的方式。一种通 常的方法是总是插入在表的开始,这样使用这 种方法size在j之后插入。 图6-11也显示了在每个“桶”中作为链接 列表实现的列表 (实心圆点用来表示空指针 )。 图6-11 分离链接的杂凑表,显示如何解决冲突 这些可以使用编译器实现语言的动态指针分配方法进行分配,或者在编译器自身的空间数组中 手工分配。 编译器编写者必须回答的一个问题是要初始化多大的“桶”数组。通常,在编译器构成时, 这个大小就固定了。 一般的大小范围从几百到上千。如果动态分配实际的入口,即使很小的 数组也允许编译很大的程序,只是花费一些额外的编译时间。在任何情况下,“桶”数组的实 际大小要选择一个素数,因为这将使一般的杂凑函数运行得更好。例如,如果希望“桶”数组 的大小是200,就应该选择211作为数组的大小而不是200(211是大于200的最小的素数)。 现在我们转向描述通用的杂凑函数。在符号表实现中使用的杂凑函数将字符串 (标识符名) 转换成0. . . size-1范围内的一个整数。一般这通过 3步来进行。首先,字符串中的每个字符转换 成一个非负整数。然后,这些整数用一定的方法组合形成一个整数。最后,把结果整数调整到 0. . . size-1范围内。 通常使用编译器实现语言内嵌的转换机构把每个字符转换成非负整数。例如, Pascal的 ord函数将字符串转换成整数,通常是其 ASCII值。类似地,在C语言中,如果要在算术表达式 中使用字符或把它赋给一整型变量,就自动地将其转换成整数。 使用数学上的取模 (modulo)函数很容易调整一个非负整数落到 0. . . size-1范围内,它返 回用size去除一个数所得的余数。这个函数在 Pascal中称作 mod,在C中用%表示。使用这种方 法时,size是一个素数,这一点很重要。否则,随机分配的整数集不会将标定的值随机分配到 0. . . size-1范围内。 留给杂凑表实现者选择一个方法,把字符的不同整数值组合成一个非负整数。一种简单的 方法是忽略许多字符,只把开头的几个字符,或第一个、中间的和最后一个字符的值加在一起。 这对编译器来说是不适当的,因为编程者倾向于成组分配变量名,像 temp1、temp2,或 m1tmp、m2tmp等等,并且这种方法在这样的名字中会经常引起冲突。因此,选择的方法要包 括每个名字中所有的字符。另一种流行但不适当的方法是简单地把所有字符的值加起来。使用 那种方法,所有排列相同的字符,像 tempx和xtemp,会引起冲突。 这些问题的一个好的解决办法是,当加上下一个字符的值时,重复地使用一个常量作为乘 法因子。因此,如果c 是第i 个字符的数字值, h 是在第i 步计算的部分杂凑值,那么h 根据下 i i i 面的递归公式计算,h = 0,h = α h -c ,最后的杂凑值用 h = h mod size计算。这里n 是杂 0 i -1 i i n 凑的名字中字符的个数。这等价于下列公式 当然,在这个公式中 α的选择对输出结果有重要影响。 α的一种合理的选择是 2的幂,如16 如果杂凑表增长得太大,在不工作时可有方法增大数组的尺寸 (和改变杂凑函数 ),但这很复杂也很少使用。 230 编译原理及实践 或128,这样乘法可以通过移位来完成。实际上,选择 α=128的结果是把字符串看成是基于 128 的数,假定字符值 c 都小于128 (对ASCII字符为真 )。在本文中提到的其他可能性是各种素数 i (见“注意与参考”一节 )。 在公式中 h 的溢出有时也成问题,特别是在双字节整数的机器中对于较大的 α值。如果整 数值用于计算,溢出将导致负数 (在双字节补码表示中 ),并引起执行错误。在这种情况中,通 过在求和循环中执行 mod操作可以得到相同的结果。在程序清单 6-2中给出了杂凑函数h 的C代 码的例子,使用前面的公式, α的值为16(进行4次移位,因为16 = 2 4)。 程序清单 6-2 符号表的杂凑函数 #define SIZE ... #define SHIFT 4 int hash ( char * key ) { int temp = 0; int i = 0; while (key[i] != '\0') { temp = ((temp << SHIFT) + key[i]) % SIZE; ++i; } return temp; } 6.3.2 说明 符号表的行为严重依赖于要翻译的语言的特性。例如,当需要调用插入和删除操作时,它 们如何作用于符号表,以及什么属性插入到表中,不同的语言都有很大的变化。甚至当符号表 建立时翻译 /执行过程的时间和符号表需要存在多长时间,对不同的语言也完全不同。这一节 我们简要说明几种语言中影响符号表行为和实现的有关声明的问题。 在编程语言中经常出现的4种基本说明是:常量说明、类型说明、变量说明和过程/函数说明。 常量声明(constant declaration)包括C语言中的const声明,如 const int SIZE = 199; (C语言还有一个 #define机构来创建常量,但那是在预处理时进行的,而不是严格意义 上的编译器处理阶段 )。 类型声明(type declaration)包括Pascal语言中的类型声明,如 type Table = array [1..SIZE] of Entry; 以及C语言中的struct和union说明,如 struct Entry { char * name; int count; struct Entry * next; }; 这里用名字Entry说明了一个结构类型。C语言中还有一个 typedef机构用来说明类型的别名 typedef struct Entry * EntryPtr; 变量说明(variable declarations)是说明中最常用的形式,包括FORTRAN语言中的说明,如 231 第 6章 语 义 分 析 integer a,b(100) 以及C语言中的说明,如 int a,b[100]; 最后,是过程/函数说明(procedure/function declarations),如程序清单6-2中说明的C函数。 这实际上不比过程 /函数类型的常数说明多什么东西,但因为它们特别的特性,通常从语言说 明中分离出来。这些说明是明确的 (explicit),使用了特定的语言结构进行说明。也可能有隐含 的(implicit)说明,说明依附于执行的指令而不明确说明。例如, FORTRAN和BASIC允许使用 没有明确说明的变量。在这些隐含说明中,使用了一些约定提供由明确说明给出的信息。例如, FORTRAN语言有类型的约定,如果没有使用明确的说明,字母 I到N开头的变量自动说明为整 型,其他的是实型。隐含说明也可称作使用时说明 (declaration by use),因为没有明确说明的变 量在第一次使用时可看成隐含包含了其说明。 通常最容易的是用一张符号表保存所有不同种类的说明的名字,特别是当语言禁止在不同 种类的说明中使用相同的名字。有时候,对每种说明使用不同的符号表也较容易,例如,所有 的类型说明包含在一张符号表中,而所有的变量说明包含在另一张符号表中。对于某些语言, 特别是Algol派生的语言,如C、Pascal和Ada,希望程序不同的区域(如过程)都有独立的符号表, 并按照语言的语义规则链接到一起 (马上我们将更详细地讨论这一点 )。 根据说明的种类,限定名字的属性也不同。常量说明给名字赋一个值,因此有时把常量说 明称作值约束 (value binding)。被约束的值决定编译器如何处理它们。例如, Pascal和Modula-2 要求常量说明的值是静态的,因此由编译器计算。在编译期间编译器可以使用符号表用值来替 代常量名。其他语言,如 C和Ada,允许常量是动态的,即在执行期间才计算。这样的常量处 理起来更像变量,在执行期间必须产生代码来计算它们的值。然而,这样的常量是单一指派 (single assignment),一旦确定了其值就不再改变。常量说明可以明确或隐含地把数据类型约束 到名字。例如,在Pascal语言中,常量的数据类型根据其 (静态)值隐含地确定,而在 C语言中数 据类型显式地给出,就像变量说明一样。 类型说明可以把名字约束为新构造的类型,也可以为存在的已命名的类型创建一个别名。 类型名通常用来和类型等价算法协作,按照语言的规则完成程序的类型检查。本章后面的一节 将专门讨论类型检查,这里不再进一步讨论类型说明。 变量说明最常用于给名字限定数据类型,像在 C语言中 Table symtab; 通过用名字 Table表示的数据类型约束名字为 symtab的变量。变量说明也可以隐含地约束其 他属性。其中对符号表有主要影响的一个属性是说明的作用域 (scope)或说明起作用的程序的区 域(也就是变量说明可到达的区域 )。作用域通常由变量在程序中说明的位置包含,但也可能被 隐含的动态符号影响并和其他说明相互作用。作用域也可能是常量、类型和过程说明的特性。 不久我们将更详细地讨论作用域规则。 涉及作用域的变量通过说明明确或隐含地约束,其属性是为说明的变量分配内存,以及执 行分配的时机(有时称作生存期 (lifetime)或说明的宽度(extent))。例如,在C语言中,所有在函 数外部说明的变量都静态分配 (即在执行开始前进行 ),因此宽度等于整个程序的执行时间,而 在函数内说明的变量只在每个函数调用期间才分配 (因此称作自动 (automatic)分配)。通过在说 明中使用关键字 static,C语言也允许函数内说明的宽度从自动变为静态,例如 int count(void) { static int counter = 0; 232 编译原理及实践 return ++counter; } 函数count有一个静态的局部变量 counter,每次调用都保留其值,因此 count返回当前它 被调用的次数。 C语言也区分说明是用来控制内存分配还是用于类型检查。在 C语言中,任何用关键字 extern开头的说明不用来执行分配。因此,如果前一个函数写成 int count(void) { extern int counter; return ++counter; } 变量counter可以在程序的任何地方分配和初始化)。C语言把分配内存的称作说明 (definitions), 而保留单词“declaration (说明)”用于不需要分配内存的说明。因此,用关键字 extern开头 的说明不是一个定义,但一个标准的变量说明,如 int x; 是一个定义。在 C语言中,相同的变量可能有许多说明,但只有一个定义。 存储器分配策略像类型检查一样在编译器的设计中形成了一个复杂而重要的部分,它是运 行时环境 (runtime environment)结构的一部分。下一章全部研究的都是环境,因此这里就不再 进一步研究分配了,而转向在符号表中作用域和维护作用域策略的分析。 6.3.3 作用域规则和块结构 编程语言中的作用域规则变化很广,但对许多语言都有几条公共的规则。在本节中,我们 讨论其中两条,使用前说明和块结构的最近嵌套规则。 使用前说明(declaration before use)是一条公共规则,在C和Pascal中使用,要求程序文本中 的名字要在对它的任何引用之前说明。使用前说明允许符号表在分析期间建立,当在代码中遇 到对名字的引用时进行查找;如果查找失败,在使用之前就出现说明错误,编译器给出相应的 出错消息。因此,使用前说明有助于实现一遍编译。有些语言不需要使用前说明 (Modula-2是 一个例子 ),在这样的语言中需要单独的一遍来构成符号表:一遍编译是不可能的。 块结构(block structure)是现代语言的一个公共特性。编程语言中的一块 (block)是能包含说 明的任意构造。例如,在 Pascal中,块是主程序和过程 /函数说明。在 C中,块是编译单元 (也就 是代码文件 )、过程/函数说明以及复合语句 (用花括号括起来的语句序列 {. . .})。在C中结 构和联合 (Pascal中的记录 )也可看成块,因为它们包含字段说明。类似地,面向对象编程语言 中的类说明是块。一种语言是块结构 (block structured)的,如果它允许在其他块的内部嵌入块, 并且如果一个块中说明的作用域限制在本块以及包含在本块的其他块中,服从最近嵌套规则 (most closely nested rule):为同一个名字给定几个不同的说明,被引用的说明是最接近引用的 那个嵌套块。 为了说明块结构和最近嵌套规则如何影响符号表,考虑程序清单 6-3的C代码片段。在整段 代码中,有5个块。首先是整个代码的块,它包含整型变量 i和j以及函数f的说明。其次,是f 自身的说明,它包含参数 size的说明。再次,是 f函数体的复合语句,它包含字符变量 i和 temp的说明(函数说明和相关的函数体也可看成表示一个块 )。第四,是包含说明d o u b l e 的j 复合语句。最后,是包含说明 char*j的复合语句。在函数 f内部,符号表中有变量 size和 temp的单独说明,所有这些名字的使用都参考这些说明。对于名字 i的情况,在 f的复合语句 233 第 6章 语 义 分 析 内部i有一个的char局部说明,根据最近嵌套规则,这个说明代替了围绕代码文件块的 i的非 局部int说明。(非局部的 int i被称作在f内部有一个作用域空洞 (scope hole)。)类似地, f中 两个后来的复合语句中的 j的说明取代它们各自块内的非局部的 int说明。在每种情况中,当 局部说明的块存在时, i和j的原始说明被覆盖。 程序清单 6-3 说明嵌套作用域的 C代码片段 int i,j; int f (int size) { char i, temp; ... { double j; ... } ... { char * j; ... } } 在许多语言中,像Pascal和Ada (但没有C),过程和函数都可以嵌套。这对这些语言的运行 环境造成了一个复杂的因素 (在下一章研究 ),但对嵌套作用域没有造成特别的复杂性。例如, 程序清单 6-4的Pascal代码包含了嵌套过程 g和h,但和程序清单 6-3中的C代码有本质上相同的 符号表结构 (当然,除了增加的名字 g和h)。 程序清单6-4 说明嵌套作用域的 Pascal代码片段 program Ex; var i,j: integer; function f ( size: integer ) : integer; var i, temp: char; procedure g; var j: real; begin ... end; procedure h; var j: ^char; begin ... end; begin (* f *) ... end; begin ... end. ( * main program * ) 234 编译原理及实践 为了实现嵌套作用域和最近嵌套规则,符号表插入操作不必改写前面的说明,但必须临时 隐藏它们,这样查找操作只能找到名字最近插入的说明。类似地,删除操作不应删除与这个名 字相应的所有说明,只需删除最近的一个,而显示前面任何的说明。然后符号表构造可以继续 进行:执行插入操作使所有说明的名字进入每个块,执行相应的删除操作使相同的名字从块中 退出。换句话说,符号表在处理嵌套作用域期间的行为类似于堆栈的方式。 为了说明这个结构如何能用实际的方法进行维护,考虑早先描述的符号表的杂凑表实现。 为简单起见,假定与图 6-11类似,进行程序清单6-3中过程f的主体说明之后,符号表如图 6-12a 所示。在处理f的主体的第2个复合语句期间(包含说明char*j),符号表如图6-12b所示。最后, 函数f的块退出之后,符号表如图 6-12c所示。注意,对每个名字,每个“桶”中链接表的行为 就像名字不同说明的堆栈。 索引 “桶” 项列表 索引 “桶” a) 项列表 索引 “桶” b) 项列表 c) 图6-12 符号表内容程序清单 6-3 a) 处理f的主体说明之后 b) 处理f的主体内第 2层嵌套的复合语句的说明之后 c) 退出f的主体之后 (删除其说明 ) 有一系列可能的方法可以实现嵌套的作用域。一种解决办法是为每个作用域建立一个新的 符号表,再从内到外把它们链接在一起,这样如果查找操作在当前表中没有找到名字,就自动 用附上的表继续搜索。离开作用域简单多了,不需要使用删除操作对说明再处理。相反,对应 于作用域的整个符号表能在一步中释放。这个结构对应于图 6-12b的一个例子在图 6-13给出。 在那个图中有 3张表,每个作用域一个,从最内层向最外层链接。离开一个作用域只要求重设 访问指针 (在左边指示 )指向最近的外层的作用域。 235 第 6章 语 义 分 析 图6-13 对应于图6-12b的符号表结构,每个作用域使用独立的表 在符号表的构造期间还需要一些另外的处理和属性计算,这依赖于具体的语言和编译器操 作的细节。一个例子是在 Ada中要求非终结符名字在作用域空洞中仍然是可视的,通过使用类 似于记录字段选择的符号可以引用它,使用与说明非终结符名字的作用域相关的名字。例如, 在程序清单 6-4的Pascal代码中,函数 f内部的全局整型变量 i,在Ada中作为变量 Ex.i仍然是 可视的(使用程序名标识全局作用域 )。因此,会有这样的感觉,在构造符号表时,用名字标识 每个作用域,通过累加嵌套作用域名为作用域中说明的每个名字加上前缀。因此,在程序清单 6-4中名字j的所有出现都区分为Ex.j、Ex.f.g.j和Ex.f.h.j。另外或另一方面,每个作用 域需要分配一个嵌套层(nesting level)或嵌套深度(nesting depth),在每个符号表入口中记录每个 名字的嵌套层。因此,在程序清单 6-4中,程序的全局变量嵌套层次是 0,f的说明(其参数和局 部变量)的层次是1,g 和h 的说明的层次是2。 与刚描述的 Ada中作用域选择特性类似的一种机制是 C++中作用域限定操作符 (scope resolution operator)::。这个操作符允许类说明的作用域从说明的外部进行访问。这可以用来在 类说明的外面完成成员函数的说明: class A { . . . i n t f ( ) ; . . . } ; / / 是f一个成员函数 A : : f ( ) / /这是A 中f 的说明 { ... } 类、过程 (Ada中)以及记录结构都可以看作表示名字的作用域,把局部说明的集合作为一个属 性。在这些作用域能被外部引用的情况下,为每个作用域建立一个单独的符号表是有好处的 (就像在图 6-13)。 至此我们随着程序的文本结构讨论了标准的作用域规则。有时称其为词法作用域 (lexical scope)或静态作用域 (static scope)(因为符号表静态地建立 )。另一种作用域规则用于一些面向 动态的语言 (LISP、SNOBOL较早的版本,以及一些数据库查询语言 )称作动态作用域 (dynamic scope)。这个规则要求嵌套作用域的申请随着执行路径进行,而不是程序原来的安 排。在程序清单 6-5的C代码是一个简单的例子,说明两个规则的不同点。使用 C语言标准的 作用域规则这段代码打印出 1,因为文件层变量 i的作用域扩展到了过程 f。如果使用动态作 用域,程序就打印出 2,因为 f在中main调用, main包含了的说明 (值为2),如果使用顺序调 用处理非终结符引用,它就扩展到 f。动态作用域要求在执行时通过执行插入和删除操作建 立,因为作用域也在运行时进入或退出。因此,使用动态作用域要求符号表变成环境的一部 分,由编译器产生代码来维护它,而不是由编译器直接 (静态地 )建立符号表。动态作用域也 破坏了程序的可读性,因为不模拟程序的执行就不能解决非局部的引用。最后因为变量的数 据类型必须用符号表来维护,所以动态作用域与静态类型检查不兼容 (注意在程序清单 6-5的 代码中,如果 main内的i说明成 double所出现的问题 )。因此,在现代语言中动态作用域很 少使用,我们不再进一步讨论。 236 编译原理及实践 程序清单 6-5 说明静态和动态作用域不同之处的代码 #include int i = 1; void f ( void ) { printf ( " %d\n ", i ) ; } void main ( void ) { int i = 2; f ( ); return 0; } 最后,我们要注意,在图 6-12中的删除操作从符号表中完全消除说明。事实上有必要在表 中保留说明(或至少不剥夺它们的存储区 ),因为编译器的其他部分在后面可能需要引用。如果 它们必须保存在符号表中,那么删除操作只需要仅仅把它们标记成不活动的,而查找操作在搜 索符号表时跳过这些标记的说明。 6.3.4 同层说明的相互作用 关于作用域更深一步的问题是在同一嵌套层中 (即相连到一块 )说明的相互作用。对不同的 说明和要翻译的语言,这些变化很大。许多语言中 (C、Pascal、Ada)一个典型的要求是在同一 层中说明不能使用相同的名字。因此,在 C中,下面连续的说明将引起编译错误: typedef int i; int i; 为检查这个要求,在每次插入前编译器必须执行一次查找,通过某种机制 (如嵌套层)确定在同 一层中任何已存在的说明是否有相同的名字。 更困难的是在相同层的序列中名字相互之间有多少可用的信息。例如,考虑下面的 C代码 片段 int i = 1; void f ( void ) { int i = 2, j = i+1; ... } ... 这里有一个问题: f内部j的值是初始化成 2还是3,即使用的是 i的局部说明还是非局部说明。 根据最近嵌套规则,应该是使用最近的说明—局部说明。事实上这是 C的方法。但是这预示在 处理时每个说明加进符号表,称作顺序说明 (sequential declaration)。可以替代所有要“同时” 处理的说明,在说明部分的最后立即加进符号表。然后说明中任意表达式的名字将引用前面的 说明,不再处理新的说明。这样的说明结构称作并列说明 (collateral declaration),一些函数式 语言,像ML和Scheme,有这样的说明结构。这样的说明规则要求说明不立即加进存在的符号 表中,而累加进一个新的表中 (或临时结构),在处理完所有的说明之后再加进现存的表中。 最后,是递归说明(recursive declaration)结构的情况,说明可以引用其自身或相互引用。这对 于过程/函数说明特别必要,相互递归函数组是公共的(举例来说,在递归下降分析程序中)。在最 237 第 6章 语 义 分 析 简单的形式中,递归函数调用其自身,如在下面的C代码中的函数计算两个整数的最大公因子: int gcd ( int n, int m ) { if ( m == 0 ) return n; else return gcd ( m,n % m ); } 要使能正确编译它,编译器必须在处理函数体之前把函数名 gcd加进符号表。否则,当递归调 用遇到 gcd时就找不到这个名字 (或含义不正确 )。在更复杂的情况中,有一组互相递归调用的 函数,如下面的C代码片段 void f ( void ) {... g ( ) ... } void g ( void ) {... f ( ) ... } 在处理函数体之前只是把每个函数加进符号表是不够的。说明上面的 C代码编译时在 f内部调 用g甚至会产生错误。在 C语言中对这个问题的解决是在 f的说明之前为 g加上一个称作函数原 型(fuction prototype)的说明: v o i d g ( v o i d ) ; /函* 数原型说明 */ void f ( void ) { ... g ( ) ... } void g ( void ) {... f ( ) ... } 这样的说明可以看作是作用域修正 (scope modifier),扩展名字g的作用域使其包含 f。因此,当 到达g的(第1个)原型说明时(与它自己的位置作用域属性一起 ),编译器把g加进符号表。当然在 到达g的主说明 (或定义)之前g的函数体一直是不存在的;而且, g的所有原型必须进行类型检 查以确保在结构上的统一。 相互递归问题还有不同的解决办法。例如,在 Pascal中提供了向前(forward)说明作为过程 / 函数作用域的扩展。在 Moudle-2中,过程和函数 (还有变量)的作用域规则扩展它们的作用域包 含整个说明块,这样就自然进行相互递归调用而不必使用另外的语言机制。这就要求一个处理 步骤,所有的过程和函数在处理其主体的任何部分之前加进符号表。类似的相互递归调用说明 也可用于一些其他的语言。 6.3.5 使用符号表的属性文法的一个扩充例子 现在考虑一个例子,来演示我们已描述过的说明的一些特性,并研究一个属性文法使这些 特性在符号表的行为清晰呈现。这个例子中使用的文法是浓缩了简单算术表达式文法,包括了 对说明的扩充: S → exp exp → (exp) | exp + exp | id | num | let dec-list in exp dec-list → dec-list, decl | decl decl → id = exp 因为属性文法包含层次属性,因此就需要在语法树的根节点进行初始化,这个文法包括一个顶 层的开始符号 S。这个文法只有一个操作符 (加,记号为 +),所以非常简单。它也是二义性的, 我们假定分析器已经构造了语法树,或者已经处理了二义性 (我们把等价的无二义性文法留作 练习 ) 。不过可以包含括号,这样如果愿意,就可以用这个文法写出无二义性的表达式。就像 238 编译原理及实践 在前一个例子中使用的类似的文法,我们假定 num和id 是记号,其结构由扫描器确定 (假定 num是一个数字序列,id 是字符序列)。 包含说明的文法增加的是let 表达式(let expression): exp → let dec-list in exp 在let 表达式中,说明由通过逗号分开的、形如 id = exp的说明序列组成,一个例子是 let x = 2+1, y = 3+4 in x + y 非正式地,let 表达式的语义如下。let记号后面的说明是建立表达式的名字,当这些名字出现 在记号 in后面的exp中时代表它们表示的表达式的值 (exp是let表达式的主体 (body))。let表达 式的值是主体的值,其计算方法是用相应 exp的值代替说明中的每个名字,并根据语义规则计 算主体的值。例如,在前一个例子中, x代表值3(2+1的值),y代表值7(3+4的值)。因此,表 达式自身的值是 10(等于x+y的值,x的值是3,y的值是7)。 从刚给出的语义中,我们看到说明在 let 表达式中表示一种常量说明 (或约束),而let 表达 式表示这个语言的块。为完成这些表达式语义的非正式讨论,需要描述 let 表达式中的作用域 规则和说明的相互作用。注意,这个文法允许仲裁 let 表达式相互之间的嵌套,例如在表达式 let x = 2, y = 3 in ( let x = x+1, y = ( let z=3 in x+y+z ) in ( x+y ) ) 中,我们为 let 表达式的说明建立下列作用域规则。首先,在相同的 let 表达式中不能说明相同 的名字,因此,形如 let x=2, x=3 in x+1 的表达式是非法的,将导致出错。其次,如果任意一个名字没有在某个外围 let 表达式中说明, 也将导致错误。因此,表达式 let x=2 in x+y 是错误的。再次,在 let 表达式中每个说明的作用域按照块结构的最近嵌套规则扩充到 let 的主 体之外。因此,表达式 let x=2 in ( let x=3 in x ) 的值是3而不是2 (因为在内层的let 表达式中的x引用说明x=3,而不是说明 x=2)。 最后。在顺序的相同 let 表达式说明的列表中,我们说明了说明的相互作用。即每个说明 使用前一个说明来处理其表达式中的名字。因此,在表达式 let x=2,y=x+1 in ( let x=x+y, y=x+y in y ) 中,第一个y的值是3(使用前一个x的说明),第2个x的值是5 (使用括起来的 let 的说明),第2个 y的值是8 (使用括起来的y的说明和刚说明x的值)。因此,整个表达式的值是 8。类似地我们请 读者计算前面三重嵌套的 let 表达式的值。 现在我们要开发属性等式,使用符号表记录 let 表达式中的说明并表示刚描述的作用域规 则和相互作用。为简单起见,我们仅用符号表确定表达式是否是错的。我们不写出计算表达式 的值的等式,而把它留作练习。作为替代,我们计算布尔值的合成属性 err,根据前面说明的 规则,如果表达式是错的其值为 true,如果表达式正确其值为 false。为实现这一点,需要两个 继承属性,symtab表示符号表,nestlevel 确定两个说明是否在相同的 let 块内。nestlevel 的值是 一个非负整数,表示块的当前嵌套层。在最外层它的值初始化为 0。 239 第 6章 语 义 分 析 symtab 属性需要一般的符号表操作。因为要写属性等式,在某种程度上表达符号表操作无 须间接的结果,编写插入操作像参数一样操作符号表,返回一个新的符号表加入新的信息,而 原始的符号表没有改变。因此, insert (s, n, l) 返回一个新的符号表,包含来自符号表 s 的所有 信息,另外把名字n 与嵌套层l 相联系,而不改变s (因为我们只确定正确性,不必要联系n 的值, 只是一个嵌套层 )。因为这个说明保证能恢复原始的符号表 s,就无须一个明确的删除操作。最 后,为了测试必须满足正确性的两个准则 (出现在表达式中的所有名字必须在前面说明,而且 在同层中不出现重复说明 ),必须能测试符号表中一个名字的出现,也能取出与出现的名字相 关的嵌套层。用两个操作实现这一点, isin (s, n)返回一个布尔值,决定 n 是否在符号表 s中, lookup (s, n)返回一个整数值,如果存在它给出 n 最近说明的嵌套层,或者如果 n 不在符号表s 中(这将允许使用lookup表示等式,而不首先执行 isin 操作)其值为-1。最后,必须说明初始符 号表中没有入口,我们把这写作 emptytable。 对符号表使用这些操作和转换,现在写出表达式的 symtab、nestlevel 和err 3个属性的属性 等式。完整的属性文法在表 6-9 中。 表6-9 带有let块的表达式的属性文法 文法规则 S→exp exp →exp + exp 1 2 3 exp →( exp ) 1 2 exp → id exp→ num exp →let dec-list in exp 1 2 dec-list → dec-list , decl 1 2 dec-list →decl 语义规则 exp.symtab = emptytable exp.nestlevel = 0 S.err = exp.err exp .symtab = exp .symtab 2 1 exp .symtab = exp .symtab 3 1 exp .nestlevel = exp .nestlevel 2 1 exp .nestlevel = exp .nestlevel 3 1 exp .err = exp .err or exp .err 1 2 3 exp .symtab = exp .symtab 2 1 exp .nestlevel = exp .nestlevel 2 1 exp .err = exp .err 1 2 exp.err = not isin (exp.symtab,id.name) exp.err = false dec-list.intab = exp .symtab 1 dec-list.nestlevel = exp .nestlevel + 1 1 exp .symtab = dec-list.outtab 2 exp .nestlevel = dec-list.nestlevel 2 exp .err = (dec-list.outtab = errtab) or exp .err 1 2 dec-list .intab = dec-list .intab 2 1 dec-list .nestlevel = dec-list .nestlevel 2 1 decl.intab = dec-list .outtab 2 decl.nestlevel = dec-list .nestlevel 2 dec-list .outtab = decl.outtab 1 decl.intab = dec-list.intab decl.nestlevel = dec-list.nestlevel dec-list.outtab = decl.outtab 240 编译原理及实践 文法规则 decl → id = exp (续) 语义规则 exp.symtab = decl.intab exp.nestlevel = decl.nestlevel decl.outtab = if (decl.intab = errtab) or exp.err then errtab else if lookup(decl.intab,id.name) = decl.nestlevel) then errtab else insert(decl.intab,id.name,decl.nestlevel) 在最顶层,分配了两个继承属性值而留下了合成属性的值。因此,文法规则 S →exp有3个 相关的属性等式 exp.symtab = emptytable exp.nestlevel = 0 S.err = exp.err 文法规则exp →(exp)也有类似的规则。 对于规则exp →exp + exp (像通常一样,在写属性等式时对非终结符编号 ),下面的规则 1 2 3 表示右边的表达式从左边的表达式继承了属性 symtab和nestlevel,并且如果右边的表达式包含 了至少一个错误,左边的表达式也就包含一个错误: exp .symtab = exp .symtab 2 1 exp .symtab = exp .symtab 3 1 exp .nestlevel = exp .nestlevel 2 1 exp .nestlevel = exp .nestlevel 3 1 exp .err = exp .err or exp .err 1 2 3 仅当在当前的符号表中不能找到名字 id时,规则exp → id产生一个错误(我们把标识符的 名字写成id.name,并假定它由扫描器或分析程序计算 ),这样相关的属性等式是 exp.err = not isin (exp.symtab, id.name) 另一方面,规则 exp → num 永远不会产生错误,因此属性等式是 exp.err = false 现在开始讨论 let表达式,使用文法规则 exp → let dec-list in exp 1 2 和相关的说明规则。在 let表达式的规则中, dec-list由一系列必须加进当前符号表的说明组成。 通过用 dec-list联系两个独立的符号表来表示这一点:从 exp 继承的输入表 intab,以及输出表 1 outtab,它包含必须传递到 exp 的新的(和旧的)说明。因为新的说明可能包含错误 (如重复说明 2 相同的名字 ),必须考虑从dec-list 传递的一个特别的 errtab符号表。最后,当 dec-list 包含一个 错误(这种情况outtab出错)或者let 表达式的主体 exp 包含一个错误, let 表达式也出错。属性等 2 式是 241 第 6章 语 义 分 析 dec-list.intab = exp .symtab 1 dec-list.nestlevel = exp .nestlevel + 1 1 exp .symtab = dec-list.outtab 2 exp .nestlevel = dec-list.nestlevel 2 exp .err = (dec-list.outtab = errtab) or exp .err 1 2 注意当进入let 块时嵌套层也增加1。 还剩下为说明列表和单个的说明开发等式。根据说明的顺序性规则,说明列表必须在处理 列表过程中累积。因此,给定规则 dec-list → dec-list , decl 1 2 像intab传递到decl一样,outtab从dec-list 传递,因此给定decl访问说明先于它在列表中。对于 2 单个说明的情况(dec-list → decl ),执行了标准的继承和移位。完整的等式在表 6-9中。 最后,讨论单个说明的情况 decl → id = exp 在这个情况中继承属性decl.intab立即传递到exp(因为在这个语言中说明是非递归的,因此 exp必 须找到这个id 名字的前一个说明,而不是当前的那个 )。然后,如果没有错误, id.name用当 前嵌套层插入到表中,并作为说明继承 outtab传递回去。在3种情况会出现错误。首先,在前面 的说明中已经有了错误,这种情况是: decl.intab是errtab,errtab必须作为 outtab传递。其次, 错误可能在exp中出现:如果是这样,则由 exp.err指出,outtab也会引起变成errtab。再次,说 明可能是一个名字在同一嵌套层次中的重复说明。这必须通过执行一次 lookup来检查,这个错 误也必须把 outtab强制变成errtab来报告(注意,如果 lookup没有找到id.name,则返回- 1,在 当前的嵌套层没有匹配,因此没有错误产生 )。decl.outtab完整的等式在表6-9中给出。 这就完成了属性等式的讨论。 6.4 数据类型和类型检查 编译器的主要任务之一是数据类型信息的计算和维护 (类型推论(type inference))以及使用这 些信息确保程序的每一部分在语言的类型规则作用下有意义 (类型检查(type checking))。通常地, 这两个任务密切相关并一起执行,但只提及类型检查。数据类型信息可以是静态的或动态的或 是两者的混合。在大多数 LISP语系的语言中,类型信息完全是动态的。在这样的语言中,编译 器必须在执行时产生代码完成类型推论和类型检查。在大多数传统语言中,如 Pascal、C和Ada, 类型信息主要是静态的,主要在程序执行之前进行正确性检查。静态类型信息也用来确定每个 变量分配所需要的存储器大小以及存储器的访问方式,这可以用来简化运行环境 (将在下一章 讨论这一点 )。这一节将只关心静态数据类型。 数据类型信息可以用几种不同的形式出现在程序中。在理论上,数据类型 (data type)是值 的集合,更精确一点,是那些值上某几种操作的值的集合。例如,数据类型 integer在一个 编程语言中指的是数学整数的子集,以及算术操作,如 +和*,由语言说明提供。在编译器构造 的实际领域,这些集合通常用类型表达式 (type expression)描述,有一个类型名,如 integer, 或结构表达式,如 a r r a y [ 1 . . 1 0 ] o f r e,a其l操作通常假定或隐含。类型表达式在一个 程序中可能出现几次。这些表达式包括变量说明,如 var x: array [1..10] of real; 242 编译原理及实践 它把类型与一个变量名和类型说明相关,又如 type RealArray = array [1..10] of real; 它说明一个新的类型名,用于以后的类型或变量说明。这样的类型信息是明确的。类型信息也 可能是隐含的,如 Pascal中常量说明的例子 const greeting = "Hello!"; 这里根据Pascal的规则,greeting隐含说明为array [1..6] of cha类r 型。 包含在说明中显式或隐含定义的类型信息保持在符号表中,当引用相关的名字时,由类型 检查器取出,新的类型则从这些类型中推断出来,并和语法树中相应的节点关联。例如,在表 达式 a[i] 中名字a和i的数据类型从符号表中取出,如果 a的类型是array [1..10] of real,而i 的类型是 integer,那么子表达式 a[i]的类型为real,并被判断为正确的 (i的值是否在 1和 10之间的问题是范围检查(range checking)问题,不能像通常一样静态确定 )。 编译器表示数据类型的方式、符号表维护类型信息的方式以及类型检查器推断类型使用的 规则等,都依赖于语言中可用的类型表达式的种类和语言中管理这些类型表达式使用的类型 规则。 6.4.1 类型表达式和类型构造器 编程语言通常包含一些内嵌的类型,如 int和double。这些预说明 (predefined)类型或者 对应于由各种机器体系结构内部提供的数字数据类型,其操作作为机器指令已经存在,或者是 像boolean或char一样属性很容易实现的基本类型。这些数据类型是简单类型 (simple type), 其值呈现为无明确的内部结构。对于整数一种典型的表示是 2字节,或 4字节作为 2字节的补充 形式。实数或浮点数的典型表示,是 4或8字节数,带一个符号位,一个指数字段和一个分数 (或尾数)字段。字符的典型表示是一字节的 ASCII代码,对布尔值是一个字节,只使用最低的 一位(1=true,0=false)。有时语言也在如何实现这些预说明类型上加强限制。例如, C语言标准 要求double浮点数类型至少有 10位十进制数字的精度。 在C语言中一种有趣的预说明类型是 void类型。这个类型没有值,因此表示空的集合。它 用于表示没有返回值的函数 (即过程),也可表示一个指针指向未知类型。 在一些语言中,可以说明一些新的数据类型。典型的例子是子界类型 (subrange type)和枚 举类型(enumerated type)。例如,在Pascal语言中由0~9组成的整数的子界类型可以说明为 type Digit = 0..9; 在C语言中由名字为 red、green和blue组成的枚举类型说明为 typedef enum { red, green, blue } Color; 子界和枚举都可以作为整数实现,或者使用较小的足以表示所有值的存储器。 给定一个预说明类型的集合,使用类型构造器 (type constructor),如数组 (array)、记录 ( record )和结构 (struct ),可以创建新的类型。这些构造可以看作是函数,把存在的类型作 为参数,而用依赖于构造的一个结构返回新的类型。这些类型通常称作结构类型 (structured type)。在这些类型的分类中,了解类型表示的值的集合的特性是重要的。通常,在类型构造的 参数值的基本集上,它密切对应于一组操作。我们通过列出一些公共的构造并把它们与集合操 作比较来说明这一点。 243 第 6章 语 义 分 析 1) 数组 数组类型构造有两个类型参数,一个是索引类型 (index type),另一个是元素类型 (component type),并产生一个新的数组类型。在类 Pascal语言中我们写作 array [ 索引类型] of 元素类型 例如,Pascal类型表达式 array [Color] of Char; 创建一个数组类型,其索引类型是 Color,元素类型是Char。通常对于索引类型有一些限制。 例如,在Pascal语言中索引类型限制为所谓的序数类型 (ordinal types):这些类型的每个值都有 一个直接前驱和直接后继。这样的类型包括整数和字符的子界类型以及枚举类型。对照地在 C 语言中,整数作用域只允许从 0开始,并只能指定大小来代替索引类型。事实上在 C语言中没 有关键字对应于 array,只是仅仅加上在括号表示作用域的后缀来说明数组类型。因此对前面 Pascal的类型表达式在C中没有直接的等式,但C的类型说明 typedef char Ar [3]; 说明了Ar类型,是与前面的类型等价的类型结构 (假定Color有3个值)。 数组表示的是元素类型的值的序列,并由索引类型的值进行索引。也就是说,如果索引类 型有值I的集合,元素类型有值C的集合,那么对应于类型 array [索引类型] of 元素类型值的 集合是通过I的元素索引的C的元素有限序列的集合,或者在数学项中,函数 I→C的集合。数组 类型的值的相关操作由单个下标的操作组成,它可以用于给元素赋值或从元素中取出值: x:= a[red]或a[blue] := y。 数组一般根据索引从小到大分配连续的存储空间,允许在执行期间使用自动的偏移量计算。 所需存储空间的大小是 n*size,这里n 是在索引类型中值的数目, size是元素类型的一个值所需 存储器的大小。因此,如果每个整数占据 4个字节,类型array [0..9] of integer 的一 个变量需要 40字节的存储空间。 数组说明中的一种复杂情况是多维数组 (multidimensioned arrays)。这通常可以通过重复应 用数组类型构造来说明,如 a r r a y [ 0 . . 9 ] o f a r r a y [ C o l o r ] o f i n t;e g e r 或者进行简化,把索引集合列在一起: a r r a y [ 0 . . 9 , C o l o r ] o f i n t e;g e r 在第1种情况中出现的下标如 a[1][red],第2种情况下写成 a[1,red]。多重下标的一个问 题是在存储器中值的序列可以用不同的方式,索引过程是:首先是第一个索引,然后是第 2个 索引,或者相反。用第一种索引方式的结果是在存储器中值的顺序是 a[0,red],a[1,red], a[2,red] ,...... ,a[9,red],a[0,blue] ,a[1,blue]......等等 (这称作列前提 形式 (column-major form)) ,用第 2种索引方式的结果是在存储器中值的顺序是 a[0,red], a[0,blue] ,a[0,green],a[1,red],a[1,blue],a[1,green]...... 等等(这称作 行前提形式 (row-major form))。如果多维数组重复说明的版本和简化版本是等价的 (也就是说 a [ 0 , r e d ]= a [ 0 ] [ r e d ],)那么必须使用行前提形式,因为可以分开加上不同的索引: a[0]的类型必须是 a r r a y [ C o l o r ]o f i n t e g e而r且必须指向一个连续的存储区。 FORTRAN语言,没有多维数组的局部索引,传统上使用列前提形式实现。 有时候,语言允许使用没有说明索引作用域的数组。这样的开放索引数组 (open-indexed array)对函数的数组参数说明特别有用,这样函数可以处理不同大小的数组。例如, C语言说明 244 编译原理及实践 void sort ( int a[], int first, int last ) 可以用来说明一个分类程序,用于任意大小的数组 a (当然,在调用时必须使用一些方法来确 定实际的大小。在这个例子中,使用了其他的参数 )。 2) 记录 记录(record)或结构(structure)类型构造器接受一个名字列表和相关的类型并构造 一个新的类型。如在 C语言中 struct { double r; int i; } 记录与数组不同,不同类型的元素可以组合起来 (在数组中所有的元素都有相同的类型 ),使用 名字(而不是索引 )访问不同的元素。记录类型的值大致对应于其元素类型值的笛卡儿积,使用 名字而不是位置访问元素。例如,前面给定的记录大致对应于笛卡儿积 R×I,这里R是相应的 double类型的数据集合, I 是相应的int类型的数据集合。更准确地,给定的记录对应于笛卡 儿积(r×R)×(i×I ),这里名字r和i识别各个元素。这些名字通常使用圆点符号 (dot notation) 选择表达式中相应的元素。因此,如果 x是给定记录类型的一个变量,那么 x.r表示第 1个元素, x.i表示第2个元素。 一些语言具有纯的笛卡儿积类型构造器。这样的一种语言是 ML,这里 int*real是整数 和实数笛卡儿积的符号。类型 int*real的值写成两个一组,如 (2,3.14), 元素通过投影函 数fst和snd (表示第1和第2)访问:fst(2,3.14) = 2和snd(2,3.14) = 3.1。4 记录或笛卡儿积的标准实现方法是顺序地分配存储器,为每个元素类型分配一块存储器。 因此,如果一个实数需要 4个字节,一个整数需要两个字节,那么前面给定的记录结构需要 6个 字节的存储器,分配如下: (4字节) (2字节) 3) 联合 联合类型对应于联合操作集合。在 C语言中它可以直接通过 union说明使用,例 如说明 union { double r; int i; } 说明了实数和整数的联合类型。严格地讲,这是一个脱节的联合 (disjoint union),因为每个值 可以看成是实数或整数,但不能同时是两者。通过访问值的元素名字可以解释的更清楚一些: 如果x是给定的联合类型的一个变量,那么 x.r表示x是实数时的值,而 x.i表示x是整数时的 值。在数学上联合的说明写成 (r×R) ∪ (i×I )。 联合的标准实现方法是为每个元素并行地分配存储器,这样每个元素类型的存储器与所有 其他的类型相重叠。因此,如果一个实数需要 4个字节,一个整数需要两个字节,那么前面给 定的联合结构仅需要4个字节的存储器 (其元素所需的最大的存储器 ),存储器分配如下: (4字节) (2字节) 245 第 6章 语 义 分 析 事实上,这样的实现需要联合被解释成脱节的联合,因为整数的表示不会适合相应的实数的表 示。实际上,编程者区分这些值没有什么意义,这样的联合在数据解释中会导致错误,也提供 了一种方法绕过类型检查器。例如,在 C语言中如果x是给定的联合类型的一个变量,那么 x.r = 20; printf ( "%d",x.i ); 将引起一个编译错误,不会打印出值 2,而是一个垃圾值。 在联合类型中的这种不安全性已由许多不同的语言设计者处理。例如在 Pascal中,联合类 型使用一种所谓的可变记录 (variant record) 说明,其中序数类型的值记录在一个判别式 (discriminant)元素中并用来区分想要的值。因此,在 Pascal中前面的联合类型可以写成 record case isReal: boolean of true: (r: real); false: (i: integer); end; 现在这个类型的变量 x有3个元素:x.isReal(布尔值)、x.r和x.i,并且isReal字段在存储 器中分配一个独立的非覆盖的空间。当赋一个实数值时,如 x . r : = 2 . 0,同时也要赋值 x.isReal := tru。e实际上,这个机构相对而言是没有用的 (至少在类型检查时编译器的使 用),因为判别式的赋值和值的辨别可以分开。当然, Pascal允许通过在说明中删除其名字说明 判别式元素(但不使用其值区分 case),如在 record case bollean of true:(r: real); false:(i: integer); end; 中不再为判别式分配存储空间。因此, Pascal编译器在合法性检查时几乎从不进行任何尝试使 用判别式。另一方面, Ada有类似的机制,但是强调只要一个联合元素被赋值,判别式必须同 时赋值进行合法性检查。 在像ML这样的函数式语言中采用了一种不同的方法,联合类型的说明使用了一根竖线表 示联合,为每个元素给定一个名字来区分它们,如: IsReal of real | IsInteger of int 现在当使用相应的类型的值时也必须使用名字 IsReal和IsInteger,就像在(IsReal 2.0) 或(IsInteger 2)中一样。名字 IsReal和IsInteger称作值构造器(value constructor),因 为它们“构造”了这个类型的值。因为当它们参照这个类型的值时总必须使用,不会有解释错 误发生。 4) 指针 指针类型由引用另一个类型值的值组成。因此,指针类型的值是一个存储器地址, 其中保存着其基类型的值。指针类型经常被看成是数字类型,因为在其上可以进行算术运算, 如加上偏移量,乘上比例因子等。然而它们不是真正的简单类型,因为它们是应用指针类型构 造器从已有的类型中构造出来的。它也没有标准的集合操作直接对应于指针类型构造器,就像 笛卡儿积对应于记录构造器一样。指针类型在类型系统中占有比较特殊的位置。 在Pascal中字符^对应于指针类型构造器,因此类型表达式 ^integer表示“整数的指针”。 在C中,等价的类型表达式是 int*。指针类型值上的标准基本操作是解除引用 (dereference)操 作。例如,在 Pascal中,^表示解除引用操作符 (和指针类型构造器 ),如果p是类型 ^integer 的一个变量,那么 p^是p解除引用的值,类型为 integer。C中也有类似的规则, *解除指针 变量的引用,并写成 *p。 246 编译原理及实践 指针类型在描述递归类型时最有用,我们简要地进行讨论。在这里,指针类型构造器最常 用于记录类型。 指针类型基于目标机器的地址的大小分配空间。通常是 4字节,有时是 8字节。有时机器的 体系结构强制更复杂的分配方案。例如,在基于 DOS的PC中,要区别近指针 (段内地址, 2字 节)和远指针 (段外地址, 4字节)。 5) 函数 我们已经注意到数组可以看成从索引集到元素集的函数。许多语言 (但不是Pascal 或Ada)都有描述函数类型更一般的能力。例如在 Modula-2中说明 VAR f: PROCEDURE (INTEGER): INTEGER; 说明变量 f是函数 (或过程 )类型,带有一个整数参数,并产生一个整数结果。在数学符号中, 这个集合描述成函数 {f: I→I },这里I 是整数的集合。在ML语言中,相同的类型被写成 int>int。C语言也有函数类型,但它们必须用有些笨拙的符号写成“指向函数的指针”。例如, 刚给出的 Modula-2的说明写成 C 语言是 int (*f) (int); 函数类型按照目标机器的地址的大小分配空间。根据语言和运行时环境的组织方式的不同, 函数类型需要给代码指针分配空间 (指向实现函数的代码 )或给代码指针和环境指针分配空间 (指 向运行环境中的位置 )。环境指针的作用将在下一章讨论。 6) 类 大多数面向对象的语言都有类似于记录说明的类说明,它所包含的操作说明除外, 那称作方法(method)或成员函数(member function)。类说明在一个面向对象的语言中可以创建 或不创建新的类型 (在C++中创建)。即使这样,类说明不仅仅是类型,因为它们允许使用属于 类型系统的特性,如继承和动态联编 。这些后期的特性必须通过独立的数据结构维护,如类 继承(class hierarchy) (直接非循环图),用于实现继承性,以及虚拟方法表 (virtual method table), 用于实现动态联编。下一章我们将再次讨论这些结构。 6.4.2 类型名、类型说明和递归类型 具有丰富类型构造器的语言通常也给编程者提供一个机制给类型表达式赋名。这样的类型 说明(type declaration)(有时也称作类型说明(type difinition))包括C语言中的typedef机制和Pascal 语言中的类型说明。例如 C语言代码 typedef struct { double r; int i; } RealIntRec; 说明名字 RealIntRec作为记录类型的名字,它由在其之前的 struct类型表达式构造。在 ML语言中,类似的说明是(但没有字段名): type RealIntRec = real*int; C语言有附加的类型命名机制,名字可以直接用 struct或union构造器关联,而无须直接使 用typedef。例如,C代码 struct RealIntRec { double r; 在一些语言中,如 C++,继承是类型系统的镜像,因为子类可以看作是子类型 (类型S可以看作是类型 T的子 类型,如果它所有的值都可看成是 T的值,或者,在集合术语中,如果 S ∈T)。 247 第 6章 语 义 分 析 int i; }; 也说明了类型名 RealIntRec ,但它在变量说明中必须使用 struct构造器名: s t r u c t R e a l I n t R e c x ; /说*明x 是一个R e a l I n t R e c类型的变量 */ 就像变量说明使变量名进入符号表一样,类型说明也使说明的类型名进入符号表。产生的一 个问题是是否类型名也像变量名样可以重用。通常这是不允许的 (作用域嵌套规则允许除外 )。 对这个规则 C语言有一个小的例外,与 struct或union相关的名字可以像 typedef名字一样 重用: struct RealIntRec { double r; int i; }; typedef struct RealIntRec RealIntRec; /* 一个合法的说明 */ 通过考虑struct说明引入的类型名为整个字符串“ struct RealIntRec”可实现这一点, 它与typedef 引入的类型名 RealIntRec不同。 类型名与符号表中属性相关的方法和变量说明相似。这些属性包括作用域 (它在符号表结 构中可以继承 )和对应于类型名的类型表达式。因为类型名可以出现在类型表达式中,与前一 节讨论的函数的递归说明类似,就出现了类型名递归使用的问题。在现代的编程语言中这样的 递归数据类型(recursive data type)特别重要,包括列表、树以及其他结构。 在处理递归类型方面,语言通常分成两组。第 1组由允许在类型说明中直接使用递归的语 言组成。这样的一种语言是 ML 。例如。在 ML中,包含整数的二叉搜索树可以说明为 d a t a t y p e i n t B S T = N i l | N o d e o f i n t * i n t B S TS*Ti n t B 这可以看成intBST的说明,即是 Nil值和整数与 intBST自身两个拷贝 (一个表示左子树,一 个表示右子树)笛卡儿积的联合。等价的C语言说明(形式稍有改变)是 struct intBST { int isNull; int val; struct intBST left,right; }; 然而,这个说明在 C语言中将产生一个错误消息,是由对类型名 intBST的递归使用引起的。 问题是这些说明没有确定分配一个 intBST类型的变量所需的存储器的大小。这一类语言,如 ML,能接受这样的说明,在执行之前不需要这样的信息,提供一种一般的存储器自动分配和 释放机制。这样的存储器管理工具是运行时环境的一部分,将在下一章讨论。 C语言没有这样 的机制,因此必须使这样的递归类型说明是非法的。 C是第2组语言的代表—在类型说明中不 允许直接使用递归。 对只允许间接使用递归的语言的解决办法是通过指针。在 C语言中intBST正确的说明是 struct intBST { int val; struct intBST *left,*right; }; typedef struct intBST * intBST; 248 编译原理及实践 或 typedef struct intBST * intBST; struct intBST { int val; intBST left, right; }; (在C语言中,递归说明需要使用递归类型名说明的 struct或union形式)。在这些说明中每个 类型所需的存储空间大小直接由编译器计算,但值的空间必须由编程者通过使用 malloc这样 的分配过程进行手工分配。 6.4.3 类型等价 给定语言可能的类型表达式,类型检查器经常需要回答何时两个类型表达式表示相同的类 型。这就是类型等价 (type equivalence)问题。一种语言有许多种可能的方法说明类型等价。这 一节我们简要讨论类型等价最常用的形式。在这里,这样描述类型等价,当在编译器的语义分 析程序中,即当函数 function typeEqual ( t1, t2 : TypeExp ) : Boolean; 接受两个类型表达式,如果根据语言的类型等价规则它们表示相同的类型就返回 true,否则返 回false。对于不同的类型等价算法,将给出这个函数的几种不同的伪代码描述。 一种有关直接描述类型等价算法的方法是类型表达式在编译器内表示。一种简单的方法是 使用语法树表示,因为这使得从说明的语法直接转换到类型的内部表示十分容易。对于这样表 示的一个具体例子,考虑图 6-14给出的类型表达式和类型说明的文法。其中有我们已讨论过的 许多类型结构的简单版本。但是没有允许关联新的类型名到类型表达式的类型说明 (因此不可 能有递归类型,尽管出现了指针类型 )。对应于图中的文法规则,要为类型表达式描述一个可 能的语法树结构。 首先考虑类型表达式 record x: pointer to real; y: array [10] of int end 这个类型表达式可以用下面的语法树表示 这里记录的子节点表示成同属列表,因为记录元素的数目是任意的。注意,表示简单类型的节 点构成了树的叶子。 var-decls → var-decls ; var-decl | var-decl var-decl → id : type-exp 249 第 6章 语 义 分 析 type-exp → simple-type | structured-type simple-type → int | bool | real | char | void structured-type→ array [num ] of type-exp| record var-decls end | union var-decls end | pointer to type-exp | proc ( type-exps ) type-exp type-exps → type-exps , type-exp | type-exp 图6-14 类型表达式的简单文法 类似地,类型表达式 proc ( bool, union a:real; b:char end, int ) : void 可以用以下语法树表示 注意,参数类型也给定为同属列表,而结果类型(在这个例子中是 void)通过使它直接成为 proc的一个子节点来区分。 我们描述的第一种类型等价,也是仅有的可用于缺省类型名的,是结构等价 (structural equivalence)。在这个等价观点中,两个类型当且仅当它们有相同的结构时它们才相同。如 果用语法树表示类型,这个规则说两个类型是等价的,当且仅当它们的语法树结构是同一 的。在练习中有一个例子,说明如何检查结构等价。程序清单 6-6给出了函数 typeEqual的伪 代码描述,它是图 6-14中文法给出的两个类型表达式的结构等价,使用了我们刚描述过的 语法树。 我们从程序清单 6-6的伪代码描述中注意到这个版本的结构等价意味着两个数组是不等价 的,除非它们有相同的大小和元素类型,两个记录是不等价的,除非它们有相同的元素并且元 素有相同的名字和顺序。在一个结构等价算法中,可能有一些不同的选择。例如,在确定等价 性时数组的大小可以被忽略,也可能允许结构或联合的元素以不同的顺序出现。 当在类型说明中说明了类型表达式新的类型名时,可以说明限制性更强的类型等价。在图 6-15中,我们修改了图 6-14的文法以包含类型说明,同时限制变量说明和类型子表达式为简单 类型和类型名。对这些说明不能再写成 record x: pointer to real; y: array [10] of int end 而必须代替为 t1 = pointer to real; 250 编译原理及实践 t2 = array [10] of int; t3 = record x: t1; y: t2 end 程序清单6-6 函数typeEqual的伪代码,测试图 6-14文法类型表达式的结构等价 function typeEqual ( t1, t2 : TypeExp ) : Bollean; var temp : Boolean; p1, p2 : TypeExp; begin if t1 and t2 are of simple type then return t1 = t2 else if t1.kind = array and t2.kind = array then return t1.size = t2.size and typeEqual ( t1.child1, t2.child1 ) else if t1.kind = record and t2.kind = record or t1.kind = union and t2.kind = union then begin p1 := t1.child1 ; p2 := t2.child1 ; temp := true ; while temp and p1≠nil and p2≠nil do if p1.name≠p2.name then temp := false else if not typeEqual ( p1.child1 , p2.child1 ) then temp := false else begin p1 := p1.sibling ; p2 := p2.sibling ; end ; return temp and p1 = nil and p2 = nil ; end else if t1.kind = pointer and t2.kind = pointer then return typeEqual ( t1.child1 , t2.child1 ) else if t1.kind = proc and t2.kind = proc then begin p1 := t1.child1 ; p2 := t2.child1 ; temp := true ; while temp and p1≠nil and p2≠nil do if not typeEqual ( p1.child1 , p2.child1 ) then temp := false else begin 251 第 6章 语 义 分 析 p1 := p1.sibling ; p2 := p2.sibling ; end; return temp and p1 = nil and p2 = nil and typeEqual( t1.child2 , t2.child2 ) end else return false; end ; (* typeEqual *) var-decls → var-decls ; var-decl | var-decl var-decl → id :simple-type-exp type-decls → type-decls ; type-decl | type-decl type-decl → id = type-exp type-exp → simple-type-exp | structured-type simple-type-exp → simple-type | id simple-type → int|bool|real|char|void structured-type → array [num] of simple-type-exp| record var-decls end | union var-decls end | pointer to simple-type-exp | proc ( type-exps ) simple-type-exp type-exps → type-exps , simple-type-exp | simple-type-exp 图6-15 带类型说明的类型表达式 现在可以说明基于类型名的类型等价,这种形式的类型等价称作名等价 (name equivalence):两 个类型表达式是等价的,当且仅当它们是相同的简单类型或有相同的类型名。这是一种非常强 的类型等价,因为给定类型说明 t1 = int; t2 = int 类型 t1和t2是不等价的 (因为名字不同 )对int也不等价。纯的名等价非常容易实现,因为 typeEqual函数可写成以下几行: function typeEqual( t1,t2 : TypeExp ) : Boolean; var temp : Boolean ; p1, p2 : TypeExp ; begin if t1 and t2 are of simple type then return t1 = t2 else if t1 and t2 are type names then return t1 = t2 else return false ; end; 252 编译原理及实践 当然,对应于类型名的实际的类型表达式必须进入符号表,以允许后面为存储器分配计算存储 器大小,以及检查操作的有效性,如指针解除引用和元素选择。 名等价中一个复杂的因素是类型表达式不同于简单类型,或类型名在变量说明中继续被使 用,或者作为类型表达式的子表达式。在这些情况下,类型表达式可能没有给定明确的名字, 编译器将产生一个类型表达式的中间名,与其他任何名字都不同。例如,给定变量说明 x: array [10] of int; y: array [10] of int; 对应于类型表达式array [10] of in,t 变量x和y被赋予不同的(和唯一的)类型名。 在出现类型名时可能保留结构等价。对这种情况,当遇到一个名字时,必须从符号表中取 出它对应的类型表达式。这可以通过在程序清单 6-6的代码中加进下列情况来实现, else if t1 and t2 are type names then return typeEqual ( getTypeExp(t1) , getTypeExp(t2)) 这里getTypeExp是一个符号表操作,返回与其参数 (必须是一个类型名)相关的类型表达式结构。 这要求每个类型名必须用表示其结构的类型表达式插入到符号表,或者至少由类型说明产生类 型名的链,如 t2 = t1; 在符号表中最后带回类型结构。 当可能有递归类型引用时,实现结构等价必须小心,因为刚才描述的算法会导致无限循环。 通过改变调用typeEqual( t1 , t2 )的方法可以避免这种情况,这里 t1和t2是类型名,假定它们已 经潜在地等价。然后如果函数曾经返回相同的调用,在那里就可说明成功。例如,考虑类型说 明 t1 = record x: int; t: pointer to t2; end; t2 = record x: int; t: pointer to t1; end; 给定调用typeEqual ( t1 , t2 ),函数typeEqual 将假定t1和t2潜在地等价。然后取出t1和t2的结构, 并且算法将成功进行直到调用 typeEqual ( t2 , t1 )分析指针说明的子孙类型。这个调用将立即返 回true,因为在初始调用中已经假定了它们潜在等价。通常,这个算法需要进行成功地假设, 那一对类型名是相等的,并在一个列表中累积假设。最后,当然,算法或者成功,或者失败 (即它不会无限循环 ),因为在任何给定的程序中只有有限的类型名。我们把 typeEqual伪代码的 修改细节留作练习(见注意与参考节)。 类型等价最后一个变化是 Pascal和C使用的名等价的一个弱化的版本,称作说明等价 (declaration equivalence)。在这个方法类型中,像 t2 = t1; 这样的说明是作为类型别名 (aliase)解释的,而不是新的类型 (作为名等价中)。因此,给定说明 t 1 = i n t; t2 = int 253 第 6章 语 义 分 析 t1和t2对int等价(即它们仅是类型名 int的别名)。在这个类型等价版本中,每个类型名等价 于某个基类型名,它或者是一个预说明类型,或者是由类型构造器产生的类型表达式给定的。 例如,给定说明 t1 = array [10] of int; t2 = array [10] of int; t3 = t1; 类型名 t1和t3根据说明等价是等价的,但和 t2都不等价。 为实现说明等价,符号表必须提供一个新的操作 getBaseTypeName,它取出基类型名而不 是相关的类型表达式。在符号表中,一个类型名如果是预说明类型或由类型表达式给出,而不 只是另一个类型名,它就被区分为基类型名。注意,说明等价类似于名等价,在检查递归类型 时解决无限循环问题,因为如果两个基类型名有相同的名字只能是说明等价。 Pascal一律使用说明等价,而 C对结构和联合使用说明等价,但对指针和数组使用结构 等价。 有时,一种语言将提供结构、说明或名等价,对不同的类型说明使用不同形式的等价。例 如,ML语言允许使用保留关键字 type把类型名说明为别名,如说明 type RealIntRec = real*int; 这把RealIntRec说明成笛卡儿积类型 real*int的别名。另一方面,说明完全创建了一个新 的类型,如说明 datatype intBST = Nil | Node of int*intBST*intBST 注意, datatype说明也必须包含值构造器名 (在给定的说明中是 Nil和Node)。而不像 type 说明。这使新类型的值能从已存在的类型的中区分出来。因此,给定说明 datatype NewRealInt = Prod of real*int; 值(2.7,10)是类型 RealIntRec或real*int的,而值 Prod(2.7,10)是类型NewRealInt的(而不是real*int)。 6.4.4 类型推论和类型检查 现在,基于类型的表示和前一节讨论的 typeEqual操作,我们对一个简单语言的语义分析动 作方面的类型检查器进行描述。使用的语言具有图 6-16给定的文法,包括图 6-14中类型表达式 的一个小的子集,加上了少量的表达式和语句。我们还假定符号表的可用性包括变量名和相关 的类型,插入操作,在表中插入名字和类型,及查找操作,返回名字的相关类型。在属性文法 中我们将不指定这些操作本身的特性。我们将分别讨论每种语言构造的类型推断和类型检查规 则。语义动作的完整列表在表 6-10中给出。这些动作没有用纯的属性文法形式给出,并且使用 符号:=而不是表 6-10规则中的等号来指示。 program → var-decls ; stmts var-decls → var-decls ; var-decl | var-decl var-decl → id : type-exp type-exp → int|bool|array n[um]of type-exp stmts → stmts ; stmt | stmt stmt → if exp then stmt | id := exp 图6-16 说明类型检查的简单文法 254 编译原理及实践 1) 说明 说明引起标识符的类型进入符号表。因此,文法规则 var-decl → id : type-exp 有相应的语义动作 insert ( id .name, type-exp.type) 把标识符插入到符号表并关联一个类型。在这个插入中相关的类型根据 type-exp 的文法规则 构造。 表6-10 用于图6-16 简单文法类型检查的属性文法 文法规则 var-decl → id :type-exp type-exp → int type-exp → bool type-exp → array 1 [num] of type-exp 2 stmt → if exp then stmt stmt → id := exp exp →exp + exp 1 2 3 exp →exp or exp 1 2 3 exp →exp [exp ] 1 2 3 exp → num exp → true exp → false exp → id 语义规则 insert( id.name, type-exp.type) type-exp.type := integer type-exp.type := boolean type-exp .type := 1 makeTypeNode (array.num.size., type-exp .type) 2 if not typeEqual(exp.type , boolean) then type-error(stmt) if not typeEqual(lookup(id.name), exp.type) then type-error(stmt) if not (typeEqual(exp .type , integer) 2 and typeEqual(exp .type , integer)) 3 then type-error(exp ) ; 1 exp .type := integer 1 if not (typeEqual(exp .type , boolean) 2 and typeEqual(exp .type , boolean) 3 then type-error(exp ); 1 exp .type := boolean 1 if isArrayType(exp .type) 2 and typeEqual(exp .type , integer) 3 then exp .type := exp .type.child1 1 2 else type-error(exp ) 1 exp.type := integer exp.type := boolean exp.type := boolean exp.type := lookup(id .name) 假定类型保持某种树形结构,因此在图 6-16中的文法的一种结构类型array对应于语义动作 makeTypeNode (array.size.type) 构成一个类型节点 255 第 6章 语 义 分 析 这里数组节点的子孙是 type参数给定的类型树。在树的表示中假定简单类型 integer和boolean构 成了标准叶子节点。 2) 语句 语句本身没有类型,但对类型正确性而言需要检查子结构。一般的情形是在示例 文法中两个语句规则, if语句和赋值语句。在 if语句的情况中,条件表达式必须是布尔类型。 这通过规则 if not typeEqual (exp.type , boolean) then type-error(stmt) 表示,这里 type-error指示一个错误报告机制,其属性将简要地描述。 在赋值语句的情况下,要求被赋值的变量和其接受的值的表达式有相同的类型。这依赖于 typeEqual函数表示的类型等价算法。 3) 表达式 常量表达式,像数字及布尔值 true和false,隐含地说明了 integer和boolean 类型。变量名在符号表中通过 lookup操作确定它们的类型。其他表达式通过操作符构成,如算 术操作符 +、布尔操作符 or、以及下标操作符 []。对每种情况子表达式都必须是指定操作的正 确类型。对于下标的情况,这由规则 if isArrayType(exp .type) 2 and typeEqual(exp .type , integer) 3 then exp .type := exp .type.child1 else type-error(exp ) 1 2 1 指示这里函数 isArrayType测试其参数是数组类型,即类型的树形表示有一个根节点,表示数组 类型构造器。下标表达式导出的类型是数组的基类型,在数组类型的树形表示中它是根节点的 (第一个)子节点表示的类型,这通过 exp .type.child1指示。 2 现在留下了描述在出现错误时这样的类型检查器的行为,像表 6-10中语义规则的 type-error 过程所指示的那样。主要的问题是何时产生错误消息以及在错误出现时如何继续类型检查。每 次出现类型错误时不是都产生错误消息;另一方面,单个的错误可能会引起一连串的许多错误 (有时也恰好出现语法错误 )。事实上,如果type-error过程能确定在有关的位置已经出现了类型 错误,那么就可能抑制错误消息的产生。这可以通过一个特别的内部错误类型 (用一空的类型 树表示)发信号。如果在一个子结构中 type-error遇到这个错误类型,就没有错误消息产生。同 时,如果错误类型意味着结构的类型不能被确定,那么类型检查器可以当作它的类型 (实际上 是未知的 )使用错误类型。例如,在表 6-10的语义规则中,给定一个下标表达式 exp →exp 1 2 [exp ],如果exp 不是数组类型,那么 exp 不能被赋予一个有效的类型,并且在语义动作中没 3 2 1 有类型赋值。这假定类型域被某个错误类型初始化。另一方面,在操作符 +和or的情况,即使 出现类型错误,这个假设可以使结果意味着整型或布尔型,表 6-10中的规则也使用它们给结果 分配一个类型。 6.4.5 类型检查的其他主题 在这一小节我们简要讨论前面已经讨论过的类型检查算法的一些常用的扩展。 1) 重载 一个操作符是重载的,如果同一操作符名用于了两个不同的操作。重载常用的例 256 编译原理及实践 子是算术操作符的情况,通常表示不同数值的操作。例如, 2+3表示整数加,而 2.1+3.0表示 浮点数加,必须通过不同的指令或指令集在内部实现。这样的重载可以扩展到用户说明的函数 或过程,相关的操作使用相同的名字,但说明不同的参数或不同的类型。例如,对两个整数和 实数值,我们定义取最大值的过程: procedure max (x,y: integer): integer; procedure max (x,y: real): real; 在Pascal和C中这是非法的,因为它表示了在相同的作用域中相同名字的重说明。然而,在 Ada和C++中,这样的说明是合法的,因为类型检查器可以根据参数的类型确定要使用哪一个 m a x过程。使用这样的类型检查分清名字的多种含义,可以根据语言的规则用多种方法实现。 一种方法用类型参数增加了符号表的 lookup过程,允许符号表找到正确的匹配。另一种不同的 解决方法是对符号表保持名字所有可能类型的一个集合,并把这个集合返回给类型检查器。这 对更复杂的情形是有用的,唯一的类型可以不必立即确定。 2) 类型转换和强制 语言类型规则的一种常用扩充是允许混合类型的算术表达式,如 2.1+3,一个实数和一个整数相加。在这样的情况下,必须建立一种通用的类型与所有子表达 式的类型兼容,在应用操作符之前必须用某种操作把运行的值转换到相应的表示。例如,在表 达式2.1+3中,在进行加之前整数值 3必须转换成浮点数,结果表达式将是浮点数类型。语言 进行这样的转换有两种途径。例如, Modula-2要求编程者提供一个转换函数,这样刚才给出的 例子可以写成2.1+FLOAT(3),否则将导致类型错误。另一种可能性 (在C语言中使用 )是基于 子表达式的类型,为类型检查器提供一个自动的转换操作。这样的一个自动转换称作强制 (coercion)。强制能由类型检查器隐含地表示,根据子表达式的类型推断出表达式的类型。如 转换3到3.0包含的类型差异 这要求后面的代码产生器检查表达式的类型确定是否需要应用转换。另一种情况,通过在语法 树中插入一个转换节点,类型检查器可以隐含地提供转换,如 类型转换和强制也用于赋值,如 r = i; 在C语言中,如果 r的类型是double,i的类型是 int,在存储i为r的值之前,它的值强制为 double。这样的赋值在转换期间可能会丢失信息,就像相反方向的赋值 (在C中也是合法的): i = r; 类似的情形出现在面向对象的语言中,通常允许子类对象向超类对象赋值 (信息也有相应 的丢失)。例如,在 C++中,如果是 A一个类,是 B一个子类,并且如果 x是的A对象,y是B的对 象,那么x=y是允许的,但反过来不行 (这称作子类型原理(subtype principle))。 257 第 6章 语 义 分 析 3) 多态性类型 有一种语言是多态性 (polymorphic)的,如果允许语言的构造有多种类型。 直到现在我们讨论的语言的类型检查本质上是单态 (monomorphic)的,所有的名字和表达式都 要求有唯一的类型。对这种单态性要求的一种放松是重载。但是,重载通过多态性的一种形 式,只能用于相同名字多种独立说明的情形。当单个说明需要用于任意的类型时就出现了另 一种不同的情形。例如,交换两个变量值的过程在原理上可以用于任何类型的变量 (只要它们 类型相同 ) : procedure swap (var x,y: anytype); 这个swap过程的类型称作被类型 anytype限定(parametrized),anytype被看作是类型变量 (type variable),能假设成任意实际的类型。可以这样表达这个过程的类型 procedure (var anytype, var anytype): void 这里anytype的每次出现都引用相同的 (但是未指定的 )类型。这样的类型实际上是类型模式 (type pattern)或类型方案(type scheme)而不是实际的类型,类型检查器对每次使用 swap的情形都需要 确定实际的类型,匹配这个类型模式或说明一个类型错误。例如,给定代码 var x,y: integer; a,b: char; ... swap(x,y); swap(a,b); swap(a,x); 在调用swap(x,y)时,swap过程根据其给定的多态性类型模式“指定”到 (单态)类型 procedure (var integer, var integer): void 而在调用 swap(a,b)时,它被指定类型 procedure (var char , var char ): void 另一方面,在调用 swap(a,x)时,swap过程的类型为 procedure (var char , var integer): void 并且这个类型不能从 swap的类型模式通过代替类型变量 anytype来产生。存在类型检查算法进 行这种一般的多态性类型检查,特别是在 ML这样的现代的函数式语言中,但其中包括复杂的 模式匹配技术,这里不进行研究 (参见“注意与参考”一节)。 6.5 TINY语言的语义分析 这一节我们基于前一章构造的 TINY语法分析程序,开发 TINY语言的语义分析程序代码。 语义分析程序所基于的 TINY的语法和语法树结构在 3.7节描述。 TINY语言在其静态语义要求方面特别简单,语义分析程序也将反映这种简单性。在 TINY 中没有明确的说明,也没有命名的常量、数据类型或过程;名字只引用变量。变量在使用时隐 含地说明,所有的变量都是整数数据类型。也没有嵌套作用域,因此变量名在整个程序有相同 的含义,符号表也不需要保存任何作用域信息。 在T I N Y中类型检查也特别简单。只有两种简单类型:整型和布尔型。仅有的布尔型值是 两个整数值的比较的结果。因为没有布尔型操作符或变量,布尔值只出现在 if或repeat语句的测 试表达式中,不能作为操作符的操作数或赋值的值。最后,布尔值不能使用 write语句输出。 我们把对 TINY语义分析程序的代码的讨论分成两个部分。首先,讨论符号表的结构及其 258 编译原理及实践 相关的操作。然后,语义分析程序自身的操作,包括符号表的构造和类型检查。 6.5.1 TINY的符号表 在T I N Y语义分析程序符号表的设计中,首先确定什么信息需要在符号表中保存。一般情 况这些信息包括数据类型和作用域信息。因为 TINY没有作用域信息,并且所有的变量都是整 型,TINY符号表不需要保存这些信息。然而,在代码产生期间,变量需要分配存储器地址, 并且因为在语法树中没有说明,因此符号表是存储这些地址的逻辑位置。现在,地址可以仅仅 看成是整数索引,每次遇到一个新的变量时增加。为使符号表更加有趣和有用,还使用符号表 产生一个交叉参考列表,显示被访问变量的行号。 作为符号表产生信息的例子,考虑下列 TINY程序的例子(加上了行号): 1: { Sample program 2: in TINY language -- 3: computes factorial 4: } 5: read x; { input an integer } 6 : i f 0 < x t h e n { d’otn c o m p u t e i f x < = 0 } 7: fact := 1; 8: repeat 9: fact := fact * x; 10: x := x - 1 11: until x = 0; 12: write fact { output factorial of x } 13: end 这个程序的符号表产生之后,语义分析程序将输出 (TraceAnalyze = True )下列信息到列 出的文件中: Symbol table: Variable Name Location Line Numbers ---------------------------------------- x 0 5 6 9 10 10 11 fact 1 7 9 9 12 注意,在符号表中同一行的多次引用产生了那一行的多个入口。 符号表的代码包含在 symtab.h和symtab.c文件中,在附录 B中列出 (分别是第 1150到 1179行和第1200到1321行)。 符号表使用的结构是在 6.3.1节中描述的分离的链式杂凑表,杂凑函数是程序清单 6-2给出 的。因为没有作用域信息,所以不需要 delete操作,insert操作除了标识符之外,也只需要行号 和地址参数。需要的其他的两个操作是打印刚才列出的文件中的汇总信息,以及 lookup操作, 从符号表中取出地址号(后面的代码产生器需要,符号表生成器也要检查是否已经看见了变量 )。 因此,头文件 symtab.h包含下列说明: void st_insert ( char * name, int lineno, int loc ); int st_lookup ( char * name ); void printSymTab(FILE * listing); 因为只有一个符号表,它的结构不需要在头文件中说明,也无须作为参数在这些过程中出现。 在symtab.c中相关的实现代码使用了一个动态分配链表,类型名是 LineList(第1236行 到第 1239行 ) ,存储记录在杂凑表中每个标识符记录的相关行号。标识符记录本身保存在一个 259 第 6章 语 义 分 析 “桶”列表中,类型名是 BucketList(第1247行到第1252行)。st_insert过程在每个“桶” 列表 (第1262行到第 1295行)前面增加新的标识符记录,但行号在每个行号列表的尾部增加,以 保持行号的顺序 (st_insert的效率可以通过使用环形列表或行号列表的前 /后双向指针来改 进;参见练习)。 6.5.2 TINY语义分析程序 T I N Y的静态语义共享标准编程语言的特性,符号表的继承属性,而表达式的数据类型是 合成属性。因此,符号表可以通过对语法树的前序遍历建立,类型检查通过后序遍历完成。虽 然这两个遍历能容易地组合成一个遍历,为使两个处理步骤操作的不同之处更加清楚,仍把它 们分成语法树上两个独立的遍。因此,语义分析程序与编译器其他部分的接口,放在文件 analyze.h中(附录B,第1350行到第1370行),由两个过程组成,通过下列说明给出 void buildSymtab(TreeNode *); void typeCheck(TreeNode *); 第1个过程完成语法树的前序遍历,当它遇到树中的变量标识符时,调用符号表st_insert 过程。遍历完成后,它调用 printSymTab打印列表文件中存储的信息。第 2个过程完成语法 树的后序遍历,在计算数据类型时把它们插入到树节点,并把任意的类型检查错误记录到列表 文件中。这些过程及其辅助过程的代码包含在 analyze.c 文件中(附录 B,第1400行到第 1558行)。 为强调标准的树遍历技术,实现 buildSymtab和typeCheck使用了相同的通用遍历函数 traverse (第1420行到第1441行),它接受两个作为参数的过程 (和语法树),一个完成每个节点 的前序处理,一个进行后序处理: static void traverse ( TreeNode * t, void (* preProc) (TreeNode * ), void (* postProc) (TreeNode * ) ) { if (t != NULL) { preProc(t); { int i; for (i=0; i < MAXCHILDREN; i++) traverse(t->child[i],preProc, postProc); } postProc(t); traverse(t->sibling, preProc, postProc); } } 给定这个过程,为得到一次前序遍历,当传递一个“什么都不做”的过程作为 preproc 时,需要说明一个过程提供前序处理并把它作为 preproc传递到 traverse。对于TINY符号 表的情况,前序处理器称作 insertNode,因为它完成插入到符号表的操作。“什么都不做” 的过程称作 nullProc,它用一个空的过程体说明 (第1438行到第1441行)。然后建立符号表的 前序遍历由 buildSymtab过程(第1488行到第 1494行)内的单个调用 traverse (syntaxTree, insertNode, nullProc); 完成。类似地, typeCheck(第1556行到第1558行)要求的后序遍历由单个调用 traverse (syntaxTree, nullProc, checkNode); 260 编译原理及实践 完成。这里 checkNode是一个适当说明的过程,计算和检查每个节点的类型。现在还剩下描 述过程insertNode和checkNode的操作。 insertNode过程 (第1447行到第 1483 行)必须基于它通过参数 (指向语法树节点的指 针)接受的语法树节点的种类,确定何时把一个标识符 (与行号和地址一起 )插入到符号表中。 对于语句节点的情况,包含变量引用的节点是赋值节点和读节点,被赋值或读出的变量名 包含在节点的 attr.name字段中。对表达式节点的情况,感兴趣的是标识符节点,名字也 存储在 attr.name中。因此,在那 3个位置,如果还没有看见变量 insertNode过程包含 一个 st_insert (t->attr.name, t->lineno, location++); 调用(与行号一起存储和增加地址计数器 ),并且如果变量已经在符号表中,则 st_insert (t->attr.name,t->lineno,0); (存储行号但没有地址 )。 最后,在符号表建立之后, buildSymtab完成对 printSymTab的调用,在标志 TraceAnalyze的控制下(在main.c中设置),在列表文件中写入行号信息。 类型检查遍的checkNode过程有两个任务。首先,基于子节点的类型,它必须确定是否出 现了类型错误。其次,它必须为当前节点推断一个类型(如果它有一个类型)并且在树节点中为这 个类型分配一个新的字段。这个字段在TreeNode中称作type字段(在globals.h中说明,见附 录B,第216行)。因为仅有表达式节点有类型,这个类型推断只出现在表达式节点。在TINY中只 有两种类型,整型和布尔型,这些类型在全局说明的枚举类型中说明(见附录B,第203行): typedef enum {Void, Integer, Boolean} ExpType; 这里类型 V o i d 是“无类型”类型,仅用于初始化和错误检查。当出现一个错误时, checkNode过程调用typeError过程,基于当前的节点,在列表文件中打印一条错误消息。 还剩下归类 checkNode的动作。对表达式节点,节点可以是叶子节点 (常量或标识符,种 类是ConstK或IdK),或者是 操作符节点(种类OpK)。对叶子节点的情况(第1517行到第1520行), 类型总是Integer (没有类型检查发生)。对操作符节点的情况 (第1508行到第1516行),两个子 孙子表达式的类型必须是 Integer (因为后序遍历已经完成,已经计算出它们的类型 )。然后, OpK节点的类型从操作符本身确定 (不关心是否出现了类型错误 ):如果操作符是一个比较操作 符(<或=),那么类型是 Boolean;否则是 Integer。 对语句节点的情况,没有类型推断,但除了一种情况,必须完成某些类型检查。这种情况 是ReadK语句,这里被读出的变量必须自动成为 Integer类型,因此没有必要进行类型检查。 所有4种其他语句种类需要一些形式的类型检查: IfK和RepeatK语句需要检查它们的测试表 达式,确保它们是类型 Boolean(第1527行到第1530行和第1539行到第1542行),而WriteK和 AssignK语句需要检查 (第1531行到第1538行)确定被写入或赋值的表达式不是布尔型的 (因为 变量只能是整型值,只有整型值能被写入 ): x := 1 < 2; { error - Boolean value cannot be assigned } write 1 = 2; { also an error } 练习 6.1 通过下面文法给出的数的整数值,写出一个属性文法: 261 第 6章 语 义 分 析 number → digit number | digit digit → 0|1|2|3|4|5|6|7|8|9 6.2 通过下面文法给出的十进制数的浮点数值,写出一个属性文法 (提示:使用一个属性 count计算小数点右面数字的个数 )。 dnum → num.num num → num digit | digit digit → 0|1|2|3|4|5|6|7|8|9 6.3 前一个练习中的十进制数文法可进行重写,不需要属性 count (包括它的等式也可避 免)。重写文法实现这一点,并为 dnum的值给出一个新的属性文法。 6.4 考虑一个表达式文法,它可写成消除左递归的预分析程序: exp → term exp′ exp′ → + term exp′| - term exp′| term → factor term′ term′ → * factor term′| factor →(exp)| number 写出用这个文法给出的表达式的值的属性文法。 6.5 重写表6-2的属性文法,代替 val计算postfix串属性,包含简单整数表达式的后缀形式。 例如, (34-3)*42的postfix属性是“ 34 3 -42 + * ”。可以假设一个串联操作符 ||和 number.strvval属性存在。 6.6 考虑下面的整数二叉树文法(线性形式): btree →( number btree btree )| nil 写出一个属性文法检查二叉树是有序的,即第一个子树中数的值≤当前数的值,并 且第 2个子树所有数的值≥当前数的值。例如, ( 2 ( 1 n i l n i l ) ( 3 n i l nil))是有序的,而(1 (2 nil nil) (3 nil nil不))是。 6.7 考虑下面简单的类 Pascal 说明的文法: decl → var-list: type var-list → var-list, id | id type → integer | real 写出变量类型的一个属性文法。 6.8 考虑练习 6.7的文法。重写这个文法使变量的类型可以说明为纯的合成属性,并给出 具有这个特性的类型的新的属性文法。 6.9 重写例6.4的文法和属性文法,使based-num 的值能通过单独的合成属性计算。 6.10 a. 画出对应于例6.14中每个文法规则的相关图,表达式是 5/2/2.0。 b. 描述要求在 5/2/2.0的语法树上计算属性的两遍,包括节点访问可能的顺序和在 每点计算的属性值。 c. 写出过程的伪代码,完成 b中描述的计算。 6.11 画出对应于练习6.4中属性文法的每个文法规则的相关图,字符串是 3*(4+5)*6。 6.12 画出对应于练习 6.7中属性文法的每个文法规则的相关图,画出说明 x,y,z:real 262 编译原理及实践 的相关图。 6.13 考虑下面的属性文法: 文法规则 S →A B C A→a B→b C →c 语义规则 B.u = S.u A.u = B.v + C.v S.v = A.v A.v = 2 * A.u B.v = B.u C.v = 1 a. 画出字符串abc的语法树 (语言仅有的字符串 ),画出相关属性的相关图。描述属性 等式的正确顺序。 b. 假设在属性等式开始前S.u赋值为3。当等式完成时S.v的值的多少? c. 假设属性等式修改如下: 文法规则 S →A B C A →a B →b C→c 语义规则 B.u = S.u C.u = A.v A.u = B.v + C.v S.v = A.v A.v = 2 * A.u B.v = B.u C.v = C.u-2 如果等式开始前S.u = 3,属性等式完成后 S.v 的值是多少? 6.14 说明对如下给定的属性文法: 文法规则 decl → type var-list type → int type → float var-list → id,var-list 1 2 var-list → id 语义规则 vat-list.dtype = type.dtype type.dtype = integer type.dtype = real id.dtype = var-list .dtype 1 var-list .dtype = var-list .dtype 2 1 id.dtype = var-list.dtype 如果在LR分析期间属性type.dtype保存在值栈中,那么当发生 var-list 归约时,这个值 不能在栈中的固定位置找到。 6.15 a. 说明文法B → B b | a 是 SLR(1),但文法 B→ ABb |a A→ (由前面文法加上 产生式构造),对任意k 都不是LR(k)。 b. 给定(a)部分的文法(带 -产生式),Yacc产生的分析程序接受什么字符串? 263 第 6章 语 义 分 析 c. 这种情形与“实际的”编程语言语义分析期间出现的情况是否相似?试说明。 6.16 把6.3.5节的表达式文法重写成无二义性文法,用这样的方法那一节写的表达式保持 合法性,用这个新文法重写表 6-9的属性文法。 6.17 使用并列说明代替顺序说明重写表 6-9的属性文法。 6.18 写一个属性文法,计算6.3.5节中表达式文法的每个表达式的值。 6.19 修改程序清单 6-6函数typeEqual的伪代码,合并类型名并提出确定252页描述的递归 类型的结构等价的算法。 6.20 考虑下列表达式的 (二义)文法: exp → exp + exp | exp - exp | exp * exp | exp / exp | (exp) | num | num.num 假设在计算任何这样的表达式时遵循 C语言的规则:如果两个表达式是混合类型的, 那么整型的子表达式转换成浮点型,并应用浮点型操作符。写一个属性文法把这样 的表达式转换成在 Modula-2中也是合法的表达式:从整数到浮点数的转换使用 FLOAT函数表达,如果两个操作数都是整数,除法操作符 /就被视为div。 6.21 考虑对图6-16的下列文法的扩展,它包括了函数说明和调用: program → var-decls ; fun-decls ; stmts var-decls → var-decls ; var-decl | var-decl var-decl → id : type-exp type-exp → int|bool|array [num] otfype-exp fun-decls → fun id ( var-decls ): type-exp ; body body → exp stmts → stmts ; stmt | stmt stmt → if exp then stmt | id := exp exp → exp + exp | exp or exp | exp [exp] | id ( exps ) |num|true|false|id exps → exps , exp | exp a. 为新的函数类型结构设计一个合适的树结构,为两个函数类型写一个 typeEqual 函数。 b. 写出函数说明和函数调用类型检查的语义规则 (由规则exp → id (exps) 表示),类 似于表6-10的规则。 6.22 考虑下列C表达式的二义文法。给定表达式 (A)-x 如果x是一个整型变量, A在typedef中说明等价于 double,那么这个表达式计算 -x 的值为 double类型。另一方面,如果 A是一个整型变量,则计算两个变量的整型 差值。 a. 描述分析程序如何使用符号表区分这两种解释。 b. 描述扫描器如何使用符号表区分这两种解释。 6.23 对应于TINY类型检查器的强制类型约束写一个属性文法。 264 编译原理及实践 6.24 写出一个TINY语义分析程序符号表构造的属性文法。 编程练习 6.25 写出例6.4基数语法树的C语言说明,并使用这些说明把例 6.13的EvalWithBase伪代码 转换成C代码。 6.26 a. 重写程序清单 4-1的递归下降求值程序,代替表达式的值而打印出后缀转换式 (见 练习6.5)。 b. 重写递归下降求值程序,打印出值和后缀转换式。 6.27 a. 为一个简单整数计算重写程序清单 5-1的Yacc规范,代替表达式的值而打印出后缀 转换式(见练习6.5)。 b. 重写Yacc规范,打印出值和后缀转换式。 6.28 写出一个Yacc规范,打印出练习 6.20中文法给出的表达式的Modula-2转换式。 6.29 写出一个程序的 Yacc规范,用于计算带 let-块的表达式的值 (表6-9) (可以把let和in 记号缩写成一个字符,并约束标识符或使用 Lex产生一个合适的扫描器)。 6.30 写出一个程序的Yacc和Lex规范,进行语言的类型检查,其文法在图 6-16给出。 6.31 重写TINY语义分析程序符号表的实现,数据结构 LineList加进一个向后的指针, 并提高insert操作的效率。 6.32 重写TINY语义分析程序,使其只对语法树进行一遍遍历。 6.33 TINY语义分析程序在变量使用之前,没有确保其已被赋值。因此,下面的 TINY代 码在语义上认为是正确的: y := 2+x; x := 3; 重写TINY分析程序进行“合理的”检查,在表达式中一个变量的赋值发生在使用之 前。什么妨碍这样的检查十分简单? 6.34 a. 重写TINY语义分析程序,允许布尔值存储到变量中。这将要求在符号表中给定变 量的数据类型为布尔型或整型。 TINY程序的类型正确性现在必须包括对变量所有 的赋值(和使用)与它们的数据类型一致的要求。 b. 根据a中的修改,写出一个TINY类型检查器的属性文法。 注意与参考 属性文法的早期工作主要是 Knuth[1968]进行的。在编译器构造中使用属性文法的进一步 研究出现在Lorho[1984]。正式使用属性文法指定编程语言的语义是 Slonneger和Kurtz[1995]的 研究,这里为类似于 TINY的语言的静态语义给出了一个完整的属性文法。属性文法的其他数 学特性可以在Mayoh[1981]中找到。一种非闭环的测试可以在 Jazayeri、Ogden和Rounds[1975] 中找到。 分析期间属性的赋值问题在 Fischer和LeBlanc[1991]的研究中更加详细一些。在 LR分析期 间确保其进行的条件在 Jones[1980]中。在调度动作中加进 -产生式(像在Yacc中)保持确定性的 LR分析的问题在 Purdom和Brown中研究。 符号表实现的数据结构,包括杂凑表及其效率分析,能在许多文章中找到;例如参见 Aho、 265 第 6章 语 义 分 析 Hopcroft和Ullman[1983]或Cormen、Leiserson和Rivest[1990]。选择杂凑函数的细致的研究在 K n u t h [ 1 9 7 3 ]中。 类型系统、类型正确性和类型推断形成了理论计算机科学研究的一个主要的领域,并应用 到许多语言中。通常的概要参见 Louden[1993]。更进一步的观点参见 Cardelli和Wegner[1985]。 C和Ada使用类型等价的混合形式,类似于 Pascal的等价说明,很难简洁地描述。较早的语言, 如FORTRAN77、Algol60和Algol68使用结构等价。像 ML和Haskell使用严格的名等价,用类型 同义词代替结构等价。在 6.4.3节中描述的结构等价算法可以在 Koster[1969]中找到;类似算法 的现代的应用在 Amadio和Cardelli[1993]中。多态的类型系统和类型推导算法在 Peyton Jones[1987]和Reade[1989]中描述。在 ML和Haskell中使用的多态的类型推导称作 HindleyMilner类型推导[Hindley,1969;Milner,1978]。 本章中我们没有描述任何属性求值的自动构造工具,因为通常并不使用 (不像扫描器和分 析程序产生器 Lex和Yacc)。基于属性文法的一些有趣的工具是 LINGUIST[Farrow,1984]和 GAG[Kastens,Hutt和Zimmermann,1982]。合成器产生器 [Reps和Teitelbaum,1989]是一个成 熟的工具,用于基于属性文法的上下文有关编辑器的产生。对这个工具可做的一件有趣的事是 构造一个语言编辑器,自动地提供基于使用的变量声明。 第7章 运行时环境 • 程序执行时的存储器组织 • 完全静态运行时环境 • 基于栈的运行时环境 本章要点 • 动态存储 • 参数传递机制 • TINY语言的运行时环境 在前几章中,我们已研究了实现源语言静态分析的编译程序各阶段。该内容包括了扫描、 分析和静态语义分析。这个分析仅仅取决于源语言的特性,它与目标 (汇编或机器 )语言及目标 机器和它的操作系统的特性完全无关。 在本章及下一章中,我们将转向研究编译程序如何生成可执行代码的问题。这个研究包括 了附加分析,例如由优化程序实现的分析,其中的一些可以与机器无关。但是代码生成的许 多任务都依赖于具体的目标机器。然而同样地代码生成的一般特征在体系结构上仍保留了很 大的变化。运行时环境 (runtime environment)尤为如此,运行时环境指的是目标计算机的寄存 器以及存储器的结构,用来管理存储器并保存指导执行过程所需的信息。实际上,几乎所有 的程序设计语言都使用运行时环境的 3个类型中的某一个,它的主要结构并不依赖于目标机器 的特定细节。环境的这 3个类型分别是:FORTRAN77的完全静态环境(fully static environment) 特征、像 C、C++、Pascal以及Ada这些语言的基于栈的环境 (stack-based environment),以及 像LISP这样的函数语言的完全动态环境 (fully dynamic environment)。这3种类型的混合形式 也是可能的。 本章将按顺序逐个讨论这 3种环境,还指出哪些环境是可行的语言特征以及它们必须具有 的特性。这包括了作用域及分配问题、过程调用的本质和不同的参数传递机制。这一章集中讨 论的是环境的一般结构,而第 8章着重于维护环境需要生成的真实代码。在这一点上,大家应 记住编译程序只能间接地维护环境,在程序执行期间它必须生成代码进行必要的维护操作。相 反地由于解释程序可以在其自己的数据结构中直接维护环境,因而它的任务就很简单。 本章的第一节包括了对所有运行时环境的一般特征及其与目标机器的体系结构之间的关系 的论述;之后的两节探讨了静态环境和基于栈的环境,以及执行时的操作示例。由于基于栈的 环境是最常见的,所以我们对于基于栈系统的不同变型和结构又要着重讲述。在这之后是一些 动态存储问题,其中包括了完全动态环境和面向对象的环境。下面还会讲到有关环境操作的各 种参数传递技术。本章最后简要描述了实现 TINY语言所需的简单环境。 7.1 程序执行时的存储器组织 典型计算机的存储器可分为寄存器区域和较慢的直接编址的随机访问存储器 (RAM)。 RAM区域还可再分为代码区和数据区。在绝大多数的语言中,执行时不可能改变代码区,且 在概念上可将代码和数据区看作是独立的。另外由于代码区在执行之前是固定,所以在编译时 所有代码的地址都是可计算的,代码区可如下所示: 267 第 7章 运行时环境 过程1的入口点 过程2的入口点 过程1的代码 过程2的代码 过程n的入口点 过程n的代码 代码存储器 特别地,在编译时还可以知道每个过程的入口点和函数 。对数据的分配不能这样说,它只有 一小部分可在执行之前被分配到存储器中的固定位置。本章大部分内容都会谈论如何处理非固 定的或动态的数据分配。 在执行之前,可以将一类数据固定在存储器中,它还包括了程序的全局和 /或静态数据 (FORTRAN77与绝大多数的语言不同,它所有的数据都属于这一类 )。这些数据通常都在一个 固定区域内并以相似的风格单独分配给代码。在 Pascal 中,全局变量属于这一类, C的外部和 静态变量也是如此。 在组织全局/静态区域中出现的一个问题是它涉及到编译时所知的常量。这其中包括了 C和 Pascal 的const声明以及代码本身所用的文字值,例如串“ Hello %d\n”和在C语句 printf (" Hello %d\n ", 12345 ) ; 中的整型值12345。诸如0和1这样较小的编译时常量通常由编译程序直接插入到代码中且不为 其分配任何数据空间。同样地,由于编译程序已掌握了全局函数或过程的入口点且可直接将其 插入到代码中,所以也不为它们分配全局数据区。然而我们却将大型的整型值、浮点值,特别 是串文字分配到全局 /静态区域中的存储器,在启动时仅保存一次,之后再由执行代码从这些 位置中得到(实际上,在C中串文字被看作是指针,因此它们必须按照这种方式来保存 )。 用作动态数据分配的存储区可按多种方式组织。典型的组织是将这个存储器分为栈 (stack) 区域和堆(heap)区域,栈区域用于其分配发生在后进先出 LIFO(last-in, first-out)风格中的数据, 而堆区域则用于不符合LIFO协议(例如在C中的指针分配)的动态分配 。目标机器的体系结构通 常包括处理器栈,利用了这个栈使得用处理器支持过程调用和返回 (使用基于存储器分配的主要 机制)成为可能。有时,编译程序不得不将处理器栈的显式分配安排在存储器内的恰当位置中。 一种一般的运行时存储器组织如下所示,它具有上述所有的存储器分类: 代码区域 全程 / 静态区域 栈 ↓ 自由空间 ↑ 堆 更为可能的情况是,代码由装载程序装载到存储器的一个在执行开始时分配的区域中,因此这是完全不可预 测的。但是之后的所有实际地址都由从固定的装载基地址的偏移自动计算得出,因此和固定地址的原理相同。 有时,编译器的编写者必须留心生成可重定位代码 (relocatable code),其中都相对于某个基址 (base)(通常是寄 存器)执行转移、调用以及引用。下一章将给出一些例子。 读者应注意到堆通常是一个简单的线性存储器区域。将它称之为堆是一个历史原因,这与算法 (如堆类排序 ) 中用到的堆数据结构无关。 268 编译原理及实践 上图中的箭头表示栈和堆的生长方向。传统上是将栈画作在存储器中向下生长,这样它的 顶部实际就是在其所画区域的底部。堆也画得与栈相似,但它不是 LIFO结构且它的生长和缩 短比箭头所表示的还要复杂 (参见7.4节)。在某些组织中,栈和堆被分配在不同的存储器部分, 而不是占据相同的区域。 存储器分配中的一个重要单元是过程活动记录 (procedure activation record),当调用或激活 过程或函数时,它包含了为其局部数据分配的存储器。活动记录至少应包括以下几个部分: 自变量(参数)空间 用作薄记信息的空间,它包括了返 回地址 用作局部数据的空间 用作局部临时变量的空间 在这里应强调 (而且以后还要重复 )这个图示仅仅表示的是活动记录的一般组织。包括其所含数 据的顺序的特定细节则依赖于目标机器的体系结构、被编译的语言特性,甚至还有编译程序的 编写者的喜好。 所有过程活动记录的某些部分,例如用于簿记信息的空间,具有相同的大小。而其他部分, 诸如用于自变量和局部数据的空间会对每一个过程保持固定,但是每个过程都各不相同。某些 活动记录还会由处理器自动分配到过程调用上 (例如存储返回地址)。其他部分(如局部临时变量 空间 )可能需要由编译程序生成的指令显式地分配。根据语言的不同,可能将活动记录分配在 静态区域 (FORTRAN77)、栈区域(C、Pascal)、或堆区域 (LISP)。当将活动记录保存在栈中时, 它们有时指的是栈框架(stack frame)。 处理器寄存器也是运行时环境的结构部分。寄存器可用来保存临时变量、局部变量甚至是 全局变量。当处理器具有多个寄存器时,正如在较新的 RISC处理器中一样,整个静态区域和 整个活动记录都可完整地保存在寄存器中。处理器还具有特殊用途的寄存器以记录执行,如在 大多数的体系结构中的程序计数器 (pc)、栈指针 (sp)(stack pointer)。可能还会为跟踪过程活动 而特别设计寄存器。这样的寄存器典型的有指向当前活动记录的框架指针 (fp)(frame pointer), 以及指向保存自变量(参数值)的活动记录区域的自变量指针 (argument pointer) 。 运行时环境的一个特别重要的部分是当调用过程或函数时,对必须发生的操作序列的判定。 这样的操作可能还包括活动记录的存储器分配、计算和保存自变量以及为了使调用有效而进行 的必要的寄存器的保存和设置。这些操作通常指的是调用序列 (calling sequence)。过程或函数 返回时需要的额外操作,如放置可由调用程序访问的返回值、寄存器的重新调整,以及活动记 录存储器的释放,也通常被认为是调用序列的一个部分。如果需要,可将调用时执行的调用序 列部分称作是调用序列(call sequence),而返回时执行的部分称为返回序列 (return sequence)。 调用序列设计的重要方面有: 1) 如何在调用程序和被调用程序之间分开调用序列操作 (也 就是有多少调用序列的代码放在调用点上,多少放在每个过程的代码开头 );2)在多大程度上 依赖处理器对调用支持而不是为调用序列的每一步生成显式代码。由于在调用点上比在被调用 这些名称都是从 VAX体系结构中得到的,但是类似的名称还可出现在其他体系结构中。 269 第 7章 运行时环境 程序内更易生成调用序列代码,但是这样做会引起生成代码数量的生长,因为在每个调用点都 要复制相同的代码所以第 1点是一个很棘手的问题,后面还要再更详细地讲到这些问题。 调用程序至少要负责计算自变量并将它们放在可由被调用程序找到的位置上 (可能是直接 放在被调用程序的活动记录中 )。此外,调用程序或被调用程序或两者必须保存调用点上的机 器状态,包括返回地址,可能还有正使用的寄存器。最后,在调用程序和被调用程序之间须用 某个可能的合作方式建立所有附加的簿记信息。 7.2 完全静态运行时环境 最简单的运行时环境类型是所有数据都是静态的,且执行程序期间在存储器中保持固定。 这样的环境可用来实现没有指针或动态分配,且过程不可递归调用的语言。此类语言的标准例 子是FORTRAN77。 在完全静态环境中,不仅全局变量,所有的变量都是静态分配。因此,每个过程只有一个 在执行之前被静态分配的活动记录。我们都可通过固定的地址直接访问所有的变量,而不论它 们是局部的还是全局的,则整个程序存储器如下所示: 主过程的代码 过程1的代码 代码 区域 过程n 的代码 全局数据区域 主过程的活动记录 过程1的活动记录 数据 区域 过程n 的活动记录 在这样的环境中,保留每个活动记录的簿记信息开销相对较小,而且也不需要在活动记录中 保存有关环境的额外信息 (而不是返回地址 )。用于这样环境的调用序列也十分简单。当调用 一个过程时,就计算每个自变量,并将其保存到被调用过程的活动中恰当的参数位置。接着 保存调用程序代码中的返回地址,并转移到被调用的过程的代码开头。返回时,转移到返回 地址 。 例7.1 作为这种环境的具体示例,考虑程序清单 7-1中的FORTRAN77程序。这个程序有一个 主过程和一个附加的过程 QUADMEAN 。在主过程和QUADMEAN中有由COMMON MAXSIZE 声明 在大多数的体系结构中,子例程转移自动地保存返回地址;当执行返回指令时,也自动地再装载这个地址。 我们忽略库函数 SQRT,它由 QUADMEAN调用而且在执行之前先被链接。 270 编译原理及实践 给出的全局变量 。 程序清单 7-1 一个FORTRAN77示例程序 忽略存储器中整型值和浮点值间可能有的大 小区别,我们显示了图 7-1中的这个程序的运 行时环境 。在该图中,我们用箭头表示从 主过程中调用时,过程 QUADMEAN的参数 A、 SIZE和QMEAN的值。在 FORTRAN77中,参 数值是隐含的存储引用,所以调用 (TABLE、 3 和T E M P )的参数地址就被复制到 QUADMEAN的参数地址中。它有几个后果。 首先,需要一个额外的复引用来访问参数 值。其次,数组参数无需再重新设置和复 制(因此,只给在 QUADMEAN中的数组参数 A 分配一个空间,在调用时指出 TABLE 的基 地址 )。再次,像在调用中的值 3的常量参数 必须被放在一个存储器地址中而且在调用 时要使用这个地址 (7.5节将更完整地讨论参 数传递机制 )。 图7-1中还有一个特性需要解释一下,这 全局区 主过程的 活动记录 过程Q U A D M E A N 的活动记录 QMEAN 返回地址 图7-1 程序清单7-1中程序的运行时环境 实际上, FORTRAN77 允许COMMON变量在不同的过程中具有不同的名称,而仍旧指的是相同存储器位置。 从这个示例开始,我们将默认忽略这种复杂性。 我们再次强调这个图示仅仅是示意性的。在操作中的实现实际与这里给出的有所差异。 271 第 7章 运行时环境 就是QUADMEAN的活动记录结尾分配的未命名的地址。这个地址是一个在算术表达式计算中用 来储存临时变量值的“凑合的”地址。在 QUADMEAN中可能需要两个运算。一个是循环中的 TEMP+A(K)*A(K)的计算,另一个是当参数在调用 SQRT时的TEMP/SIZE的计算。我们早已 谈过需要为参数值分配空间 (尽管在对库函数的调用中实际上是有差别的 )。循环计算中也需要 临时变量存储地址的原因在于每个算术运算必须在一个步骤中,所以就计算 A(K)*A(K)并在 下一步中添加 TEMP的值。如果没有足够的寄存器来放置这个临时变量值,或如果有一个调用 要求保存这个值,那么在完成计算之前应先将值存储在活动记录中。编译程序可以总是先预测 出在执行中它是否必要,然后再为分配临时变量的地址的恰当数量 (以及大小)作出安排。 7.3 基于栈的运行时环境 在允许递归调用以及每一个调用中都重新分配局部变量的语言中,不能静态地分配活动记 录。相反地,必须以一个基于栈的风格来分配活动记录,即当进行一个新的过程调用 (活动记 录的压入 (push))时,每个新的活动记录都分配在栈的顶部,而当调用退出时则再次解除分配 (活动记录的弹出 (pop))。活动记录的栈 (stack of activation record)(也指运行时栈 (runtime stack) 或调用栈(call stack))就随着程序执行时发生的调用链生长或缩小。每个过程每次在调用栈上可 以有若干个不同的活动记录,每个都代表了一个不同的调用。这样的环境要求的簿记和变量访 问的技术比完全静态环境要复杂许多。特别地,活动记录中必须有额外的簿记信息,而且调用 序列还包括设置和保存这个额外信息所需的步骤。基于栈的环境的正确性和所需簿记信息的数 量在很大程度上依赖于被编译的语言的特性。在本节中为了提高难度复杂性,我们将考虑基于 栈的环境的组织,它是由所涉及到的语言特性区分的。 7.3.1 没有局部过程的基于栈的环境 在一个所有过程都是全局的语言 (例如C语言)中,基于栈的环境有两个要求:指向当前活 动记录的指针的维护允许访问局部变量,以及位置记录或紧前面的活动记录 (调用程序的活动 记录)允许在当前调用结束时恢复活动记录 (且舍掉当前活动 )。指向当前活动的指针通常称为 框架指针(frame pointer) 或(fp),且通常保存在寄存器中 (通常也称作fp)。作为一个指向先前活 动记录的指针,有关先前活动的信息一般是放在当前活动中,并被认为是控制链 (control link) 或动态链(dynamic link) (之所以称之为动态的,是因为在执行时它指向调用程序的活动记录 )。 有时将这个指针称为旧 fp (old fp),这是因为它代表了 fp的先前值。通常,这个指针被放在栈 中参数区域和局部变量区域之间的某处,并且指向先前活动记录控制链。此外,还有一个栈 指针(stack pointer)或sp,它通常指向调用栈上的最后位置 (它有时称作栈顶部(top of stack)指针, 或tos)。 现在考虑几个例子。 例7.2 利用Euclid算法的简单递归实现,计算两个非负整数的最大公约数,它的代码 (C语言) 在程序清单 7-2中。 程序清单 7-2 例7-2的C代码 272 编译原理及实践 假设用户在该程序中输入了值 15和10,那么main就初始化调用 gcd(15,10)。这个调用 导致了另一个递归调用 gcd(10,5), (因为 15 % 10 = 5) ,而这又引起了第 3个调用 gcd(5,0)(因为10%5=0),它将返回值 5。在第 3个调用中,运行时环境如图 7-2所示。请读者 注意指向每个调用的 gcd是如何向栈的顶部添 加新的大小完全相同的活动记录,并且在每个 全局/静态区域 main 的活动记录 新活动记录中,控制链指向先前活动记录的控 制链。还请大家注意, fp指向当前活动记录的 控制链,因此在下一个调用中当前的 fp就会变 控制链 返回地址 第一次调用 gcd 时的活动记录 成下一个活动记录的控制链了。 调用最后一个gcd的之后,将按顺序从栈中 删去每个活动,这样当在 main中执行printf 控制链 返回地址 第二次调用 gcd 时的活动记录 语句时,只在环境中保留了 main和全局 /静态区 域的活动记录 (我们已将 main的记录显示为空。 在实际中,它应包含将控制传回到操作系统的 控制链 返回地址 第三次调用 gcd 时的活动记录 信息)。 最后,应指出在调用 gcd 时调用程序不 自由空间 栈生长方向 需 要为自变量值安排空间(与图 7 - 1 中的 FORTRAN77环境中的常量 3不同),这是因为C 图7-2 例7.2的基于栈的环境 语言使用值参数。7.5节将详细探讨这一点。 例7.3 考虑程序清单 7-3中的C代码。这个代码包括将用来进一步描述本节相关内容的变量, 但是它的基本操作如下所示。从 main来的第1个调用是到 g(2)的(这是因为 x在这一点上有值 2)。在这个调用中, m变成了 2,而y则变成了 1。接着 g调用f(1),而f也相应地调用 g(1)。 在这个到g的调用中, m变成了1,而y则变成了0,所以再也没有别的调用了。该点 (在对g的第 二次调用期间)上的运行时环境显示在图7-3a 中。 程序清单 7-3 例7.3的C程序 273 第 7章 运行时环境 现在对g和f的调用退出(f在返回之前它的静态局部变量 x减1),它们的活动记录从栈中弹 出,且控制返回到紧随在第 1次对g的调用的 f的调用之后的点。现在 g给外部变量 x减1,并进 行另一次调用 g(1),将m设为2将y设为1,这样就得到了图 7-3b 中的运行时环境。在此之后再 也没有调用了,从栈中就会弹出剩余的活动记录,程序退出。 控制链 返回地址 全局/静态区 main 的活动记录 调用 g 时的活动 记录 控制链 返回地址 全局/静态区 main 的活动记录 调用 g 时 的 活 动 记录 控制链 返回地址 调用 f 时的活动 记录 控制链 返回地址 调用 g 时 的 活 动 记录 控制链 返回地址 y:0 自由空间 调用 g 时的活动 记录 自由空间 b) a) 图7-3 程序清单7-3中程序的运行时环境 a) 在第2次对g 的调用时,程序清单 7-3中程序的运行时环境 b) 在第3次对g 的调用时,程序清单 7-3中程序的运行时环境 请注意,在图7-3b 中,对g的第3次调用的活动记录占据着 (并覆盖)f的活动记录先前占着 的存储器区域。大家还应注意到:由于 f中的静态变量x必须坚持通过所有对 f的调用,所以不 可在f的活动记录中分配。因此,尽管不是全局变量,也必须在全局 /静态区域中与外部变量 x 274 编译原理及实践 在一起分配。因为符号表会总能区分它与外部的 x,并在程序每一个点上判定访问的正确变量, 所以它们不会有什么混淆。 活动树(activation tree) 是分析程序中复杂调用 结构的有用工具,每个活动记录 (或调用 )都成为该 树的一个节点,而每个节点的子孙则代表了与该节 点相对应的调用时进行的调用。例如,程序清单 7- 2中程序的活动树是线性的,在图 7-4a 中描述 (对 于输入 15和10而言),而程序清单 7-3中程序的活动 树在图 7-4b 中描述。请注意,图 7-2和图 7-3中显 a) b) 示的环境表示了在调用时由活动树的每个叶子代表 的调用。一般而言,在特定调用开头的活动记录栈 与活动树的相应节点到根节点的通路有一个结构等 图7-4 程序清单7-2和程序清单7-3中 程序的活动树 价。 1) 对名称的访问 在基于栈的环境中,再也不能像在完全静态环境中那样用固定的地址访 问参数和局部变量。而它们由当前框架指针的偏移量发现。在大多数的语言中,每个局部声明 的偏移量仍是可由编译程序静态地计算出来,因为过程的声明在编译时是固定的,而且为每个 声明分配的存储器大小也根据其数据类型而固定。 考虑程序清单 7-3中C程序的过程 g (参见图 7-3中画出的运行时环境 )。g的每个活动记录 的格式完全相同,而参数 m和局部变量 y也总是位于活动记录中完全相同的相对位置。我们 把这个距离称作 mOffset和yOffset。然后,在对 g的任何调用期间,都有下面的局部环 境图: m 控制链 返回地址 y m和y都可根据它们从 fp的固定偏移进行访问。例如,具体地假设运行时栈从存储器地址的 高端向低端生长,整型变量的存储要求两个字节,地址要求 4个字节。若活动记录的组织如上 所画,就有 mOffset = +4和yOffset = -6,且对m和y的引用写成机器代码 (假设是标准的汇 编器约定 )为4(fp) 和-6(fp)。 局部数组和结构与简单变量相比分配和计算地址并不更加困难,如以下示例所示。 例7.4 考虑C过程 void f(int x, char c) { int a[10]; double y; ... } 对f调用的活动记录显示为: 控制链 返回地址 275 第 7章 运行时环境 x 偏移量 c 偏移量 a 偏移量 y 偏移量 而且假设整型是两个字节、地址 4个字节、字符 1个字节、双精度浮点数 8个字节,那么就 有以下的偏移值(再次假设栈的生成为负方向 ),这些值在编译时都是可计算的: 名称 X C A Y 偏移量 +5 +4 -24 -32 现在对a[i]一个访问将要求计算地址 (-24+2*i)(fp) (这里在产生式2*i中的因子是比例因子(scale factor),它是从假设整型值占有两个字节得来的 )。 这样的存储器访问依赖于 i的地址以及体系结构,可能只需要一条指令。 对于这个环境中的非局部的和静态名字而言,不能用同局部名字一样的方法访问它们。实 际上,我们此处所考虑的情况——不带有过程的语言——所有的非局部的名字都是全局的,因 此也就是静态的。所以在图 7-3中,外部的 (全局的)C变量x具有一个固定的静态地址,因此也 就可以被直接访问 (或是通过某个基指针的偏移而不是 fp)。对来自 f的静态局部变量 x的访问也 使用完全相同的风格。请注意,正如前一章所描述的,这个机制实现静态 (或词法的 )作用域。 如果需要动态作用域,那么就要使用一个更为复杂的访问机制 (本节后面将要提到)。 2) 调用序列 调用序列大致由以下的步骤组成 。当调用一个过程时, ① 计算自变量并将其存放在过程的新活动记录中的正确位置 (将其妥当地压入到运行时栈 中就可做到这一点 )。 ② 将fp作为控制链存放(压入)到新的活动记录中。 ③ 改变fp以使其指向新的活动记录的开始 (如已有了一个sp,则将该sp复制到该点上的fp中, 也可以做到这一点 )。 这个描述忽略了必须发生的寄存器的任何保存。它还忽略了将返回值放在一个可用的地址的需要。 276 编译原理及实践 ④ 将返回地址存放在新的活动记录中 (如果需要)。 ⑤ 完成到被调用的过程的代码一个转移。 当存在着一个过程时,则 ① 将fp复制到sp中。 ② 将控制链装载到fp中。 ③ 完成到返回地址的一个转移。 ④ 改变sp以弹出自变量。 例7.5 考虑在前面图7-3b 中的对g的最后一个调用的情况: (栈的其余部分) 控制链 返回地址 调用g时的活动记录 自由空间 当进行对 g新的调用时,首先将参数 m的值压入到运行时栈中: (栈的其余部分) 控制链 返回地址 调用g时的活动记录 接着将fp压入到栈中: 自由空间 (栈的其余部分) 控制链 返回地址 调用g时的活动记录 控制链 自由空间 277 第 7章 运行时环境 现在将sp复制到fp中,并将返回的地址压入到栈中,就得出了到 g的新的调用了: (栈的其余部分) 控制链 返回地址 调用g时的活动记录 控制链 返回地址 自由空间 调用g时新的活动记录 最后, g在栈上分配和初始化新的 y以完成对新的活动记录的构造: (栈的其余部分) 控制链 返回地址 调用g时的活动记录 控制链 返回地址 调用g时新的活动记录 自由空间 3) 处理可变长度数据 到这里我们已经描述了一种情况,所有的数据,无论是局部的还是 全局的,都可在一个固定的地方,或由编译程序计算出的到 fp的固定偏移处找到。有时编译程 序必须处理数据变化的可能性,表现在数据对象的数量和每个对象的大小上。发生在支持基于 栈的环境的语言中的两个示例如下:① 调用中的自变量的数量可根据调用的不同而不同。② 数组参数或局部数组变量的大小可根据调用的不同而不同。 情况(1)的典型例子是 C中的printf函数,其中的自变量的个数由作为第一个自变量传递 的格式串决定。因此: printf("%d%s%c", n, prompt, ch); 就有4个自变量 (包括格式串 "%d%s%c"),但是 printf("Hello, world\n"); 却只有一个自变量。通常, C编译程序一般通过把调用的自变量按相反顺序 (in reverse order) 压入到运行时栈p来处理这一点。那么,在上面描述的实现中第 1个参数(它告知printf的代码 278 编译原理及实践 共有多少参数 )通常是位于到 fp的固定偏移的位置(使用上一个示例的假设得出实际上是 +4)。另 一个选择是使用一个在 VAX体系结构中的诸如 ap (自变量指针) 的处理器机制。还会对这以及 其他的可能情况在练习中再进一步讨论。 Ada非约束数组 (unconstrained array)是情况(2)的一个示例: type Int_Vector is array(INTEGER range <>) of INTEGER; procedure Sum (low, high: INTEGER; A: Int_Vector) return INTEGER is temp: Int_Array (low..high); begin ... end Sum; (请注意局部变量 temp,其大小不可预测 )。处理这种情况的典型办法是:为变量长度数据使用 间接的额外层,并将指针存放到一个在编译时可预测的地址中的实际数据里,同时执行期间用 sp可管理的方法在运行栈的顶部进行真正的分配。 例7.6 给定前面定义的Ada Sum过程,假定环境的组织也同上面一样 ,那么可以如下所示实 现Sum的活动记录 ( 这个图示具体地显示了一个当数组大小为 10时对Sum的调用): (栈的其余部分) A的大小:10 控制链 返回地址 调用Sum时的活动记录 可变长度数据区域 自由空间 现在,对 A[i]的访问就可由计算 @6 (fp) +2*i 得到。其中@意味着间接,且此时仍假设整型两个字节,地址 4个字节。 注意,在上例所描述的实现中,调用程序必须知道 Sum的任何活动记录的大小。而且编译 这在Ada中实际是不够的,它将会导致嵌套过程。参见本节后面的讨论。 279 第 7章 运行时环境 程序也了解在调用点上的参数部分和簿记部分的大小 (这是因为可以计算出自变量大小,而簿 记部分与所有的过程都相同 ),但是一般而言,却不知道调用点上的局部变量部分的大小。因 此,这个实现就要求编译程序为每个过程预先计算出局部变量的大小并将其存放在符号表中以 备后用。用类似的方法可处理可变长度局部变量。 我们还需提醒大家 C数组并不属于这类可变长度数据。实际上, C数组是一些指针,所以 数组参数是由引用在C中传递且并不局部分配(而且它们不带有任何尺寸信息 )。 4) 局部临时变量和嵌套声明 基于栈的运行时环境还有两个需要提及的复杂问题:局部临 时变量和嵌套声明。 过程调用时必须保存的计算是导致局部临时变量的部分原因。例如考虑 C表达式: x[i] = (i + j)*(i/k + f(j)) 在这个表达式从左到右的求值计算中,在对 f的调用过程中需要保存中间结果: x[i]的地址 (未决赋值)、计算i+j的和(加法的未决)以及i/k的商(和与f(j)的调用结果未决)。这些中间值 可计算到寄存器中,并根据某个寄存器管理机制进行保存和恢复,或者可将它们作为临时变量 存储在对 f调用之前的运行时栈中。在这后一种情况下,运行时栈可能会出现在对 f的调用之 前的点上,如下所示: (栈的其余部分) 控制链 返回地址 包含表达式过程的活动记录 x[i]的地址 i+j的结果 i/j的结果 临时栈 调用 f(将要创建的)时的 新活动记录 自由空间 在这种情况下,先前描述的利用 sp的调用序列并未改变。此外,编译程序还可以很便利 地从fp计算出栈顶的位置 (在缺少变量长度数据时 ),这是因为临时变量所要求的数量是编译时 决定的量。 嵌套声明也出现了类似的问题。考虑以下的 C代码 void p( int x, double y) { char a; int i; ... A:{ double x; int j; ... } ... B:{ char * a; 280 编译原理及实践 int k; ... } ... } 在这个代码中,在过程 p的主体中嵌套着两个分别标作 A和B的块(也称作复合语句 ),它们每个 都有两个作用域仅仅覆盖着其所在块 (也就是说向上直到下一个闭合的括号 )的局部声明。在 块进入之前无需对这些块的局部声明进行分配,而且块 A和块B也无需同时进行分配。编译程 序能够像对待过程一样处理块,并且在每次进入块时创建新的活动记录,并在退出时抛弃它。 然而,由于这样的块比过程简单得多,所以它的效率并不高:这样的块没有参数且无返回地 址,而且总是立即被执行而不是从其他地方调用。一个更简单的方法是按照与临时表达式相 类似的办法在嵌套的块中处理声明,并在进入块时在栈中分配它们而在退出时重新分配。 例如,在上面所给出的简单 C代码中进入块A之后,运行时栈应如下所示: (栈的其余部分) 控制链 返回地址 调用P时的活动记录 当进入块 B后,则如下所示: 自由空间 块A的分配区 (栈的其余部分) 控制链 返回地址 调用P时的活动记录 自由空间 块B的分配区 这样的实现必须这样小心地分配嵌套声明,周围过程块的 fp的偏移在编译时计算。特别是,这 281 第 7章 运行时环境 样的数据必须要在任何变量长度数据之前进行分配。例如在上面才给出的代码中,位于块 A上 的变量j从p的fp的偏移是-17 ( 再次假设整型 2个字节,地址 4个字节,浮点实数有个字节,而 字符是 1个字节),块 B中的k的偏移是- 13。 7.3.2 带有局部过程的基于栈的环境 如果在语言编译时允许有局部过程声明,那么前面所讲到的运行时环境就无效了,因为没 有提供非局部的和非全局的引用。 例如,考虑程序清单7-4中的Pascal代码 (在Ada中也可以写出类似的程序 ):在对q的调用中, 运行时环境如图 7-5所示。当使用标准的静态作用域规则时,在 q中每次提及 n必须指的是 p的 局部整型变量 n。正如我们在图7-5中所看到的一样,使用至今为止保存在运行时环境中的任何 簿记信息都无法找到这个 n。 程序清单7-4 Pascal程序显示非局部的非全局的引用 若我们愿意接受动态作用域,那么利用控制链有可能找到 n。观察图7-5,看到通过跟随控 制链就可以找到在 r的活动记录中的这个 n,而且若 r没有n的说明,则可以通过跟随另一个控 制链来找到 p的n (这个处理称为链接 (chaining),我们很快还会看到这个方法 )。不幸的是,不 仅仅是这个实现是动态作用域,可以找到 n的偏移也会随着调用的不同而不同 (请注意,在 r中 的n与在p中的n具有不同的偏移 )。因此,在这样的实现中,必须在执行时保存用于每个过程 的局部符号表,这样才能允许在每个活动记录中查询标识符,以及若它退出的话也可以看到, 并且可以判定出它的偏移。这是运行时环境的最主要的额外复杂性。 解决这个问题的方法也实现静态作用域,是将一个称作访问链 (access link)的额外簿记 信息添加到每个活动记录中。除了可以指向代表过程的定义环境而不是调用环境之外,访 282 编译原理及实践 问链与控制链相似。正是由于这个原因,即使它 不是编译时决定的量,访问链有时也被称作静态 主程序的 活动记录 链 (static link) 。 图7-6显示了将图 7-5中的运行时栈修改之后包 控制链 返回地址 调用p时的 活动记录 括了访问链的情况。在这个新的环境之下, r和q 的活动记录的访问链都指向 p的活动记录,这是 r 和 q 都在 p 中声明的缘故。因为这总是 p 的一个活 动记录,现在位于 q中的对 n的引用会引起后接访 问链,这里 n可在固定偏移处找到。通常,通过将 控制链 返回地址 控制链 返回地址 调用r时的 活动记录 调用q时的 活动记录 访问链装载到寄存器中,然后根据到这个寄存器 自由空间 (它此时作为 fp)的偏移访问 n,而在代码中完成。 例如,使用前面描述的大小约定,若寄存器 r用作 访问链,则在用值 4(fp) 装载 r之后可将 p中的 n作 图7-5 程序清单7-4中程序的运行时栈 为- 6(r)来访问 (访问链从图 7-6中的 fp得到偏 移+4)。 注意,正如由它将要到达的位置上的括号 中的注解所指出的,过程 p的活动记录本身并 无访问链 控制链 返回地址 主程序的 活动记录 调用p时的 活动记录 未包含有访问链。这是因为 p是一个全局过程, 所以 p中的任何非局部的引用必须都是全局引 用且通过全局引用机制来访问。因此,访问链 就是多余的了 (实际上为了与其他的过程保持 连贯性,可以很便利地插入空的或是任意的访 问链)。 访问链 控制链 返回地址 访问链 控制链 返回地址 调用r时的 活动记录 调用q时的 活动记录 上面讨论的情况实际是最简单的,其中非 局部引用是指向下一个最外面的作用域中的 自由空间 声明。而指向最远的作用域中的声明的非局 部引用也是可能的。例如在程序清单 7-5中的 代码。 图7-6 添加了访问链后的程序清单 7-4中 程序的运行时栈 程序清单 7-5 示范访问链的Pascal代码 我们当然了解定义过程,但却不知道它的活动记录的确切位置。 283 第 7章 运行时环境 在这段代码中,在过程q中说明了过程r,而过程q又是在过程p中说明的。因此,对于r中的x的 赋值 (即p的x) 必须越过两个作用域层去寻找x。图7-7显示了在对r的 (第1个) 调用之后的运行时 栈 (由于r可能递归地调用p,所以到r的调用可能不止一个)。在这种环境中,必须跟随两个访问 链才能到达x,这个过程称作访问链接 (access chaining)。访问链接是通过重复地取出访问链实 现的,利用前面取出的链好像它就是fp。图7-7中的x可如下进行访问 (使用前面的大小约定): Load 4(fp) into register r. Load 4(r) into register r. Now access x as -6(r). 对于用于访问链工作的方法,编译程序 必须能够在局部访问名字之前判定出要链接 多少个嵌套层。这就要求编译程序预先为每 个声明计算出嵌套层 (nesting level) 属性。通 常,将最远的作用域 ( Pascal中的主程序层或 是C中的外部作用域 ) 给定为嵌套层 0,每次 进入一个函数或过程 (在编译时 ),嵌套层就 增加1,退出时则减去 1。例如,在程序清单 7-5的代码中,由于过程 p是全局的,所以它 的嵌套层为 0;由于在进入 p时嵌套层增加了, 无访问链 控制链 返回地址 访问链 控制链 返回地址 访问链 控制链 返回地址 主程序的活动 记录 调用p时的活动 记录 调用q时的活动 记录 调用r时的活动 记录 所以变量 x的嵌套层为 1;由于过程q对于p是 局部的,所以它的嵌套层也为 1;而过程r的 自由空间 嵌套层为 2的原因是当进入 q时嵌套层再次增 加了。最后,在 r内的嵌套再一次增加到3。 现在通过比较在访问点上的嵌套层与名 图7-7 程序清单7-5代码中第1次对r的调用 之后的运行时栈 字声明的嵌套层,可判断出访问非局部名字所必须的链接的数量;后面跟随的访问链接数是这 两个嵌套层的差。例如,在前面的情况中,对 x的赋值发生在嵌套层 3,而x具有嵌套层 1,所 以必须跟在两个访问链接之后。一般而言,若在嵌套层中的差是 m,那么为访问链接而必须生 成的代码须将m 装入到一个寄存器 r上,且使第1个使用fp,其他的使用r。 由于必须为每个带有大的嵌套差的非局部引用执行一个很长的指令序列,所以对于变量 访问访问链接看起来效率不高。但在实际运用中,嵌套层极少有超过两个到 3个深度的,而 且大多数的非局部引用都是对全局变量的 (嵌套层为 0),这样就可利用前面所讲到的直接办 法对它们继续访问了。在嵌套层索引的查询表中有一个实现访问链接方法,链接不会带来 执行开销。该方法中所用到的数据结构称作显示 (display)。它的结构与使用在练习中论述。 284 编译原理及实践 1) 调用序列 实现访问链接的调用序列的改变相对比较简单。在实现中,调用时必须将访 问链压入到 fp之前的运行时栈中,退出之后必须用一个额外的量来修改 sp以便像对自变量一样 删掉访问链接。 唯一的问题是在调用时寻找过程的访问链接。通过使用附在正调用的过程声明之上的 (编 译时)嵌套层信息可以解决这个问题。实际上, 我们所要做的仅是生成一个访问链接,就像 在过程调用的同一嵌套层上访问一个变量。 这样计算所得出的地址就是相应的访问链。 当然若过程是局部的 (在嵌套层中的差是 0), 无访问链 控制链 返回地址 主程序的活动 记录 调用p时的活动 记录 那么访问链与控制链就是相同的 (而且也与在 调用点上的 fp相同)。 例如可考虑程序清单 7-4上的 r中对 q的 访问链 控制链 返回地址 调用q时的活动 记录 调用。在 r内,位于嵌套层 2,而 q的声明在 嵌套层 1中 (这是因为 q对于p而言是局部的, 而在 p内的嵌套层是 1)。因此,一个访问步 骤就要求计算 q的访问链,而在图 7-6中, q 的访问链指向 p的活动记录 (且与r的访问链 访问链 控制链 返回地址 访问链 控制链 返回地址 调用r时的活动 记录 调用p时的活动 记录 相同)。 请注意,即使是位于定义环境的多重活 动中,这个过程也将计算出正确的访问链, 访问链 控制链 返回地址 调用q时的活动 记录 因为计算是在运行时而不是在编译时进行的 (利用编译时嵌套层 )。例如,假设有程序清 单7-5的代码,在对 r的第2个调用后 (假定是 访问链 控制链 返回地址 调用r时的活动 记录 对p的递归调用 ),运行时栈如图 7-8 所示。在 自由空间 该图中, r有两个不同的活动记录,带有两 个不同的访问链,指向 q的不同的活动记录, 代表 r不同的定义环境。 图7-8 在程序清单7-5的代码中对r的第2次 调用之后的运行时栈 7.3.3 带有过程参数的基于栈的环境 在某些语言中,不仅允许有局部过程,而且还可将过程作为参数传递。在这样的语言中, 当调用一个作为参数传递的过程时,编译程序不可能像前一节所讲的那样生成代码以计算调用 点上的访问链。当将过程作为参数传递时,必须预先计算出过程的访问链并与过程代码的指针 一同传递。因此,再也不能将过程参数值看作是一个简单的代码指针了,它应包含一个访问指 针,定义解决非局部引用的环境。这个指针对,一个代码指针和一个访问链,或一个指令指针 (instruction pointer) 和环境指针 (environment pointer),一同表示了过程或函数参数的值,它们 通称为闭包 (closure)(这是因为访问链“闭合了”由非局部引用引起的“洞” ) 。我们将闭包表 示为,其中ip表示过程的指令指针 (代码指针或入口点 ),而ep表示过程的环境指针 (访 问链)。 这个术语在 λ微积分中有它自己的来源,且它不会与正则表达式或 NFA状态中的 闭包的 (Kleene) 闭包运算 相混淆。 285 第 7章 运行时环境 例7.7 考虑程序清单 7-6中的标准 Pascal程序,它有一个过程 p,带有一个也是过程的参数 a。 在q中对p调用之后,q的过程r传递到p,p中的对a的调用实际上调用的是 r,而且这个调用仍 必须在q的活动中寻找非局部变量 x。当调用 p时,将a构造为闭包,其中ip是指向 r的 代码的指针,而 ep是在调用点 fp的拷贝 (也就是它指向调用 q的环境,其中定义了 r)。a的ep的 值由图7-9的虚线指明,表示在 q中的调用 p之后的环境。接着当在 p内调用 a时,就将 a的ep用 作其活动记录的静态链,如图 7-10所示。 程序清单7-6 带有作为参数的过程的标准 Pascal代码 无访问链 控制链 返回地址 x: 2 a: 无访问链 控制链 返回地址 自由空间 主程序的活动 记录 调用q时的活动 记录 调用p时的活动 记录 无访问链 控制链 返回地址 x: 2 a: 无访问链 控制链 返回地址 访问链 控制链 返回地址 自由空间 主程序的活动 记录 调用q时的活动 记录 调用p时的活动 记录 调用a时的活动 记录 图7-9 程序清单7-6的代码中调用 p之后 的运行时栈 图7-10 程序清单7-6的代码中调用 a 之后的运行时栈 现在,刚刚描述的环境中的调用序列能很清楚地区分常规过程和过程参数。同前面一样, 286 编译原理及实践 常规过程调用是使用过程的嵌套层取出访 问链,并直接跳到过程的代码 (在编译时已 知)。而另一方面,过程参数早已得到了它 的访问链,并已将其存放在局部活动记录 中,而这一记录又必须取出并插入到新的 活动记录中。然而,编译程序却无法直接 无访问链 控制链 返回地址 全局/静态区 主程序的活动 记录 调用q时的活动 记录 得到过程代码的地址;而必须对存放在当 前活动记录中的 ip进行间接调用。 为了保持简洁和一致性,编译程序的编 写者可能会希望要避免常规过程与过程参 无访问链 控制链 返回地址 调用p时的活动 记录 数之间的这种区别,并将所有的过程都作 为环境中的闭包。实际上,若语言对过程 的处理越普通,则这个方法就越合理。例 访问链 控制链 返回地址 调用a时的活动 记录 如,若允许有过程变量,或可以动态地计 自由空间 算过程变量,则过程的 表示就变成 了对这种情形的要求了。图 7-11显示了当所 有的过程值都存放在作为闭包的环境中时 图7-10的环境。 图7-11 程序清单7-6的代码调用a之后的运行时 栈,所有的过程都作为环境中的闭包 最后,我们注意到C、Modula-2和Ada都避免本节所提到的复杂情况:对于 C而言,它没有 局部过程(即使它有参数和变量 );对于Modula-2而言,是一个特殊的规则限制了过程参数和过 程变量值都应是全局过程;而对于 Ada而言,则是由于它没有过程参数和变量。 7.4 动态存储器 7.4.1 完全动态运行时环境 上一节讨论的基于运行时环境的栈在 C、Pascal以及Ada这样的标准命令式语言中是最普通 的环境格式。但这样的环境也有限制。尤其是如果一种语言在过程中对局部变量的引用可返回 到调用程序,无论是显式的还是隐含的,在过程退出时的基于栈的环境都会导致摇摆引用 (dangling reference),这是因为过程的活动记录将从栈中释放分配。最简单的示例是返回局部 变量的地址,如在 C代码中: int * dangle(void) { int x ; return &x;} 现在赋值addr = dangle()使addr指向活动栈中的不安全的地址,它的值可由后面对任何 过程的调用随机改变。 C对此类问题的处理是,只说明这样的程序是错误的 (尽管没有哪个编译 程序会给出错误信息 )。换而言之, C的语义被建立在基于栈的环境之下。 若调用可返回局部函数,则会发生更为复杂的摇摆引用情况。例如,如 C允许有局部函数 定义,则程序清单 7-7的代码就会出现一个对 x和g的参数的间接摇摆引用,在 g退出后调用 f就 可访问到它了。当然 C是通过禁止局部过程的存在来避免这个问题的。诸如 Modula-2的其他语 言既有局部过程也有过程变量、参数和返回值,就必须定出一个特殊规则使程序报错 (在 Modula-2中,该规则是仅有全局过程才可以是自变量或返回值——这甚至是从 Pascal风格过程 287 第 7章 运行时环境 变量的主要退步)。 程序清单 7-7 显示返回局部函数引起的摇摆引用的伪 C代码 但是在很多语言中,这样的规则并不适用,即那些像 LISP和ML的函数程序设计语言。设 计函数语言的一个主要原则是函数应尽可能的通用,而这就意味着函数应是能够局部定义的, 并像参数一样传递,作为结果返回。因此,对于此类的语言而言,基于栈的运行时环境并不合 适,而且需要一个更一般的环境格式。因为活动记录仅在对它们所有的引用都消失了才再重新 分配,而且这又要求活动记录在执行时可动态地释放任意次,所以称这个环境为完全动态的 (fully dynamic)。因为完全动态运行时环境包含了要在执行时跟踪引用,以及在执行时任意次 地找寻和重新分配存储器的不可访问区域 (这种处理称作废弃单元收集 (garbage collection)),所 以这种环境比基于栈的环境要复杂许多。 尽管在这个环境中增加的了复杂性,其活动记录的基本结构仍保持不变:必须为参数和局 部变量分配空间,而且仍然需要控制链和访问链。当然,现在当控制返回到调用程序时 (且使 用控制链来恢复先前的环境 ),退出的活动记录仍留在存储器中,而且在以后的某个时刻被重 新分配。因此这个环境的整个额外的复杂性可被压缩到存储器管理程序中,这个管理程序将取 代带有更普通的分配和重新分配例程的运行时栈操作。本节后面还会谈到一些有关这样的存储 器管理程序的设计问题。 7.4.2 面向对象的语言中的动态存储器 面向对象的语言在运行时环境中要求特殊的机制以完成其增添的特征:对象、方法、继承 以及动态装订。这一小节将给出有关这些特征的各种实现技术。我们假设读者对于基本的面向 对象的技术和概念比较熟悉 。 面向对象语言在对运行时环境方面的要求差异很大。 Smalltalk和C++是这种差异的极好的 代表者:Smalltalk要求与LISP相似的完全动态环境;而 C++则在设计上花了很大的功夫以保持 C的基于栈的环境,它并不需要自动动态存储器管理。在这两种语言中,存储器中的对象可被 看作是传统记录结构和活动记录之间的交叉,且带有作为记录域的实例变量 (数据成员)。这个 结构与传统记录在对方法和继承特征的访问上有着一定的差别。 实现对象的一个简单机制是,初始化代码将所有当前的继承特征 (和方法)直接地复制到记 以后的讨论也假设只有继承性是可用的。“注意与参考”部分中的某些著作还谈到了多重继承性。 288 编译原理及实践 录结构中 (将方法当作代码指针 )。但这样做极浪费空间。另外一种方法是在执行时将类结构的 一个完整的描述保存在每个点的存储器中,并由超类指针维护继承性 (有时这也称作继承图 (inheritance graph))。接着同用于它的实例变量的域一起,每个对象保持一个指向其定义类的指 针,通过这个类就可找到所有 (局部和继承的 )的方法。此时,只记录一次方法指针 (在类结构 中),而且对于每个对象并不将其复制到存储器中。由于是通过类继承的搜索来找到这个机制 的,所以该机制还实现继承性与动态联编。其缺点在于:虽然实例变量具有可预测的偏移量 (正如在标准环境中的局部变量一样 ),方法却没有,而且它们必须由带有查询功能的符号表结 构中的名字维护。然而,它是对于诸如 Smalltalk的高度动态语言的合理的结构,其中对于类结 构的改变可以发生在执行中。 将整个类结构保存在环境中的另一种方法是,计算出每个类的可用方法的代码指针列表, 并将其作为一个虚拟函数表 (virtual function table)(C++术语) 而存放在(静态)存储器。它的优点 在于:可做出安排以使每个方法都有一个可预测的偏移量,而且也就不再需要用一系列表查询 遍历类的层次结构。现在每个对象都包括了一个指向相应的虚拟函数表而不是类结构的指针 (当然,这个指针的位置必须也有可预测的偏移量 )。这种简化仅在类结构本身是固定在执行之 前的情况下才成立。它是 C++中选择的方法。 例7.8 考虑以下的C++类声明: class A { public: double x,y; void f(); virtual void g(); }; class B:public A { public: double z; void f(); virtual void h(); }; 类A的一个对象应出现在存储器中 (带有它的虚拟函数表),如下所示: 虚拟函数表指针 而类B的一个对象则应如下所示: A的虚拟函数表 虚拟函数表指针 B的虚拟函数表 每次增添对象结构时,注意虚拟函数指针如何保留固定的地址,这样就可在执行之前知道 289 第 7章 运行时环境 它的偏移量。还应注意 (由于函数f没有声明“虚拟” ),它并不遵守C++动态联编,因此也就不 出现在虚拟函数表 (或环境中的任何其他地方):在编译时决定对 f的调用。 7.4.3 堆管理 在7.4.1节中,我们讨论了如果完全支持一般的函数,那么与在大多数编译语言使用的基于 栈的运行时环境比,更需要具有动态性。但在绝大多数语言中,即使是基于栈的环境也需要一 些动态功能以处理指针分配和重新分配。处理这样的分配的数据结构称作堆,堆通常作为存储 器中的一个线性块分配,这样如果需要它还可以生长,而且对栈的干扰尽可能小 (7.1节已显示 了堆位于栈区域的相反一端的存储器块中 )。 本章一直到这里,我们的注意力都是放在活动记录和运行时栈的组织上。而在本节中,我们 希望描述一下如何管理堆,以及如何将堆操作扩展,提供带一般函数功能的语言要求的动态分配。 堆提供两个操作:分配操作和释放操作。分配操作通常是按字节数得到一个大小参数 (或 显式或隐含 ),并返回一个指向正确大小的存储器块的指针,或若不存在则返回一个空指针。 释放操作得到一个指向被分配的存储器块的指针并再次将它标为空的 (释放操作必须还能通过 或显式或隐含的参数来发现将空的块的大小。 )这两个操作存在于许多语言的不同名称之下: 在Pascal分别称作 new和dispose,在 C++中称作 new和delete。C语言中有这些操作的若干 个版本,但最基本的是 malloc和free,它们都是标准库 (stdlib.h) 的一部分,此时它们 基本都有以下的声明: void * malloc (unsigned nbytes); void free (void * ap); 我们将用这些声明作为堆管理的基本描述。 维护堆和实现这些函数的标准方法是使用空块的环形链接列表, malloc从中得到存储器 而free返回存储器。它具有简单的优点,但也有缺点:其一, free操作不能辨认出它的指针 自变量是否是它真正指向的由 malloc先前分配的合法块。若用户传递了一个无效的指针时, 则堆就会很容易和很快坏掉。其二 (问题并不很严重 )是必须注意,返回处附近有空的块列表时 要合并 (coalesce) 块,因为这样会导致最大的空块。若不合并,堆就会很快变成碎片 (fragmented),也就是被分割成大量的较小的块,这样尽管有足够多的全部可用空间可用于分 配,但分配大块时却失败了(在合并中当然也可能有碎片 )。 在这里我们指出使用环形链接的列表数据结构的 malloc和free在实现上的一点差异,这个 数据结构保存分配的和空的块 (因此也就不易坏掉 ),也提供在自合并块方面的优点。程序清单 7 - 8给出了代码。 程序清单 7-8 维护邻近的存储器的 C代码,使用指向使用块和空块的指针 290 编译原理及实践 这个代码使用容量 MEMSIZE的静态分配数组作为堆,但也可使用操作系统调用分配堆。 我们定义了一个数据类型 Header保存每个存储器块的簿记信息,定义了具有 Header类型元 素的堆数组,这样就可很容易地将簿记信息保存在存储器块中。类型 Header包含了3块信息: 指向列表的下一个块的指针,当前分配空间的长度 (位于存储器之后 ),以及任何后面的自由间 的长度(若有的话)。因此,列表中的每个块都有格式 next usedsize freesize 已占用的空间 自由空间 291 第 7章 运行时环境 程序清单 7-8中类型Header的定义还使用了一个 union声明和Align数据类型 (在代码中 将其设为 double )。这是将存储器元素排在合理的字节边界上,根据系统的不同,这有时是需 要的,有时是不需要的。后面的描述中可安全地把这种复杂性忽略掉。 堆操作还需要的另一片数据是指向环形链接的列表中的一个块的指针。这个指针称作 memptr,它总是指向具有一些自由空间的块 (通常是被分配或释放的最后一个空间 )。它被初 始化为NULL,但是在malloc的第一次调用上,对初始化代码的执行是通过将 memptr设置为 堆数组的开头并初始化数组的头部,如下所示: 分配的头部 自由空间 这个在第1次调用malloc时分配的初始化头部永远也不会被释放。这时在列表中有一个块, 而其余的 malloc代码搜索该列表并从具有足够自由空间的第 1个块中返回一个新块 (这是首次 适用 (first fit ) 算法)。因此在对malloc的3次调用之后,该列表看起来应是这样的: 占用的 占用的 占用的 自由的 注意,当连续分配块时,每次都会生成一个新块,并且还有前面块所剩下的自由空间 (因 此从块的自由空间生成的分配总是将 freesize设置为 0)。memptr跟随在新块的构造之后, 所以它总是指向某个自由空间的块。大家还要注意, malloc总是增加指向新创建块的指针, 所以也将头部保护起来而不会被客户程序覆盖 (只要在返回存储器中使用正向偏移 )。 现在来考虑free过程的代码。它首先把用户传递的指针减1,以找到块的头部。接着它再搜 索列表以寻找与之相同的指针,以保护该列表防止坏掉,而且还能计算指向先前块的指针。一旦 找到就将该块从列表中删除,且将其使用过的和自由空间都添加到先前块的自由空间中,所以也 就自动地合并了自由空间。请读者注意,还将memptr设置为指向包含了刚才释放的存储器的块。 例如,假设将上图中3 个使用过的块的中间一个释放了,则堆和与之相关的块列表应如下所示: 占用的 自由的 占用的 自由的 292 编译原理及实践 7.4.4 堆的自动管理 由于程序员必须编写出到分配和释放存储器的明确的调用,所以用 malloc和free完成指 针的动态分配和重新分配是管理堆的手工 (manual)方法。相反地,运行时栈则是由调用序列自 动地 (automatically)管理。在一种需要完全动态的运行时环境的语言中,堆也必须类似地自动 管理。然而尽管在每个过程调用中可以很方便地调度对 malloc的调用,但是由于活动记录必 须要持续到其所有的引用都消失为止,所以退出时却很难调度对 free的调用。因此,自动存 储器管理涉及到了前面分配的但不再使用的存储器的回收,可能是在它被分配的很久以后,而 没有明确的对 free的调用。这个过程称作垃圾回收 (garbage collection)。 在存储块不再引用时,无论是直接还是通过指针间接的引用,识别是一项比维护堆存储块 的列表复杂得多的任务。标准的技术是执行标识和打扫 (mark and sweep)垃圾回收 。在这种方 法中,直到一个对 malloc的调用失败之前都不会释放存储器,在这时将垃圾回收程序激活, 寻找可被引用的所有存储器并释放所有未引用的存储器。这是通过两遍来完成的。第 1遍递归 地顺着所有的指针前进,从所有当前的可访问指针值开始,并标出到达的每个存储器块。这个 过程要求额外的位存储标识。另一个遍则线性地打扫存储器,并将未标出的块返回到自由存储 器中。虽然这个过程通常要寻找足够的相邻自由存储器以满足一系列的新要求,但存储器仍有 可能是非常破碎,故尽管是在垃圾回收之后,大的存储请求仍旧会失败。因此,垃圾回收经常 也会通过将所有的分配的空间移到堆的末尾,以及在另一端留下相邻的自由空间的唯一一个大 型块而执行存储器压缩(memory compaction)。这个过程还必须在存储器中更新对那些在执行程 序时被移掉的区域的所有引用。 标识和打扫垃圾回收有若干个缺点:它要求额外的存储 (用于标识 ),在存储器中的两个遍 导致了过程中很大的延迟,有时需要几秒钟,而每一次调用垃圾回收程序又都需要几分钟时间。 这对于那些许多涉及到了交互和即时响应的应用程序显然是不合适的。 可以通过将可用的存储器分为两个部分并每次只从一个部分中分配存储来对这个过程进行 改进。在标识遍时,将所有到达了的块都复制到未被使用的另一半存储器中。这就意味着在存 储时不再要求额外的标识位而且一个遍就够了。它还自动地进行压缩。一但位于使用的区域中 的所有可到达的块都复制好时,就将使用的和未使用的存储器部分相互交换,而过程依然继续 进行。这种方法称作停机和复制 (stop-and-copy)或二部空间 (two space)垃圾回收。然而它对存 储回收中的过程延迟改进不大。 最近又提出了一个大大减少延迟的方法,称为生育的垃圾回收(generational garbage collection), 它将一个永久的存储区域添加到前一段描述的回收方案中。将存在时间足够长的被分配的对象只 复制到永久空间中,并在随后的存储回收时不再重新分配。这就意味着垃圾回收程序在更新的存 储分配时只需要搜索存储器中的很小的一个部分。当然永久存储器也有可能由于不可达到的存储 而用尽,但这相对于前面的问题就不那么严重了,这是因为临时存储会很快消失,而可被分配的 存储则总会有的。人们已证明了这个处理很好,在虚拟存储系统中尤为如此。 读者可在 “注意与参考”一节中查阅此种方法的具体细节以及其他的垃圾回收方法。 7.5 参数传递机制 我们已经看到了在过程调用中,参数是如何通过调用程序在跳到被调用过程的代码之前与 另一种称为引用计数 (reference counting)的更为简单的方法也经常用到。参见“注意与参考”一节。 293 第 7章 运行时环境 活动记录中的位置相对应的,该活动记录则由自变量或参数值组成。因此对于被调用的过程而 言,参数代表了没有附加代码的完全正式的值,但该值仅在代码可以发现其最终值的活动记录 中建立一个位置,这一最终值只在发生调用时才退出。建立这些值的过程有时是自变量的参数 的绑定 (binding)。自变量的值是如何由过程代码解释依赖于源语言的采用的特定参数传递机制 (parameter passing mechanism(s))。正如早已提到过的, FORTRAN77采用的就是将参数传递到 位置而不是值的机制,而 C则将所有的自变量都当作是值。其他的语言,诸如 C++、Pascal和 Ada则是提供参数传递机制的选择。 在本节中,我们将讨论两个最常用的参数传递机制,值传递 (pass by value)和引用传递 (pass by reference)(有时也称作由值调用和由引用调用 ),此外还有两个重要方法,由值的结果 传递(pass by value-result)和由名字传递(pass by name)(也称作延迟赋值(delayed evaluation))。它 们的一些变形则放到了练习中。 未由参数传递机制本身说明的一个问题是自变量计算的顺序。在大多数情况下,这个顺序 对于程序的执行并不重要,而且任何的计算顺序都是产生相同的结果。此时为了有较高的效率 或其他原因,编译程序可能会选择改变自变量计算的顺序。但是许多语言却允许会导致副作用 的自变量调用(改变存储器)。例如C的函数调用 f(++x,x); 会使 x的值改变,所以不同的计算顺序会引起不同的结果。在这样的语言中,可能会要指定诸 如从左到右的标准顺序,或者由编译程序的编写者来决定,而此时调用的结果在各种实现中都 各不相同。特别地, C编译程序是从右到左计算它们的自变量。这就允许有不同数量的自变量 (例如在printf函数中),如在7.3.1节中的讨论。 7.5.1 值传递 在这个机制中,自变量是在调用时计算的表达式,而且在执行过程时,它们的值就成为了 参数的值。这是在C中唯一可用的参数传递机制,且在 Pascal和Ada中是缺省的(Ada还允许将这 样的参数显式地指定为传入 (in )参数)。 在最简单的格式中,这就意味着值参数在执行过程中是作为常量值,而且可将值传递解释为 用自变量的值取代过程体中的所有参数。Ada使用这个值传递的格式,此时不能给这样的参数赋 值或作为局部变量来使用。 C和Pascal采用更宽松的观点,在其中的值参数在本质上被看作是被 初始化了的局部变量,该变量可被用作常规变量,但是它们的改变不会引起任何非局部的改变。 在诸如 C这样仅提供值传递的语言中,通过改变它的参数直接写一个过程来达到目的是不 可能的。例如,以下用 C中写出的 inc2函数并没有达到其预想的效果: void inc2( int x) /* incorrect! */ { ++x;++x; } 但在理论上,可能用函数恰当的一般性,通过返回相应的值而不是改变参数值来完成所有的计 算,像 C这样的语言通常提供使用值传递的方法,进行非局部改变。在 C中,它使用了传递地 址而不是值的格式 (而且因此改变了参数的数据类型 ): void inc2( int* x) /* now ok */ { ++(*x);++(*x); } 当然由于要求y的地址而不是它的值,所以增加变量 y,这个函数必须被称作inc2(&y)。 294 编译原理及实践 由于数组是隐含指针,这种方法在 C中用于数组时特别好,而且值传递允许改变单个的数 组元素: void init(int x[],int size) /* this works fine when called as init(a), where a is an array */ { int i; for(i=0;iattr.name) == -1) st_insert(t->attr.name,t->lineno,location++); else st_insert(t->attr.name,t->lineno,0); 当st_lookup返回-1时,变量并不在表中。此时就记录下一个新的地址并添加了位置计数器。 另一种情况是变量早已在表中,此时符号表忽略地址参数 (并写下0作为一个虚构的地址 )。 上面所述内容处理了在 TINY程序中分配命名了的变量:位于存储器顶部的临时变量的分 配以及保留这个分配所需的操作都由代码生成器负责,这将在下一章讨论到。 练习 7.1 为以下的FORTRAN77程序的运行时环境画出一个可能的组织结构,它应与图 7-1相类 似。还要保证包括了与对 AVE的调用时存在的一样的存储器指针。 REAL A(SIZE),AVE INTEGER N,I 10 READ *, N IF (N.LE.0.OR.N.GT.SIZE) GOTO 99 READ *,(A(I),I=1,N) PRINT *, 'AVE = ',AVE(A,N) GOTO 10 99 CONTINUE END REAL FUNCTION AVE(B,N) INTEGER I,N REAL B(N),SUM SUM = 0.0 DO 20 I=1,N 20 SUM=SUM+B(I) AVE = SUM/N END 7.2 为以下的C程序的运行时环境画出一个可能的组织结构,它应与图 7-2相类似。 298 编译原理及实践 a. 在进入函数f中的块A之后。 b. 在进入函数g中的块B之后。 int a[10]; char * s = "hello"; int f(int i, int b[]) { int j=i; A:{ int i=j; char c = b[i]; ... } return 0; } void g(char * s) { char c = s[0]; B:{ int a[5]; ... } } main() { int x=1; x = f(x,a); g(s); return 0; } 7.3 在对factor的第2次调用之后为程序清单 4-1中的C程序的运行时环境画出一个可能 的组织结构,假设输入串为 (2)。 7.4 为以下的Pascal程序画出活动记录的栈,并在对过程 c的第2次调用之后表示出控制和 访问链。描述如何在 c中访问变量 x。 program env; procedure a; var x:integer ; procedure b; procedure c; begin x := 2; b; end; begin (* b *) c; end; begin (* a *) b; end; begin (* main *) a; end. 299 第 7章 运行时环境 7.5 为以下的Pascal程序画出活动记录的栈 a. 在对p的第1次调用中对a的调用之后。 b. 在对p的第2次调用中对a的调用之后。 c. 程序打印出什么?为什么? program closureEx(output); var x:integer; procedure one; begin writeln(x); end; procedure p(procedure a); begin a; end; procedure q; var x:integer; procedure two; begin writeln(x); end; begin x := 2; p(one); p(two); end; (* q *) begin (*main *) x := 1; q; end. 7.6 考虑以下的 Pascal程序。假设一个用户输入包括了 3个数1、2和0,则当第 1次打印数1 画出活动记录的栈。包括所有的控制和访问链以及所有的参数和全局变量,并假设将 所有的过程都存储在作为闭包的环境中。 program procenv(input,output); procedure dolist (procedure print); var x:integer; procedure newprint; begin print; writeln(x); end; begin (* dolist *) readln(x); if x = 0 then begin print; print; 300 编译原理及实践 end else dolist(newprint); end; (* dolist *) procedure null; begin end; begin (* main *) dolist ( null ) ; end. 7.7 为了完成完整的静态分配, FORTRAN77编译程序需要构造对于程序中的任何表达式 计算所需的临时变量的最大数的估计。设计一个方法来估计计算一个表达式所需临时 变量的数目,计算通过表达式树的遍历进行。假设表达式是从左到右赋值且必须将每 个左子表达式保存在临时变量中。 7.8 在允许过程调用中包含可变数量自变量的语言中,找到第 1个自变量的一种方法是根 据7 . 3 . 1节所述按相反的顺序计算出自变量。 a. 按相反顺序计算自变量的另一种方法是识别活动记录,以使用第1个自变量即使是在 可变数量自变量时也是适用的。描述这样的活动记录组织以及它所需的调用序列。 b. 另一个办法是使用除 sp和fp之外的另一个称作 ap(自变量指针 )的指针。描述使用 ap 寻找第1个自变量和其所需的调用序列的活动记录结构。 7.9 本书讲解了如何处理通过值传递可变长参数 (如开放式数组 )(参见例7.6),并提出了一 个与变长局部变量作用相似的方法。但是当两个变长参数和局部参数都出现时就会有 问题了。请利用以下的 Ada过程作为示例来描述这个问题并提出解决办法: type IntAr is Array(Integer range <>) of Integer; ... procedure f(x:IntAr; n:Integer) is y: Array(1..n) of Integer; i: Integer; begin ... end f; 7.10 利用局部过程在语言中访问链的另一种方法是由嵌套层在栈外的一个数组中保存访 问链。这个数组称作显示 (display)。例如当图7-7中的运行时栈带有显示时,应如下 所示: 主程序的活动记录 调用p时的活动记录 d i s p l a y [ 1] d i s p l a y [ 2] 调用q时的活动记录 调用r时的活动记录 自由空间 301 第 7章 运行时环境 而图7-8中的运行时栈如下所示: 主程序的活动记录 调用p时的活动记录 调用q时的活动记录 调用r时的活动记录 调用p时的活动记录 调用q时的活动记录 调用r时的活动记录 自由空间 display[1] display[2] a. 描述显示如何可以从很深嵌套的过程中提高非局部引用的效率。 b. 使用显示重做练习 7.4。 c. 描述实现显示必需的调用序列。 d. 利用过程参数在一个语言中使用显示出现了一个问题。利用练习 7.5描述问题。 7.11 考虑C语法中的以下过程: void f( char c, char s[10], double r ) { int * x; int y[5]; ... } a. 使用标准C参数传递约定,并假设数据大小为:整型 = 2个字节,字符 = 1个字节, 双精度 = 8个字节,地址 = 4个字节,利用本章所描述的活动记录结构判断以下的 fp的偏移:(1) c. (2) s[7]. (3) y[2]. b. 假设所有的参数都由值(包括数组)传递,重复a。 c. 假设所有的参数都引用传递,重复 a。 7.12 执行以下的C程序并用运行时环境解释其输出: #include void g(void) { {int x; printf("%d\n",x); x = 3;} {int y; printf("%d\n",y);} } 302 编译原理及实践 int* f(void ) { int x; printf("%d\n",x); return &x; } void main() { int *p; p = f(); *p =1; f(;) g(); } 7.13 为以下的C++类画出对象的存储器框架以及如 7.4.2节所述的虚拟函数表: class A { public: int a; virtual void f(); virtual void g(); }; class B : public A { public : int b; virtual void f(); void h(); }; class C : public B { public: int c; virtual void g(); } 7.14 在面向对象的语言中的虚拟函数表为一个方法而保存层次图搜索遍历,但这是有代 价的。请解释这个代价是什么。 7.15 利用7.5节中所谈到的4个参数传递办法给出以下程序的输出 (用C语法编写): #include int i=0; void p(int x, int y) { x += 1; i += 1; y += 1; } main() { int a[2]={1,1}; p(a[i],a[i]); printf("%d %d\n",a[0],a[1]); return 0; } 303 第 7章 运行时环境 7.16 利用7.5节中所谈到的参数传递办法给出以下程序的输出 (在C语法中): #include int i=0; void swap(int x, int y) { x = x + y; y = x - y; x = x - y; } main() { int a[3] = {1,2,0}; swap(i,a[i]); printf("%d %d %d %d\n",i,a[0],a[1],a[2]); return 0; } 7.17 假设将FORTRAN77的子例程p说明如下: SUBROUTINE P(A) INTEGER A PRINT *, A A=A+1 RETURN END 且从主程序中调用如下: CALL P(1) 在某些FORTRAN77系统中,这将导致一个运行时的错误。而在其他系统中却不会发 生运行时错误,但若用 1作为其自变量而再调用一次子例程,它将会打印出值 2。请 根据运行时环境解释这两个行为是如何发生的。 7.18 名字传递的一个变形称作由文本传递 (pass by text) ,其中的自变量的赋值是用的延 迟风格,这与名字传递相同,但每个自变量都是在被调用的过程的环境中而不是在 调用的环境中赋值。 a. 说明由文本传递的结果可与名字传递不同。 b. 描述一个运行时环境组织以及可被用作实现由文本传递的调用序列。 编程练习 7.19 正如在7.5节中所描述的一样,名字传递或延迟赋值都可被看作是将自变量包在一个 函数体中 (或中止),它在参数每次出现在代码中时被调用。重写练习 7.16的C代码以 在这个风格中实现 swap函数的参数,并证实该结果确实是与由名字传递相等。 7.20 a. 正如在 7.5.4节中所述,名字传递中的一个有效的实现可以通过记忆它第 1次赋值 的自变量的值。重写前一个练习中的代码以实现这样的记忆,并比较两个练习的 结果。 b. 记忆会导致与名字传递所不同的结果。请解释它是如何发生的。 7.21 可将压缩 (7.4.4节) 垃圾回收分成两个不同的步骤,并当存储器要求由于缺少足够的 大型块而失败时也可由 malloc完成。 304 编译原理及实践 a. 重写7.4.3节中的malloc过程以包括压缩步骤。 b. 压缩需要先前分配空间改变的位置信息,这意味着程序必须发现这些改变。描述 如何使用指向内存块的指针表来解决这个问题,并重写 a部分的代码以包括这个 功能。 注意与参考 FORTRAN77(以及更早的FORTRAN版本)的完全静态环境给出了一个类似汇编环境的原始 而直接的环境设计方法。基于栈的环境随着诸如 Algol60的包含递归的语言的出现而流行起来 (Naur[1963])。Randell和Russell [1964]详细描述了早期的 Alogol60基于栈的环境。用于一些C编 译器的活动记录组织和调用序列的描述在 Johnson和Ritchie [1981]中。用显示代替访问链 (练习 7.10)在Fisher和LeBlanc[1991]中有详细描述,其中还包括在有过程形式参数的语言中使用时将 出现的问题。 动态内存管理在许多数据结构的书中有所讨论,比如 Aho,Hopcrott和Ullman [1983]。一个 实用的近期浏览在 Drozdek和Simon[1995]中给出。 malloc 和free的代码实现也是类似的,但 Kernighan 和Ritchie[1988]中比7.4.3节的代码稍微简单一些。编译中使用的堆结构设计在 Fraser 和Hanson [1995]中讨论。 一个垃圾回收的浏览可以在 Wilson[1992]或Cohen[1981]中找到。生成的垃圾回收和用于函 数语言ML运行时环境在Appel [1992]中描述。Gofer函数语言编译器(Jones[1984])包括标记和清 除以及一个两阶段垃圾回收。 Budd[1987]描述一个用于小型 Smalltalk系统的完全动态环境,包括继承图的应用和带有引 用计数的垃圾回收器。 C++中使用的虚拟函数表在 Ellis和Stroustrup[1990]中描述,还包括处理 多继承的扩展。 更多的参数传递技术可以在 Louden[1993]中找到,其中还有懒惰赋值的描述。懒惰赋值的 实现技术可以在 Peyton Jones[1987]中找到。 第8章 代 码 生 成 本章要点 • 中间代码和用于代码生成的数据结构 • 基本的代码生成技术 • 数据结构引用的代码生成 • 控制语句和逻辑表达式的代码生成 • 过程和函数调用的代码生成 • 商用编译器中的代码生成:两个案例研究 • TM:简单的目标机器 • TINY语言的代码生成器 • 代码优化技术考察 • TINY代码生成器的简单优化 在这一章中,我们着手编译器的最后工作——用来生成目标机器的可执行代码,这个可执 行代码是源代码语义的忠实体现。代码生成是编译器最复杂的阶段,因为它不仅依赖于源语言 的特征,而且还依赖于目标结构、运行时环境的结构和运行在目标机器的操作系统的细节信息。 通过收集源程序进一步的信息,并通过定制生成代码以便利用目标机器,如寄存器、寻址模式、 管道和高速缓存的特殊性质,代码生成通常也涉及到了一些优化或改善的尝试。 由于代码生成较复杂,所以编译器一般将这一阶段分成几个涉及不同中间数据结构的步骤, 其中包括了某种称做中间代码 (itermediate code)的抽象代码。编译器也可能没有生成真正的可 执行代码,而是生成了某种形式的汇编代码,这必须由汇编器、链接器和装入器进行进一步处 理。汇编器、链接器和装入器可由操作系统提供或由编译器自带。在这一章中,我们仅仅集中 关注于中间代码和汇编代码的生成,这两者之间有很多共同特性。我们不考虑汇编代码到可执 行代码的更进一步的处理,汇编语言或系统的编程文本可以更充分地处理它。 本章的第 1节考虑中间代码的两种普遍形式,三地址码和 P-代码,并且讨论它们的一些属 性。第2节描述生成中间代码或汇编代码的基本算法。接下来的章节讨论针对不同语言特性的 代码生成技术,这包括了表达式、赋值语句、控制语句 (如if语句, while语句)以及过程和函 数调用。 之后的一节将应用在前面章节中学到的技术开发 TINY语言的一个汇编代码生成器。由于 在这种细节水平上的代码生成需要实际的目标机器,因此首先讨论一个目标结构和机器模拟器 TM。附录C提供了源代码清单。然后,我们再描述完整的 TINY语言的代码生成器。最后给出 一个关于标准代码的改善、优化技术的简介,同时描述了怎样将一些简单的技术融入到 TINY 代码生成器之中。 8.1 中间代码和用于代码生成的数据结构 在翻译期间,中间表示(intermediate representation)或IR代表了源程序的数据结构。迄今为 止,本文使用了抽象语法树作为主要的 IR。除IR外,翻译期间的主要数据结构是符号表,这在 第6章中已学过了。 虽然抽象语法树是源代码完美充分的表述,即使对于代码生成也不过这样 (这一点我们将 在后面的章节中看到 ),但是它与目标代码极不相像,在控制流构造的描述上尤为如此。在控 制流构造上,目标代码 (如机器代码或汇编代码 )使用转移语句而不是 if和while语句。因此, 306 编译原理及实践 编译器编写者可能希望从语法树生成一个更接近目标代码的中间表示形式,或者用这样一个中 间表示代替语法树,然后再从这个新的中间表示生成目标代码。这种类似目标代码的中间表示 称为中间代码(intermediate code)。 中间代码能采用很多形式,几乎有多少种编译器就有多少种中间代码形式。然而所有中间 代码都代表了语法树的某种线性化 (linearization)形式,也就是说,语法树用顺序形式表示。中 间代码可以是高水平的,它几乎和语法树一样可以抽象地表示各种操作。它或者还可以非常接 近目标代码。它可以使用或不使用目标机器和运行时环境的细节信息,如数据类型的尺寸、变 量的地址和寄存器。它可以混合或不混合符号表中包括的信息,如作用域、嵌套层数和变量的 偏移量。假如它混合了符号表中包括的信息,目标代码的生成基于中间代码就足够了;否则, 编译器必须保留符号表。 当编译器的目标是产生非常高效的代码时,中间代码是极其有用的。如要产生高效的代码 就需要相当数量的目标代码属性分析,使用中间代码能使这变得容易。特别地,虽然从语法 树中直接得到混合细节分析信息的附加数据结构不是不可能的,但它能更容易地从中间代码 中得到。 中间代码在使编译器更容易重定向上也是有用的:假如中间代码与目标机器相对独立,那 么要为不同目标机器生成代码就仅需重写从中间代码到目标代码的翻译器。这比重写整个编译 器要容易。 在本节中我们将学习两个中间代码的普遍形式:三地址码 (three-address code)和P-代码(Pcode)。这两种中间代码以许多不同的形式出现,我们的研究将仅集中在普遍特性上,而不是 代表了某一个版本的细节化描述。这种描述能在本章最后“注意与参考”节所描述的文献中 找到。 8.1.1 三地址码 三地址码最基本的用法说明被设计成表示算术表达式的求值,形式如下: x = y op z 这个用法说明表示了对 y和z的值的应用操作符 op,并将值赋给x。这里的op可以是算术运算符, 如+或-,也可以是其他能操作于 y、z值的操作符。 三地址码这个名字来自于这个用法说明的形式,因为 x、y、z通常代表了内存中的 3个地 址。但是要注意, x的地址的使用不同于 y、z的地址的使用。 y、z(x不能)可以代表常量或没 有运行时地址的字面常量。 为了看清这种形式的三地址码如何能表示表达式的计算,考虑下边的算术表达式 2*a + ( b-3 ) 语法树如下: 相应的三地址码如下 t1 = 2 * a 307 第 8章 代 码 生 成 t2 = b - 3 t3 = t1 + t2 三地址码要求编译器产生临时变量名,在这个例子中的是 t1、t2和t3。这些临时变量对应于 语法树的内部节点而表示计算值,在这个例子中用临时变量 t3表示根节点值 。这里并没有说 明如何在内存中分配这些临时变量;它们通常将被分到寄存器中,但也有可能保存在活动记录 里面(参见第7章“临时栈”的讨论)。 三地址码仅代表了从左至右的语法树线性化,因为首先列出了相对于根的左子树的求值的 代码,编译器在某种情况下希望用另一种顺序也是有可能的。我们注意到对于三地址码来说, 是有可能使用另一顺序,即 (临时变量有不同的意思 ): t1 = b-3 t2 = 2*a t3 = t2+t1 很明显,上面所示的这种三地址码形式对于表示所有语言,即使是最小的程序语言的特性也是 不够的。例如一元操作符 (如负号)就需要一个三地址码的变种 (包含两个地址)如: t2 = -t1 为适应标准程序语言的使用结构,必须为每个结构改变三地址码的形式。如果语言中含有 不常见到的特性,那么就必须为表达的这种特性发明另一种三地址码形式。这就是三地址码之 所以没有标准形式的原因 (正如语法树没有标准形式一样 )。 在这一章余下的章节中我们将逐个处理一些程序语言共有的结构,并显示怎样将这些结构 翻译成三地址码。为了知道其结果,我们给出一个用 TINY语言编写的完整示例。 请考虑来自第 1章1.7节的TINY例子,该例计算了一个整数的阶乘,我们把它重新放在程序 清单8-1中。 程序清单 8-1 TINY程序示例 程序清单 8-2是这个例子的三地址码。这个代码包含了许多三地址码的不同形式。首先,内 置的输入和输出操作符 read和write已被直接翻译成一地址指令。其次,这里有一个条件 转移指令 if_false,它通常被用来翻译 if语句和循环语句,它包含两个地址:被检测的条 诸如t1、t2的名字仅仅意味着是这种代码通常类型的代表。实际上,如果像这里一样使用源代码名字的话, 三地址码中的临时变量名必须区别于在实际的源代码中所用的名字。 308 编译原理及实践 件值和转移的代码地址。一地址的 label指令指示了这个转移地址的位置。有了用以实现三 地址码的数据结构,这些 label指令可能并不是必需的。 halt指令(无地址 )用来标志代码的 结束。 程序清单 8-2 程序清单8-1中TINY程序的三地址码 最后,我们注意到原代码中的赋值语句导致了如下形式的 copy指令 x=y 例如,例程中语句 fact:=fact*x; 翻译成2个三地址码 t2=tact *x fact=t2 即使三地址码指令也是足够了,这种情况的技术原因将在 8.2节中讲述。 8.1.2 用于实现三地址码的数据结构 三地址码通常不被实现成我们所写的文本形式 (虽然这是可能的 ),相反是将其实现为包含 几个域的记录结构。并将整个三地址指令序列实现成链表或数组,它能被保存在内存中并在需 要时可以从临时文件中读写。 最通常的实现是将三地址码按其所显示的内容实现。这意味着有 4个域是必需的: 1个操作 符和3个地址。对于那些少于 3个地址的指令,将一个或更多的地域置成 null或“empty”,具体 选择哪个域取决于实现。必须有 4个域的三地址码表示叫做四元式 (quadruple)。程序清单 8-2的 三地址码的四元式在程序清单 8-3中给出。这里我们用数学中的元组概念书写四元式。 程序清单 8-3 程序清单 8-2中的三地址码的四元式实现 309 第 8章 代 码 生 成 程序清单8-4 程序清单 8-3中四元式的数据结构的 C定义 程序清单 8-4所示的是程序清单 8-3中的四元式的 C类型定义,在这些定义中,允许地址为 整数、常量或字符串 (代表临时变量或一般变量的名字 )。由于使用了名字,就必须将其加入到 符号表中,以供进一步处理时查询。另一种替代方法是在四元式中使用指向符号表入口的指针, 这将避免额外的查询。这对可嵌套的语言来说有特别的好处,因为这时候的名字查询还需要更 多的嵌套层信息,如果常量也输入符号表,那么将不再需要地址数据类型中的 union。 三地址码另一个不同的实现是用自己的指令来代表临时变量,这样地址域从 3个减少到了 两个。因此在三地址指令中包含 3个地址而目标地址总是一个临时变量 。如此的三地址码实现 称为三元式 (triple)。它要求:或是通过数组的索引号或是通过链表指针,每个三地址指令都是 可引用的,如程序清单 8-5是程序清单8-2的三地址码作为三元式实现的抽象表达。在那幅图中, 我们使用了一个数字系统,它对应于代表三元式的数组索引。在三元式内,把三元式引用用圆 括号括起,以同常量相区别。程序清单 8-5取消了label指令,代之以三元式引用。 程序清单8-5 程序清单 8-2中三地址码的三元式表示 这不是三地址码的固有属性,但能通过实现来保证。例如,程序清单8-2的代码就是这样的 (程序清单8-3也是)。 310 编译原理及实践 三元式是代表三地址码的有效方法,空间数量减少了且编译器不需要产生临时变量名;然 而,三元式也有一个不利因素:用数组索引代表三元式使得三元式位置的移动变得很困难,而 如用链表的话就不存在这个问题。三元式和对三元式的 C代码定义的问题仍处于实践价段。 8.1.3 P- 代码 在70年代和80年代早期,P-代码作为由许多 Pascal编译器产生的标准目标汇编代码被设计 成称作P-机器(P-machine)的假想栈机器的实际代码。 P-机器在不同的平台上由不同的解释器实 现。这个思想使得 pascal编译器变得容易移植,只需对新平台重写 P-机器解释器即可。 P-代码 已被证明是一个非常有用的中间代码,它的各种扩展和修改版在许多自然代码的编译器中得到 了使用,其中大多数都是针对类 Pascal语言的。 由于将P-代码设计成直接可执行的,所以它包含了对特殊环境的明确描述、数据尺寸,还 有P-机器大量的特有信息,如果要理解 P-代码程序,就必须提供上述信息。为避开细节而恰当 地说明问题,在这里只描述 P-代码的一个简化的抽象版本。各种不同版本的实际 P-代码的描述 能在本章最后列出的大量参考书中找到。 从我们的目的出发, P-机器包括一个代码存储器、一个未指定的存放命名变量的数据存储 器、一个存放临时数据的栈,还有一些保持栈和支持执行的寄存器。作为 P-代码的第1个例子, 考虑如下表达式,这个表达式在 8.1.1节中已用过,它的语法树在 8.1.1节: 2*a+(b-3) 这个表达式的P-代码版本如下: ldc 2 lod a mpi lod b ldc 3 sbi adi ; load constant 2 ; load value of variable a ; integer multiplication ; load value of variable b ; load constant 3 ; integer substraction ; integer addition 这些指令被看作代表如下的 P-机器操作:ldc 2 首先将值2压入临时栈,然后,lod a 将变量a 的值压入栈。指令 mpi将这两个值从栈中弹出,使之相乘 (按弹出的相反顺序 ),再将结果压入 栈。接下来两个指令(l o a b和l d c 3)将b的值和常量3压入栈(现在栈中有3个值),随后,sbi 指令弹出栈顶的两个值,用第 1个值去减第2个值,再把结果压入栈中,最后 adi指令弹出余下 的两个值并使之相加,再将结果压入栈。代码结束时,栈中只有一个值,它代表了这次运算的 结果。 作为第2个例子,考虑赋值语句: x := y + 1 对应于如下的P-代码指令: lda x lod y ; load address of x ; load value of y 311 第 8章 代 码 生 成 ldc 1 adi sto ; load constant 1 ; add ; store top to address ; below top & pop both 注意,这段代码首先计算 x的地址,然后将表达式的值赋给 x,最后执行sto命令,这个命令需 要临时栈顶上的两个值:一个是要被存储的值,在它下面的那个是值所要存入的地址。 sto指 令也弹出两个值 (在这例子中使栈变空 )。在x:=y+1中,左边的 x的用处和右边的 y的用处不一 样,相应的P-代码用装入地址(lda)和装入值(lod)作出区别。 作为本节中最后的一个 P-代码例子,我们对程序清单 8-1中的TINY程序给出P-代码的翻译, 如程序清单 8-6所示,其中每条操作都有注释。 程序清单8-6 程序清单 8-1中TINY程序的P-码指令 程序清单8-6中的P-代码包含了几个新的 P-代码指令。首先,无参的 rdi和wri指令实现了 TINY中的整型的 read和write语句。rdi P-代码指令要求要读的那个变量地址应位于栈顶, 这个地址作为指令的一部分被弹出。 wri指令要求要写的值地址位于栈顶,这个值作为指令的 一部分弹出。程序清单 8-6中出现的另一些新指令是 lab指令,它定义了标签名字的位置; fjp 指令(“false jump”)需要在栈顶有一个布尔值; sbi指令(整数减法)的操作类似于其他算术指 令;grt指令(大于)和etu(等于)指令需要两个整型值位于栈顶 (要被弹出)然后压入他们的布尔 312 编译原理及实践 型结果。 stp指令对应于前面三地址码的 halt指令。 1) P-代码和三地址码的比较 P-代码在许多方面比三地址码更接近于实际的机器码。 P-代 码指令也需要较少地址;我们已见过的都是一地址或零地址指令,另一方面, P-代码在指令数 量方面不如三地址码紧凑, P-代码不是自包含的,指令操作隐含地依赖于栈 (隐含的栈定位实 际上就是“缺省的”地址 ),栈的好处是在代码的每一处都包含了所需的所有临时值,编译器 不用如三地址码中那样为它们再分配名字。 2) P-代码的实现 历史上,P-代码已经大量地作为文本文件生成,但前面的三元地址码的 内部数据结构描述(三元式和四元式)也能作用于P-代码的修改版。 8.2 基本的代码生成技术 本节讨论代码生成的基本方法,在下一节,我们将针对单个的语言结构进行代码生成。 8.2.1 作为合成属性的中间代码或目标代码 中间代码生成 (或没有中间代码的直接目标代码生成 )能被看作是一个属性计算,这类似于 第6章中研究的许多属性问题,实际上假如生成代码被看作一个字符串属性 (每条指令用换行符 分隔 ) ,这个代码就成了一个合成属性并能用属性文法定义,并且能在分析期间直接生成或者 通过语法树的后序遍历生成。 为了看清楚三地址码或 P-代码怎样被作为合成属性定义,考虑下边的文法,它代表了 C表 达式的一个子集。 exp→id = exp | aexp aexp→aexp + factor | factor factor→( exp ) | num | id 这个文法仅包含了两个操作,赋值 (=)和加法(+) 。记号id代表简单标识符,记号 num代表了 表示整数的简单数字序列。这两个记号被假设成有一个预先计算过的 strval属性,它可以是字 符串或词 (例如“42”是num,“xtemp”是id )。 1) P-代码 我们首先考虑P-代码的情况,由于不需要产生临时变量名,属性文法会简单些, 然而,嵌套赋值的存在是一个复杂因素。在这种情况下,我们希望保留被存的值作为赋值表达 式的结果值,然而标准的 P-代码指令sto是有害的,因为所赋的值会丢掉 (P-代码在这里显示出 了它 pascal源,在 pascal源代码中不存在嵌套的赋值语句 )。我们通过引入一个无害的存储 (nondestructive store)指令stn来解决这个问题, stn和sto一样,都假设栈顶有一个值且下面 有一地址。 stn将值存入那个地址,但在丢弃那个地址时栈顶上仍保留了那个值。表 8-1是使 用这个新的指令后 P-代码属性的属性文法。在那幅图中,已经用属性名 pcode表示P-代码串, 并已把两个不同的符号用于串的连接: ++表示所连的串之间不能在同一行, ||表示连接一个串 用空格相隔。 我们将跟踪某个例子的 pcode属性计算留给读者并写出来,例如:表达式 (x=x+3)+4有如 下的pcode属性: lda x lod x 这个例子中的赋值有如下的语义: x=e将e的值存入x,该赋值的结果值是 e。 313 第 8章 代 码 生 成 ldc 3 adi stn ldc 4 adi 表8-1 P-代码合成字符串属性的属性文法 文法规则 exp → id = exp 1 2 exp → aexp aexp → aexp + factor 1 2 aexp → factor factor → ( exp ) factor → num factor → id 语义规则 exp .pcode = " l d a " || id.strval ++ exp .pcode ++ "stn" 1 2 exp.pcode = aexp.pcode aexp .pcode = aexp .pcode ++ factor.pcode ++ "adi" 1 2 aexp.pcode = factor.pcode factor.pcode = exp.pcode factor.pcode = "ldc"||num.strval factor.pcode = "lod"||id.strval 2) 三地址码 前面那个简单表达式的三地址码属性文法在表 8-2中给出。在那张表中,我 们称代码属性为 tacode。同表8-1,也用++表示其间插有换行符的串连接, ||表示其间有空格的 串连接。与P-代码不同,三地码要求为表达式的中间结果生成临时变量名,这就要求属性文法 在每个节点中都包括一个新名字属性。这个属性也是合成的,如果没有为一个内部节点分配一 个新产生的临时名,就用 newtemp( ) 产生一个临时名字系列 t1、t2、t3, . . . ( 每次调用 newtemp ( )就返回一个新的 )。在这个简单例子中,仅对应于 +的节点需临时名,赋值操作使用 右边的表达式的名字。 表8-2 三地址码作为合成串属性的属性文法 文法规则 exp → id = exp 1 2 exp → aexp aexp → aexp + factor 1 2 aexp → factor factor → ( exp ) factor → num factor → id 语义规则 exp .name = exp .name 1 2 exp .tacode = exp .tacode ++ id.strval || "="||exp .name 1 2 2 exp.name = aexp.name exp.tacode = aexp.tacode aexp .name = newtemp() 1 aexp .tacode = aexp .tacode ++ factor.tacode 1 2 ++ aexp .name|| "="|| aexp .name 1 2 || "+"||factor.name aexp.name = factor.name aexp.tacode = factor.tacode factor.name = exp.name factor.tacode = exp.tacode factor.name = num.strval factor.tacode = "" factor.name = id.strval factor.tacode = "" 表8-2的产生式 exp→aexp和aexp→factor中,将名字属性即 tacode属性从子节点提到父节点, 在操作符内部节点中,新名字属性应在联合的 tacode代码之前生成。在叶产生式 factor→num和 314 编译原理及实践 factor→id 中记号的串值记作 factor.name。与P-代码不同,在叶产生式的节点上没有生成三地 址码(用""表示空串)。 再次,我们让读者按表 8-2所给出的等式写出表达式 (x=x+3)+4的每一步的tacode,这个 表达式的 tacode属性如下: t1 = x+3 x = t1 t2 = t1+4 (这里假设newtemp( ) 用后序调用并且产生从 t1开始的临时名 )。注意x=x+3是怎样用临时名产 生两个三地址指令的。这是属性值总是为每一个子表达式产生一个临时名的结果,它包括了赋 值号的右边部分。 将代码生成看成一个合成字符串属性计算,对于清楚地显示语法树各部分代码系列的关系 以及比较不同的代码生成方法是很有用的,但它作为真实的代码生成技术是不实际的,这有 几个原因:首先,串连接的使用造成了过度的串拷贝因而浪费了内存 (除非连接符做得非常复 杂),其次,通常希望产生几片代码作为代码产生的收益并且将这几片代码写入一个文件或将 它们插入一个数据结构 (如四元式数组),这就需要语义动作,而语义动作又不与属性的标准后 序合成有牵连。最后,即使将代码看作是纯合成是有用的,但通常的代码生成很大程度上依 赖于继承属性,这将使得属性文法大大复杂化。由于这个原因,我们就不必麻烦再去写出实 现前面例子中的属性文法的代码了 (即使是伪码 )。相反地,在下一节中,我们将转向更直接的 代码生成技术。 8.2.2 实际的代码生成 标准的代码生成或者涉及语法树后序遍历的修改,这棵语法树是由前面例子的属性文法所 包含的,或者如没有显式生成的语法树,则在分析中涉及了相等效的动作。基本算法由下面的 递归过程描述(用于二叉树,但容易将其推广到节点子树多于 2的情况): procedure genCode ( T:treenode ); begin if T is not nil then generate code to prepare for code of left child of T; genCode (left child of T ); generate code to prepare for code of right child of T; genCode (right child of T ); generate code to implement the action of T; end; 注意,这个递归遍历过程不仅有一个后序部分 (产生实现 T 动作的代码),而且还有一个前序和 一个中序部分 (为T 的左右子树产生准备代码 )。通常, T 表示的每一个动作需要前序和中序准 备代码的稍微不同的一个版本。 为了详细地看清在一个特殊的例子中怎样构造产生代码的过程,考虑我们在这一节已用过 的简单算术表达式的语法,这个语法的抽象语法树的 C语言定义如下所示: typedef enum { Plus, Assign } Optype; typedef enum { OpKind, ConstKind, IdKind } NodeKind; 315 第 8章 代 码 生 成 typedef struct streenode { NodeKind kind; Optype op; /* used with OpKind */ struct streenode *lchild, *rchild; int val; /* used with ConstKind */ char * strval; /* used for identifiers and numbers */ } STreeNode; typedef STreeNode *SyntaxTree; 用这些定义,表达式 (x=x+3)+4的语法树如下所示: 注意,赋值节点包含了要被赋于的表达式 (在strval域中),以致一个赋值节点只有一个 子树(要被赋于的表达式) 。 基于这棵语法树的结构,我们能写出一个 genCode过程来产生程序清单 8-7的P-代码。对 图中的代码作如下注释:首先,代码用标准 C函数sprintf把字符串连接到本地的临时变量 codestr。其次,调用过程emitCode用以生成一个P-代码行,在数据结构或输出文件中不显 示它的细节。最后,两个操作符 (加号和赋值号 )需要两个不同的遍历顺序,加号仅需要一些后 序处理,而赋值需要一些前序处理和一些后序处理。因此不可能在所有情况下,能将递归调用 都写成一个样子。 程序清单 8-7 表8-1属性文法定义的 P-码的代码生成过程的实现 在这个例子中,我们将数字也当作字符串保存在 strval域中。 316 编译原理及实践 即使用到了必需的不同顺序的遍历,显示代码生成仍然可以在分析时进行 (没有语法树的 生成),我们在程序清单 8-8中出示了一个 Yacc说明文件,它直接对应于程序清单 8-7的代码(注 意赋值的前序和后序的组合处理是怎样翻译成独立的部分 )。 程序清单 8-8 根据表8-1的属性文法生成 P-代码的Yacc说明 317 第 8章 代 码 生 成 请读者按表 8-2的属性文法写出一个 genCode 过程和生成三地址码的 Yacc说明。 8.2.3 从中间代码生成目标代码 如果编译器或者直接从分析中或者从一棵语法树中产生了中间代码,那么下一步就是产生 最后的目标代码(通常在对中间代码的进一步处理之后),这一步本来就相当复杂,特别是当中间 代码为高度象征性的,且只包含了很少或根本没包含目标机器和运行时环境的信息。在这种情 况下,最后的代码生成必须支持变量和临时变量的实际定位,并增加支持运行时环境所必需的 代码。寄存器的合适定位和寄存器使用信息的维护(如哪个寄存器可用和哪个包含了已知值)是一 个特别重要的问题,我们将在本章最后再讨论分配问题细节。现在仅讨论这个处理的通用技术。 通常,来自中间代码的代码生成涉及了两个标准技术:宏扩展(macro expansion)和静态模拟 (static simulation)。宏扩展涉及到用一系列等效的目标代码指令代替每一种中间代码指令。这要 求编译器保留关于定位和独立的数据结构的代码惯用语的决定,它要求宏过程按照中间代码中 涉及的特殊数据的需要改变代码序列。因此,这一步要比在C预处理器或宏汇编器中可利用的宏 扩展的简单形式复杂得多。静态模拟包括中间代码效果的直线模拟和生成匹配这些效果的目标 代码。这也需要额外的数据结构,它可以是非常简单的跟踪形式,并在与宏扩展的连接中使用, 也可以是非常复杂的抽象解释(abstract interpretation) (当计算值时,对它们保持代数学的追踪)。 在考虑从P-代码翻译成三元地址码 (反之亦然 )时,我们能了解这些技术的细节。想象一个 在这节中已作为运行例子用过的小表达式语法,并考虑表达式 (x=x+3)+4,它的P-代码和三 地址码的翻译在前面已经给出。我们首先考虑将这个表达式的 P-代码: lda x lod x ldc 3 adi stn ldc 4 adi 翻译成相应的三地址码: t1 = x + 3 x = t1 t2 = t1 + 4 这需要执行一个 P-机器栈的静态模拟用以发现代码的三地址码等效式。在翻译期间,用实际的 栈数据结构实现它们。在前三条 P-代码指令后仍无三地址码指令产生,但是已经将 P-机器栈修 改以反映这些装入。栈内容如下所示: 栈顶 x 的地址 318 编译原理及实践 现在当处理 adi 操作时,产生三地址指令 t1=x+3 栈改成: stn指令造成三地址指令 x=t1 生成,栈变成为: x 的地址 下一个指令压常量 4入栈: 栈顶 栈顶 栈顶 最后adi指令生成三地址指令 t2 = t1 + 4 栈变成为 栈顶 这就完成了静态模拟和翻译。 我们现在考虑将三地址码翻译成 P-代码的情况。假如忽略临时变量名增加的复杂性,就能 用简单的宏扩展来完成。因此,一个三地址指令 a=b+c 总能被翻译成如下的 P-代码指令序列 lda a lod b ; or ldc b if b is a const lod c ; or ldc c if c is a const adi sto 这导致了如下的三地址码到 P-代码的翻译(有点不令人满意) lda t1 lod x ldc 3 adi sto lda x lod t1 sto lda t2 319 第 8章 代 码 生 成 lod t1 ldc 4 adi sto 假如要消除额外的临时变量,就需要用一个比宏扩展复杂得多的方案。一种可能是从三地 址码生成一棵新树,这棵树用每个指令的操作符和所分配的名字来标记树节点,从而显示代码 的效果。它可看作是静态模似的一种形式。前面的三地址码的结果树如下: 注意三地址指令 x = t1 在树中不生成节点,而是造成 t1节点获得另一个名字 x。这棵树同源代码的语法树类似, 但又有不同 。P-代码能从这个树产生,这非常类似于从语法树中产生 P-代码。通过对内部 节点只分配永久名,而消除了临时变量。因此,在这棵样例树中,只分配了 x,在 P-代码中 根本没有使用 t1、t2。对应于根节点 (t2)的值被放在 P机器栈中。这导致了同前面完全一 样的 P-代码生成,只要在存储时用 stn代替 sto即可。我们鼓励读者编写伪码或 C代码执行 这个处理。 8.3 数据结构引用的代码生成 8.3.1 地址计算 在前一节中,我们已经看到如何生成一个简单的算术表达式和赋值语句的中间代码。这些 例子中,所有的基本值或者是常量或者是简单变量 (程序变量如 x,临时变量如 t1)。简单变量 仅通过名字识别一一到目标代码的翻译需要这些名字由实际地址代替,这些地址可以是寄存器、 绝对地址 (针对全局变量 ),或是活动记录的偏移量 (针对局部变量,可能包括嵌套层 )。这些地 址可以在中间代码生成时插入,也可以推迟到实际代码生成时 (用符号保持地址 )。 为了定位实际地址,有许多情况需要执行地址计算,即使是在中间代码中,这些计算也必 须被直接表达出来。这样的计算发生在数组下标记录域和指针引用中。我们将依次讨论这些情 况。但首先将描述一下可以表达这样的地址计算的三地址码和 P-代码扩展。 1) 用于地址计算的三地址码 在三地址码中,对新操作符的需要不是很多。通常的算术操 作符能被用来计算地址,但对于显示地址模式的方法需要几个新符号。在我们的三地址码版本 中,使用了与 C语言中意义一样的“ &”和“*”来指示地址模式。例如,假设想把常量 2存放 在变量x加上10个字节的地址处,用三地址码表示如下: t1 = &x + 10 *t1 = 2 这棵树是称为基本块的 DAG(DAG of a basic block)的一般结构的特例,这在 8.9.3节中描述。 320 编译原理及实践 这些新地址模式的实现要求三地址码的数据结构包含一个或多个新域。例如,图 8-4的四 元式数据结构增加了一个枚举型的 AddrMode域,枚举值为None、Address和Indiredct。 2) 用于地址计算的P-代码 在P-代码中,通常引入新的指令表示新的地址模式 (由于很少有 外在的地址需要指定地址模式 )。为了这个目的将引入如下两条指令: • ind (间接装入) 用一个整型偏移量作为参数,假设栈顶上有一个地址,就将这个地址 与偏移量相加得到新地址,再将新地址中的值压入栈以代替原来栈顶的地址。 执行前的栈 执行后的栈 • ixa (索引地址) 用整型比例因子作为参数,假设一个偏移量已在栈顶并且在其下边有 一个基地址,则用比例因子与偏移量相乘,再加上基地址以得到新地址,再将偏移量和基地址 从栈中弹出,压入新地址。 执行前的栈 执行后的栈 这两个P-代码指令,和以前介绍过的 lda (装入地址)指令,将允许我们执行和三地址码中地址模 式一样的地址计算和引用 。例如,前面的例子 (把常量2存入变量x加10字节处的地址 )现在用 P-代码实现如下: lda x ldc 10 ixa 1 ldc 2 sto 我们现在开始讨论数组,记录和指针,随后是目标代码生成和一个扩展的例子。 8.3.2 数组引用 数组引用通过表达式涉及了数组变量下标,得到数组元素的引用或值。正如下面的 C代码 所示: int a[SIZE]; int i,j; ... a[i+1] = a[j*2] + 3; 在这个赋值语句中,a的下标表达式i+1产生了一个地址(赋值的目标地址 ),而通过下标表达式 j*2在已计算的地址中得到 a元素类型的一个值。由于数值按顺序存放于存储器中,每个地址 必须根据a的基地址(base address) (即数组a在存储器中的起始地址)和线性地依赖于下标的偏移 量计算。当需要的是一个值而不是地址时,就必须生成一个额外的间接步骤从已计算的地址中 取出值。 实际上, ixa指令能用算术运算符来模拟,除了在 P-代码中,这些操作被类型化 (adi = 整数相乘 ),因此不 能应用于地址。我们不强调 P-代码的类型限制,因为这涉及额外的参数,为了简化,没有使用。 321 第 8章 代 码 生 成 从下标值中计算偏移量如下:首先,假如下标范围不从 0开始(这在Pascal和Ada中是可能 的),就必须对下标值作一调整。其次,调整后的下标值必须与比例因子 (scale factor)相乘,比 例因子等于存储器中数组元素类型的尺寸。最后比例作用过的下标值加上基地址,从而得到数 组元素的最终地址。 例如,C数组引用a[i+1]的地址是: a + (i + 1) * sizeof (int) 更一般地,任何语言中数组元素 a[t]的地址是: base_address (a) + (t - lower_bound (a)) * element_size (a) 我们现在转向用三地址码和 P-代码表示这个地址计算的方法。为了不依赖目标机器,假设 数组变量的地址就是它的基地址。因此,如果 a是数组变量,在三地址码和 P-代码中, &a就是 数组a的基地址。 P-代码 lda a 将a的基地址压入 P-机器栈。由于数组引用计算依赖于目标机器元素类型的尺寸,所以用 elem-size(a)代表目标机器上数组 a的元素尺寸 。由于这是一个静态量 (假设是静态类型), 这个表达式在编译时将用常量代替。 1) 数组引用的三地址码 在三地址码中表示数组引用的一个可能的方法是引入两个新的操 作。一个是获取数组元素的值 t2 = a[t1] 还有一个是给数组元素地址中赋值。 a[t2] = t1 (这些就用符号=[]和[]=表示),用这个术语表示实际地址的计算不是必要的 (机器依赖性如元 素尺寸将从这个概念中消失 )。例如,源代码语句 a[i+1] = a [j*2] + 3 将翻译成下面的三地址指令 : t1 = j * 2 t2 = a [t1] t3 = t2 + 3 t4 = i + 1 a [t5] = t3 然而,引入前面所述的地址模式仍是必需的,在处理记录域和指针引用时,用统一方式处理 所有地址运算是有意义的,因此在三地址码中也直接写出数组元素地址的计算。例如,赋值 语句: t2 = a [t1] 能写成:(用更多的临时变量: t3和t4) t3 = t1 * elem_size (a) t4 = &a + t3 t2 = *t4 在C中,一个数组 (如此例中的 a)的名字代表了它的基地址。 这实际上是一个由符号表提供的函数。 322 编译原理及实践 赋值语句 a[t2] = t1 写成 t3 = t2 * elem_size(a) t4 = &a + t3 *t4 = t1 最后,一个更复杂的例子,源代码语句 a[i+1] = a [j*2] + 3; 翻译成三地址指令如下: t1 = j * 2 t2 = t1 * elem_size(a) t3 = &a + t2 t4 = *t3 t5 = t4 + 3 t6 = i + 1 t7 = t6 * elem_size (a) t8 = &a + t7 *t8 = t5 2) 数组引用的P-代码 正如前面所描述的,我们用新的地址指令 ind和ixa。指令ixa实 际上由数组地址计算精确地构成。 ind指令用来装入前面已计算地址的值 (例如实现一个间接 装入),数组引用 t2 = a [t1] 的P-代码表示如下: lda t2 lda a lod t1 ixa elem_size(a) ind 0 sto 数组赋值 a[t2] = t1 的P-代码如下: lda a lod t2 ixa elem_size(a) lod t1 sto 最后,前面那个较复杂的例子 a [i+1] = a [j*2] + 3 ; 翻译成P-代码如下: lda a lod i 323 第 8章 代 码 生 成 ldc 1 adi ixa elem_size(a) lda a lod j ldc 2 mpi ixa elem_size(a) ind 0 ldc 3 adi sto 3) 数组引用的代码生成过程 这里将显示数组引用是怎样由一个代码生成过程产生的,我 们用前一节中的C表达式子集的例子,但扩充了下标操作。要用的新文法如下: exp → subs = exp | aexp aexp → aexp + factor | factor factor → ( exp ) | num | subs subs → id | id [ exp ] 注意,赋值的目标可能是一个简单变量,也可能是一个有下标的变量 (都包括在非终结符 subs 中)。我们使用和前面的语法树一样的数据结构,另外为下标增加 Subs操作 typedef enum { Plus, Assign, Subs } Optype; /* 像前面一样的其他说明 */ 由于下标表达式可以在一个赋值号的左边,那么是不可能将目标变量名存入赋值节点中 (因为 可能没有这样的名字 )。赋值节点现在有 2个子树,这同加法节点一样。左子树必须是标识符 或下标表达式。下标本身只能被用作标识符,因此,存储数组变量名于下标节点。所以,表 达式: (a[i+1]=2)+a[j] 的语法树如下: 程序清单 8-9是一个为这样的语法树产生 P-代码的代码生成过程 (同程序清单8-7比照)。这个代 码同程序清单 8-7代码的主要不同在于它需要继承属性 isAddr,用于识别下标表达式标识符在 赋值号的左边还是在右边。如果 isAddr被设置成TRUE,那么就必须返回表达式的地址;否则 返回值。请读者检验下面的表达式 (a[i+1]=2)+a[j]的P-代码生成过程: 324 编译原理及实践 lda a lod i ldc 1 adi ixa elem_size(a) ldc 2 stn lda a lod j ixa elem_size(a) ind 0 adi 在练习中也请读者检验这个文法的三地址码的代码生成器的构造。 程序清单 8-9 上面的表达式文法对应的 P-码的生成过程的实现 325 第 8章 代 码 生 成 4) 多维数组 在数组地址计算中,在大多数语言中多维数组的存在是其复杂的一个因素。 例如,在C语言中,二维数组 (具有不同的索引大小 )可以声明为: int a[15][10] ; 这样的数组可以用部分下标以生成维数更少的数组,或者完全使用下标以产生该数组元素类型 的一个值。例如,在 C语言中给定上面 a的声明,表达式 a[i]使用a的部分下标以产生一个一 维整型数组,而表达式 a[i][j]使用a的全部下标产生一个整型值。数组变量的部分或全部用 下标表示后的地址可通过递归使用一维数组中描述的技术来计算。 8.3.3 栈记录结构和指针引用 计算记录结构的域地址提出了一个同计算下标数组地址相同的问题。首先,计算结构变量的 基地址,然后,找到域偏移量(通常是固定的),两者相加得到结果地址。例如,考虑C语言声明: typedef struct rec { int i ; char c; int j ; } Rei; ... Rec x; 一般地,变量x如下图所示那样在存储器中分配,每一个域 (i、c和j)有一个从x基地址(可 以是它自己在活动记录里的偏移量 )开始的偏移量。 (其他存储器) 分配给x的 存储器 (其他存储器) x.j 的偏移量 x.c 的偏移量 x 的基地址 326 编译原理及实践 注意,域被线性分配 (通常从低地址到高地址 ),每个域的偏移量是常量,第 1个(x.i)偏移量为 0,注意:域偏移量依赖于目标机器上不同数据类型的大小,这里没有数组中的比例因子。 为了对记录结构域地址计算编写独立与于目标机器的中间代码,必须引入一个新函数返回 域的偏移量,并给定结构变量和域名,这个函数称为 field_offset,并为它写出两个参数。 第1个是变量名,第 2个是域名,因此 field_offset(x,j) 返回x.j的偏移量。和其他相似 的函数一样,这个函数能由符号表提供。在任何情况下,这只是编译时的量,因此实际产生的 中间代码将用常量代替对 field_offset的调用。 记录结构一般使用指针和动态内存分配来实现动态数据结构 (如链表和树 ),因此将描述指 针和域地址计算是如何交互作用的。出于这里讨论的目的,一个指针只是建立了一个间接层次, 而忽略了指针值产生的分配问题 (这些在第7章讨论过)。 1) 结构和指针引用的三地址码 首先考虑用于域地址计算的三地址码:为了计算 x.j的地 址并存入临时变量t1,使用如下的三地址指令: t1 = &x + field_offset (x,j) 如C语句的一个域赋值表达式: x.j = x.i ; 能被翻译成如下的三地址码 : t1 = &x + field_offset (x,j) t2 = &x + field_offset (x,i) *t1 = *t2 现在考虑指针。例如,假设 x被定义成整型指针,例如 C声明: int * x; 再进一步假设 i是一个普通整型变量, C赋值语句: *x = i; 能被翻译成三地址指令 : *x = i 赋值语句 i = *x; 翻译成三地址指令 i=*x 为了看清楚指针的间接手段是怎样同域地址计算相互作用的,可考虑下面的树结构例子, C变量声明如下: typedef struct treeNode { int val; struct treeNode * lchild, * rchild; } TreeNode; ... TreeNode *p; 现在考虑两个典型的赋值 p -> lchild = p; p = p -> rchild; 327 第 8章 代 码 生 成 这些语句翻译成三地址码如下: t1 = p + field_offset ( *p, lchild ) *t1 = p t2 = p + field_offset ( *p, rchild ) p = *t2 2) 结构和指针引用的 P-代码 给定本次讨论开始时的 x定义,x.j的直接地址计算被翻译成 下面P-代码 lda x lod field_offset (x,j) ixa l 赋值语句 x.j=x.i; 能被翻译成下面的 P-代码 lda x lod field_offset (x,j) ixa 1 lda x ind field_offset (x,i) sto 注意ind 指令在没有计算出 x.i的完全地址时是怎样被用来获取它的值的。 在指针情形中(x被定义为int *)。赋值 *x = i; 翻译成P-代码如下 lod x lod i sto 赋值 i = *x; 翻译成P-代码如下 lda i lod x ind 0 sto 我们用P-代码可得出赋值 p -> lchild = p; p = p -> rchild; 的推断(参见前面p的声明)。这些可翻译成如下P-代码: lod p lod field_offset ( *p, lchild ) ixa 1 lod p sto 328 编译原理及实践 lda p lod p ind field_offset ( *p, rchild ) sto 我们将生成这些三地址码或 P-代码的代码生成过程细节留作练习。 8.4 控制语句和逻辑表达式的代码生成 在这一节中,我们描述控制语句不同形式的代码生成。这里主要的部分是结构化的 if语句 和while语句,这一节的开头将说明它。这个描述也包括了对 break语句的说明,但是因为这种 语句能简单地用中间代码或目标代码直接实现,所以不讨论低级控制 (如goto语句)。结构化控 制的其他形式,如 repeat语句(或do-while语句)、for语句和 case语句(或switch语句)的描述将留作 练习。在switch语句中用到的额外的实现技术称作转移表 (jump table),也在练习中描述。 控制语句的中间代码生成——无论是在三地址码还是 P-代码中——都涉及到了标号的产 生,这种方式与三地址码中临时变量名的生成相类似,但标号在目标代码中代表要转移的地址。 假如在目标代码生成中消除了标号,则在转移中将引发一个代码定位问题,这将在本节的第 2 部分讨论。逻辑表达式或布尔表达式被用作控制测试,但也可以独立地用作数据,这将在接下 来的一部分中讨论。尤其是在短循环求值中,它们不同于算术表达式。 最后,在这节中,我们对if和while语句给出了一个 P-代码生成过程的例子。 8.4.1 if和while语句的代码生成 考虑下面两种 if和while语句形式,这在很多不同的语言中都是相似的 (现在给出的是类 C 语法): if-stmt → if ( exp ) stmt | if ( exp ) stmt else stmt while-stmt → while ( exp ) stmt 对这样语句的代码生成的主要问题是:将结构化的控制特性翻译成涉及转移的非结构化等价物, 它能被直接实现。编译器以一种标准次序来安排这种语句的代码生成,这种次序有可能高效地 使用转移子集,这种转移子集是目标系统所允许的。图 8-1和图8-2是对每一个这种语句的典型 代码排列 (图8-1显示一个else部分(虚假的情况),但根据刚刚给定的文法规则这是一个可选项, 这个排列对于缺少的 else部分的修改是很容易的 ),在这些排列中,仅有两种转移——无条件转 移和条件为虚假的转移。条件为真时是不需要转移的“失败”情况。这减少了编译器要产生的 转移的数量,这也意味着在中间代码中仅需要两个转移指令。虚假转移已经在程序清单 8-2中 的三地址码中出现过了 (如if-false..goto语句),此外也在程序清单 8-6的P-代码中的三地 址码中出现过了 (如fjp指令)。剩下要介绍的无条件转移,它将在三地址码中用goto指令实现, 在P-代码中用ujp (无条件转移)实现。 1) 控制语句的三地址码 我们假设代码生成器产生了一系列标号 L1、L2…,对于语句 if ( E ) S1 else S2 生成下面的代码模式: < c o d e t o e v a l u a tEe t o t 1 > if_false t1 goto L1 if 语句前的代码 if 测试的代码 条件 对 错 TRUE情况下的代码 无条件转移 FALSE情况下的代码 if 语句后的代码 图8-1 if 语句的典型代码排列 goto L2 label L1 label L2 类似地, while语句 while ( E ) S 将导致生成下面的三元地址码样式 label L1 < c o d e t o e v a l u a t eE t o t 1 > if_false t1 goto L2 goto L1 label L2 2) 控制语句的P-代码 对于语句 if ( E ) S1 e l s e S2 生成下面的 P-代码样式 < c o d e t o e v a l u a tEe> fjp L1 ujp L2 329 第 8章 代 码 生 成 while-语句前的代码 while 测试的代码 条件转移 对 错 while 体的代码 无条件转移 while-语句后的代码 图8-2 while语句的典型代码排列 330 编译原理及实践 lab L1 lab L2 对于语句 while (E ) S 生成下面的 P-代码样式 lab L1 < c o d e t o e v a l u a tEe> fjp L2 ujp L1 lab L2 注意,所有这些代码序列 (三地址码和 P-代码)都以标号定义结束。我们称这个标号为控制 语句的出口标号 (exit label)。许多语言提供一个语言结构允许循环在循环体的任意位置退出。 例如,C语言提供了 break语句(这也能被用在 switch语句中)。在这些语言中,出口标号对于所 有代码生成例程都是可用的,这些代码生成例程在循环体内可以使用。因此如果遇上一个退出 语句如 break,就产生一个到出口标号的转移。在代码生成期间,将出口标号放入继承属性, 这必须被存入栈或作为一个合适的代码生成例程的参教,关于这方面的更多细节,将在这一节 的后面给出。 8.4.2 标号的生成和回填 在目标代码生成期间,引起问题的控制语句代码生成的一个特性实际上是对标号的转移必 须在标号本身定义之前生成。在中间代码的生成期间这不会有什么问题。由于因向前转移而需 要一个标号时,代码生成例程能只是调用标号生成过程,并且一直保存这个标号名字 (局部的 或在栈中 )到知道这个标号的位置为止。假如在目标代码生成期间要产生汇编代码,标号能只 是传给汇编器。但如果要生成实际的可执行代码,这些标号就必须被解析成绝对的或相对的代 码位置。 产生这样一个向前转移的一个标准方法是:在转移发生的代码处留下空位,或者产生一个 虚假的转移(针对虚假地址)。然后当知道实际的转移位置时,这个位置用来回填 (backpatch)缺 少的代码。这也要求产生代码并保存在内存缓冲区内以易于能频繁地进行回填,或者将代码写 入一个临时文件,在需要的时候再重新输入或回填。另一种情况是:回填可能需要在一个栈中 缓冲或者在递归过程中局部地保持。 在回填处理中,更深入的问题出现了,这是由于许多结构中有两种转移,一个短转移 (含 有128字节),一个需要更多的代码空间的长转移。在这种情况下,代码生成器可能在短转移时 需要插入 nop指令或者采取办法缩短代码。 8.4.3 逻辑表达式的代码生成 到目前为止,我们还未提到过逻辑或布尔表达式的代码生成,它们经常作为控制语句的测 试而使用。假如中间代码有一个布尔数据类型和逻辑操作符,如 and和or,那么就能在中间代 码中计算布尔表达式的值,这与算术表达式一样。它是一个 P-代码的例子,能类似地设计出中 间代码。然而即使是这个例子,因为大多数系统没有内置的布尔值,所以到目标代码翻译仍需 331 第 8章 代 码 生 成 要将布尔值算术地表示出来。做这个的标准方法是将 true作为1,fasle作为0,然后标准的位运 算符and和or能在大多数系统上用来计算布尔表达式的值。这需要将比较操作符如 <的结果规 格化为0或1。在一些系统中,因为比较操作符本身仅仅设置条件码,所以需要显式地装入 0或1。 在那个例子中,条件转移需要装入合适的值。 如果逻辑操作符是短路(short circuit)的,更深入地使用转移是必须的。如果不再求它的第 2 个参数,那么这个逻辑操作符就被短路了。例如,假如 a是一个布尔表达式,值是 false,那么 就能立即断定布尔表达式 a and b为假,也就不用去求 b了。类似地,如 a为真,那么立刻就能 判断出a or b为真,也不用求 b了。短路的操作符对代码来说是非常有用的。因为如果操作符没 被短路的话,对第 2个表达式的求值可能会引起错误,例如,在 C中这是很平常的: if ((p!=NULL) && ( p->val==0) ) ... 这里当p为可空时p->val 的求值将引起内存错误。 除了它们还返回值以外,短路布尔操作符类似于 if语句,它们经常用 if表达式定义,如 a and b ≡ if a then b else false 和 a or b ≡ if a then true else b 为产生确保第2个子表达式仅在必要的时候进行求值的代码,我们必须如同在 if语句中一样,使 用转移。例如,对于 (表达式(x!=0)&&(y==x)的短路 P-代码如下: lod x ldc 0 neq fjp L1 lod y lod x equ ujp L2 lab L1 lod FALSE lab L2 8.4.4 if和while语句的代码生成过程样例 这一节用如下简化的文法展示一个控制语句的代码生成过程。 stmt → if-stmt | while-stmt | break | other if-stmt → if( exp ) stmt | if( exp ) stmt else stmt while-stmt → while( exp ) stmt exp → true | false 出于简化目的,这个语法用 other代表没有包括在文法中的语句 (如赋值语句 ),它也仅包 括常量布尔表达式 true和false。为了显示怎样将 break语句作为参数传递的继承的标号来 实现,它包括了一个 break语句。 下面的C声明,能被用来为这个文法实现一棵抽象语法树 typedef enum { ExpKind,IfKind, WhileKind, BreakKind, OtherKind } NodeKind; 332 编译原理及实践 typedef struct streenode { NodeKind kind; struct streenode * child[3] ; int val; /* used with ExpKind */ } STreeNode; typedef STreeNode * SyntaxTree; 在这棵语法树结构中, 1个节点可以有 3个孩子(1个有else部分的if节点)。表达式节点包含了真 假值 (在val域中作为 1或0存储 ),例如语句 if (true) while (true) if (false) break else other 有如下的语法树: 这里我们仅显示了每个节点的非空子树 。 用被给定的typedef和相应的语法树结构,一个产生 P-代码的代码生成过程在程序清单 8- 10中给出,我们对这段代码作了如下注释: 首先,这段代码假设已存在一个 emitCode过程(这个过程只是将传给它的字符串打印出 来)这段代码也假设返回顺序标号名 (如L1、L2、L3…) 的无参过程genLabel已存在。 genCode过程有一个额外的标号参数,在为 break语句而生成的转移语句中会用到它。仅 在处理while语句循环体的递归调用中会改变这个参数。因此, break 语句将总是跳出最近的那 个嵌套 while语句(对genCode的初始调用能用一个空串作为标号参数,任何在 while语句外的 break语句将产生一个到空标号的转移,这将引发错误 )。 请注意标号变量 Lab1和Lab2怎样用来在转移和(或)定义仍未决定时保存标号名的。 最后,由于other语句没有对应的实际代码,这个过程只是产生了非 P-代码指令“Other”。 程序清单8-10 控制语句的代码生成过程 在这个语法中,标准的“最近嵌套”规则解决了悬挂的二义性,如语法图所示。 333 第 8章 代 码 生 成 请读者跟踪程序清单 8-10过程中的操作,并将这条语句的操作显示出来。 if (true) while (true) if (false) break else other 它产生了如下的代码序列 ldc true fjp L1 lab L2 ldc true fjp L3 ldc false fjp L4 ujp L3 ujp L5 lab L4 334 编译原理及实践 Other lab L5 ujp L2 lab L3 lab L1 8.5 过程和函数调用的代码生成 在本章中,过程和函数调用是在一般术语中讨论的最后的语言机制,对这个机制的中间代 码和目标代码描述的复杂程度甚至超过了其他语言机制,因为不同的目标机器用相当不同的机 制来执行调用,而且调用很大程度上依赖于运行时环境的组织。因此,实现一个对任何目标系 统和运行时环境都够用的中间代码表示是困难的。 8.5.1 过程和函数的中间代码 函数调用的中间代码表示的要求可明确地表述如下:首先,有两个实际的机制需要说明 —函数过程的定义(definition) (也叫声明(declaration)) 和函数过程的调用 (call) 。定义产生了 函数名、参数和代码,但函数却不在那点执行。调用产生了参数的实际值,并且执行一个到函 数代码的转移。函数代码被执行和返回。当产生函数代码时,除了它的一般结构,执行的运行 时环境还不知道其他情况。这个运行时环境部分由调用者建造,部分也由被调用函数代码建造。 任务的分隔是调用次序(calling sequence) 的一部分,这在第 7章已研究过了。 定义的中间代码必须包括一条标志开始的指令,或称函数代码的入口点 (entry point),还要 包括一条标志结束的指令,称为函数返回点 (return point)。写出概要如下: Entry instruction Return instruction 相似地,函数调用必须有一条指令指示参数计算的开始 (为调用而准备的 ),然后实际调用指令 指示已经构成了参数,到函数代码的实际转移可以发生了。 Begin-argument-computation instruction Call instruction 不同的中间代码版本在括号括起中的指令的实现上是不相同的。关于环境的信息数量、参 数尤为如此,作为每一条指令一部分的函数本身的指令更是这样。这些信息的典型例子包括数 字尺寸和参数的定位、栈的大小、局部以及临时变量空间的大小和被调用函数使用寄存器的指 示。通常地,将产生一些在指令本身中已包含少量信息的中间代码,而任何必要的信息可以单 独地放在过程的符号表中。 1) 过程和函数的三地址码 在三地址码中,入口指令需要给出一个过程入口点的名字, 这类似于 label指令,因此,这是一条地址指令。我们将其简单地称作 entry,类似地把返 通过这段文字,我们发现函数和过程本质上都代表了相同的机制,如没有特殊的说明,在这一节中认为它们 是一样的。当然,仅有的区别是:当函数退出时,返回调用者可利用的返回值,并且调用者必须知道在哪儿 能发现它。 335 第 8章 代 码 生 成 回指令叫作 return。这个指令也是一条地址指令,假如有返回值,那么它必须给出返回值的 名字。 例如,考虑 C函数定义 int f ( int x, int y ) { return x + y + 1; } 这样翻译成如下的三地址码: entry f t1 = x + y t2 = t1 + 1 return t2 在调用的情况下,实际需要 3个不同的三地址指令:一个标志参数计算的起点,称作 begin_ args(是一个零地址指令 );一个被用于重复地说明参数值的名字的指令,我们称其为 arg (它 必须包括参数值的地址或名字 );最后是实际调用指令,简单地写成 call,它也是一个一地址 指令(被调用函数的名字或入口点必须给出 )。 例如,假如上例中的函数 f已经在 C中定义,那么,这个调用 f ( 2+3, 4) 翻译成三地址码如下: begin_args t1 = 2 + 3 arg t1 arg 4 call f 在这里按从左到右顺序列出参数。这个顺序可以是不同的 (参看7.3.1节)。 2) 过程和函数的P-代码 P-代码的入口指令是 ent,返回指令是 ret,前面C函数f的定义 可以翻译成 P-代码如下: ent f lod x lod y adi ldc 1 adi ret 注意,ret指令不需要一个参数用来指示返回的值。在返回时,返回值被认为已在 P机器 栈顶上。 用于调用的P-代码指令是 mst指令和 cup指令。 mst指令表示“标志栈”对应于三地址码 指令中的 begin-args。它之所以称作“标志栈”的原因是从这种指令生成的目标代码将为一 个新调用在栈上建立一个活动记录,这是调用序列中前几个步骤。这通常意味着,对于参数这 样的元素,必须在栈中为它分配或“标志”空间。 P-代码指令 cup是“调用用户过程”指令, 它直接对应于三地址码中的 call指令。取这个名字的原因是 P-代码区分两种调用—— cup和 csp (调用标准过程)。标准过程是语言定义所需的内部过程,如 pascal中的sin和abs过程(C无 内部过程可言 )内部过程能用关于它们的操作的特殊知识来提高调用的效率 (或者甚至消除这个 调用)。这里就不再进一步考虑 csp操作了。 336 编译原理及实践 注意,并没有引入等效于三地址码 arg指令的 P-代码指令。相反地,在遇到 cup指令时, 将所有参数值都假想成出现在栈顶 (按适当的顺序)。这导致了与三地址码稍微不同的调用序列 顺序(见练习)。 前面所述的函数 f,它的C调用例子f(2+3,4)可以翻译成如下的 P-代码: mst ldc 2 ldc 3 adi ldc 4 cup f (再次从左向右计算参数 )。 8.5.2 函数定义和调用的代码生成过程 和前面几节一样,我们想展示函数定义和调用语法样例的代码生成过程。要用的文法如下: program → decl-list exp decl-list → decl-list decl | decl → fn id ( param-list )= exp param-list → param-list, id | id exp → exp + exp | call | num | id call → id ( arg-list ) arg-list → arg-list, exp | exp 这个文法定义了一个程序,该程序是函数定义序列,这个序列后跟随着单个表达式。在这个文 法中没有变量或赋值,只有参数、函数和可以包括函数调用的表达式。所有值都是整型的,所 有函数返回整型,所有函数至少有 1个参数。这里只有1个数字操作(除函数调用外):整数加法。 由这个文法定义的程序例子如下所示: fn f(x)=2+x fn g(x,y)=f(x)+y g(3,4) 这个程序包含两个函数定义,其后跟着函数 g的调用表达式。在 g中有一个对函数 f的调用。我 们想对这个文法定义一个语法树结构,通过下面的 C声明来进行: typedef enum {PrgK, FnK, ParamK, PlusK, CallK, ConstK, IdK} NodeKind; typedef struct streenode { NodeKind kind; struct streenode *lchild,*rchild, *sibling; char * name; /* used with FnK,ParamK,Callk,IdK */ int val; /* used with ConstK */ } StreeNode; typedef StreeNode * SyntaxTree; 在这个树结构里有7个不同种类的节点。每个语法树都有一个根节点 PrgK。这个节点仅用来将 声明和程序表达式绑在一起。一棵语法树只包含一个这样的节点。这个节点的左子树是 FnK节 337 第 8章 代 码 生 成 点的兄弟链表。右子树是相关联的程序表达式。每一个 FnK节点都有一个左子树,它是 ParamK节点的兄弟链表。这些节点定义了参数的名字。每个函数体都是 FnK节点的右子树。 除了有一个 CallK节点之外,表达式节点和通常的一样。这个节点包含了所谓函数的名字,并 且有一个右子树是参数表达式的兄弟链表。例如,前面样例程序的语法树如图 8-3所示。为使 之表达清楚,在该图中包括了每个节点的节点种类,还有所有的名字 /值属性。孩子和兄弟用 方向区别(兄弟向右,孩子向下 )。 图8-3 上面程序例子的语法树 给定这棵语法树结构,产生 P-代码的代码生成过程在程序清单 8-11中给出。对这段代段作 如下的注释:首先, PrgK节点的代码简单地向树的其余部分递归。 Idk、ConstK或PlusK的 代码实际上和前面例子中的一样。 程序清单 8-11 函数定义和调用的代码生成过程 338 编译原理及实践 这里剩下了FnK、ParamK和CallK的情况。 FnK节点的代码只用 ent和ret将函数体 (右 子树 ) 的代码括上了。函数参数没有被访问。实际上参数节点不引起代码的产生,参数已经由 调用者产生 。这也解释了在程序清单 8-11中在ParamK节点上为什么没有动作。实际上由于 FnK代码的行为,在树的遍历中,不会触及到任何一个 ParamK节点。因此这种情况实际上不 用讨论。 最后的例子是 CallK。代码先发出一个 mst指令,然后为每一个参数产生代码,最后发出 cup指令。 请读者显示图 8-11中的代码生成过程是怎样生成如下的 P-代码。给定的程序语法图如图 8 - 3。 ent f ldc 2 lod x adi ret ent g mst lod x cup f lod y adi ret mst 因为参数节点参数在活动记录中的相对位置和位移,所以它们有着重要的簿记任务。假设这一点是在其他地 方处理的。 339 第 8章 代 码 生 成 ldc 3 ldc 4 cup g 8.6 商用编译器中的代码生成:两个案例研究 这一节检查两个不同商业编译器 (不同的处理器 )产生的汇编代码输出。第 1个是Borland公 司对80×86处理器的 3.0版C编译器。第2个是Sun公司对于 SparcStation的2.0版C编译器,我们 将示出这些编译器对一些代码例子的汇编输出,那些代码例子和用来阐明三地址码和 P-代码所 用的例子一样 。这将对代码生成技术和中间代码到目标代码的转化进行更深入的考察,同时 它也提供了与TINY编译器生成的机器代码之间有用的比较, TINY的机器代码将在后面的章节 中被讨论。 8.6.1 对于80×86的Borland 3.0版C编译器 我们用8.2.1节中使用的赋值表达式开始关于这个编译器输出的例子。这个赋值式是: (x=x+3)+4 假设将变量 x存于栈中。 对于这个表达式, Borland 3.0 C编译器产生的Intel 80×86上的汇编代码如下: mov ax, word ptr [bp-2] add ax, 3 mov word ptr [bp-2], ax add ax, 4 在这段代码中,累加寄存器 ax在计算中用作主要的临时变量位置。局部变量 x的位置是bp-2, 它反应了寄存器bp (基指针)用作框架指针和整型变量在机器中占两个字节。 第1条指令把x的值移到ax中(地址[bp-2]的括号表示一个间接装入而不是一个直接装入 ), 第2个指令将常量3增加进这个寄存器。第3个指令将这个值移到x的位置。最后,第4条指令将4 加入ax,以致于将表达式最后的值留在了寄存器。它可以为进一步的计算所使用。 注意,在第3个指令中赋值用的x的地址不需要预先计算 (如P-代码指令lda)。中间代码的静 态模拟连同可利用的地址模式的知识能将 x的地址计算推迟到要用的时候。 1) 数组引用 用C表达式 (a[i+1]=2)+a[j] (参见“数组引用的代码生成过程”部分中的中间代码生成的示例。 )作为例子,假设 i、j和a 被作局部变量声明: int i,j; int a[10]; Borland C编译器为这个表达式产生如下的汇编代码 (为使引用变得容易,我们对代码进行了编 号) (1) mov (2) shl (3) lea bx,word ptr [bp-2] bx,1 ax, word ptr [bp-22] 出于这个目的,不考虑编译器的优化。 340 编译原理及实践 (4) add (5) mov (6) mov (7) mov (8) shl (9) lea (10) add (11) add bx,ax ax,2 word ptr [bx],ax bx,word ptr [bp-4] bx,1 dx,word ptr [bp-24] bx,dx ax,word ptr [bx] 因为在这个系统中整数的大小是 2字节。bp-2是i在局部活动记录中的位置。 bp-4是j的位置, a的基地址是bp-24 (24 = 数组索引尺寸×整型尺寸的 2个字节加上i和j的4个字节),因此指令 1将i的值装入bx,指令2将这个值乘以 2(左移一位)。指令3将a的基地址装入 ax (lea表示装入 有效地址 ),这个基地址由于下标表达式 i+1中的常量 1的缘故,已经增加了 2个字节。换句话 说,编译器已经实现了这样一个代数事实: address (a[i+1]) = base_address (a) + (i+1)*elem_size (a) = (base_address (a) + elem_size (a)) + i*elem_size (a) 指令4计算a[i+1]的结果地址,并将其放入 bx中。指令 5将常数 2移入ax中,指令 6将其存 入a[i+1]的地址中,指令 7将j值装入 bx,指令 8将这个值乘 2,指令 9将a的基地址装到 dx, 指令10计算a[j]地址并放入 bx,指令11把这个地址中的内容加入到 ax中。表达式的结果值留 在ax中。 2) 指针和域引用 假设上一个例子的声明为: typedef struct rec { int i; char c; int j; } Rec; typedef struct treeNode { int val; struct treeNode * lchild, * rchild; } TreeNode; ... Rec x; TreeNode *p; 同时也假设 x和p被声明为局部变量,也进行了适当的指针分配。 首先考虑涉及的数据类型的尺寸。在 80×86系统中,整型变量占2个字节,字符变量占 1字 节,“近”指针占2个字节 。因此变量 x有5个字节,变量 p有2个字节。作为仅有的变量,它们 被声明是局部的, x在活动记录中分配在 bp-6位置(不能将局部变量分配在偶数字节界上,这 是一个典型的限制,因此将不用额外的字节 ),p被分配到寄存器si中。更进一步,在结构 Rec 中,i有偏移量 0,c有偏移量 2,j有偏移量 3,而在树节点结构中, val有偏移量 0,lchild 有偏移量 2,rchild有偏移量 4。 语句 x.j =x.i; 80×86有一个更一般的指针叫远指针,有 4个字节。 341 第 8章 代 码 生 成 的生成代码是: mov ax,word ptr [bp-6] mov word ptr [bp-3],ax 第1条指令将 x.i装入 ax,第 2条将这个值存到 x.j 中。请注意,对 j 的偏移量计算 (6+3=-3)由编译器静态执行。 语句 p->l child = p; 的生成代码是: mov word ptr [si+2], si 请注意怎样将间接和偏移量计算放进同一指令中的。 最后,语句 p = p->rchild; 的生成代码是 mov si, word ptr [si+4] 3) if和while语句 这里将显示由Barland C 编译器生成的典型控制语句的代码。所用语句是 if (x>y) y++;else x--; 和 while (xlchild=p; 的目标代码为 ld [%fp+-0x10], %o2 ld [%fp+-0x10], %o3 st %03, [%o2+0x4] 在这里p的值装载到寄存器 o2和o3,其中的一个拷贝 (o2)用作存储另一个拷贝到 p->lchild 位置的基地址。最后,赋值语句 p = p->rchild; 的目标代码 ld [%fp+-0x10],%o4 ld [%o4 +0x8],%o5 st %o5,[%fp+-0x10] 在这段代码中 p的值装入寄存器 o4,然后作为基地址以装入 p->rchild的值。最后一条指令 将这个值存入 p的位置。 3) If和While 语句 我们如同Borland编译器一样展示Sun SparcStation C编译器为同一段典 型控制语句产生的代码 if (x>y) y++; else x--; 345 第 8章 代 码 生 成 和 while (x0) reg [PC_REG] =a if (reg [r]>0) reg [PC_REG] =a if (reg [r]==0) reg [PC_REG] =a if (reg [r]!=0) reg [PC_REG] =a 这里操作数 r、s、t为正规寄存器 (在装入时检测)。这样这种指令有 3个地址,且所有地址 都必须为寄存器。所用算术指令被限制到这种格式,以及两个基本输入 /输出指令。 一条寄存器 -存储器指令有如下格式: opcode r,d(s) 在代码中 r和s必须为正规的寄存器 (装入时检测),而d为代表偏移的正、负整数。这种指令为 两地址指令,第 1个地址总是一个寄存器,而第 2个地址是存储器地址 a,用a = d + reg[s]给 出,这里a必须为正规地址(0≤a<DADDR_SIZE)。如果a超出正规的范围,在执行中就会产生 DMEM_ERR。 RM指令包括对应于 3种地址模式的 3种不同的装入指令:“装入常数” (LDC),“装入地址” (LDA)和“装入内存” (LD)。另外,还有1条存储指令和6条转移指令。 在RD和RM中,即使其中一些可能被忽略,所有的 3个操作数也都必须表示出来。这是由 于简化了装载器,它仅区分两类指令 (RO和RM)而不允许在一类中有不同的指令格式 。 程序清单8-12和到此为止的 TM讨论表示了完整的 TM结构。特别需要指出的是:除了 pc之 外没有特殊寄存器 (没有sp或fp),其他再也没有硬件栈和其他种类的要求。因此, TM的编译器 必须完全手工维护运行时环境组织。虽然这看起来有点不切实际,但它有所有操作在需要时必 须显式产生的优点。 由于指令集是最小的,这就需要一些说明来指出它们如何被用来构造大部分标准程序语言 操作(实际上,这个机器如果不去满足少量高级语言的话已经足够了 )。 1) 算术运算中目标寄存器、 IN以及装入操作先出现,然后才是源寄存器。这类似于 80×86 而不同于Sun SparcStation。对用于目标和源的寄存器没有限制:特别地,目标和源寄存器可以 相同。 这也使代码生成更容易了,因为对两类指令只需两种例程。 349 第 8章 代 码 生 成 2) 所有的算术操作都限制在寄存器之上。没有操作 (除了装入和存储操作 )是直接作用于内 存的。这一点TM与诸如Sun Sparc Station的RISC机器相似。另一方面,TM只有8个寄存器,而 大部分RISC处理器有至少32个 。 3) 没有浮点操作和浮点寄存器。为 TM增加浮点操作和寄存器的协处理器并不困难。在普 通寄存器和内存之间转换浮点数时要小心一些。请参阅练习。 4) 与其他一些汇编代码不同,这里没有在操作数中指定地址模式的能力 (比如LD#1表示立 即模式,或 LD @a表示间接)。作为代替的是对应不同模式的不同指令: LD是间接,LDA是直 接,而LDC是立即。实际上TM只有很少的地址选择。 5) 在指令中没有限制使用 pc。实际上由于没有无条件转移指令,因此必须由将 pc作为LDA 指令的目标寄存器来模拟: LDA 7,d(s) 这条指令效果为转移到位置a = d + reg[s]。 6) 这里也没有间接转移指令,不过也可以模拟,如果需要也可以使用 LD指令,例如, LD 7,0(1) 转移到由寄存器1指示地址的指令。 7) 条件转移指令(JLT等)可以与程序中当前位置相关,只要把 pc作为第2个寄存器,例如 JEQ 0,4(7) 导致TM在寄存器0的值为0时向前转移5条指令,无条件转移也可以与 pc相关,只要pc两次出现 在LDA指令中,这样, LDA 7,-4(7) 执行无条件转移回退 3条指令。 8) 没有过程和JSUB指令,作为代替,必须写出 LD 7,D(S) 其效果是转移到过程,其入口地址为 dMem[d+reg[s]]。当然,要记住先保存返回地址,类 似于执行 LDA 0,1(7) 它将当前 pc 值加 1放到 reg[0](那是我们要返回地地方,假设下一条指令是实际的转移到 过程 )。 8.7.2 TM模拟器 这个模拟器接受包含上面所述的 TM指令的文本文件, 并有以下约定: 1) 忽略空行。 2) 以星号打头的行被认为是注释而忽略。 3) 任何其他行必须包含整数指示位置后跟冒号再接正规指令。任何指令后的文字都被认为 是注释而被忽略掉。 TM模拟器没有其他特征了——特别是没有符号标号和宏能力。程序清单 8-13为一个手写 的TM程序对应于程序清单 8-1的TINY程序。严格地说,程序清单 8-13中代码尾部不需要 HALT 由于TM寄存器的数量增加很容易,因为基本代码生成无需如此,所以我们不必这样做。见本章最后的练习。 350 编译原理及实践 指令,由于TM模拟器在装入程序之前已设置了所有指令位置直到 HALT。然而,将其作为一个 提醒是很有用的——以及作为转移退出程序的目标。 程序清单 8-13 显示约定的程序 * This program inputs an integer, computes * its factorial if it is positive, * and prints the result 0: IN 0, 0, 0 r0 = read 1: JLE 0, 6 (7) if 0 < r0 then 2: LDC 1,1,0 r1 = 1 3: LDC 2, 1, 0 r2=1 * repeat 4: MUL 1, 1, 0 r1 = r1*r0 5: SUB 0, 0, 2 r0 = r0-r2 6: JNE 0, -3 (7) until r0 == 0 7: OUT 1, 0, 0 write r1 8: HALT 0, 0, 0 halt * end of program 此外没有必要如程序清单 8-13中那样将位置升序排列。每个输入行足够指出“将这条指 令存在这个位置”:如果 TM程序打在卡片上,那么在掉到地板上之后再读入还是工作得很完 美。 TM模拟器的这种特性可能引起阅读程序时的混淆,为了在没有符号标号的情况下反填转 移,代码要能不必回翻代码文件就能完成反填。例如,代码生成器可能产生程序清单 8-13代 码如下: 0: IN 0,0,0 2: LDC 1,1,0 3: LDC 2,1,0 4: MUL 1,1,0 5: SUB 0,0,2 6: JNE 0,-3(7) 7: OUT 1,0,0 1: JLE 0,6(7) 8: HALT 0,0,0 因为在知道if语句体后面的位置之前,向前转移的指令 1没法生成。 如果程序清单8-13的程序在文件fact.tm中,那么这个文件可以用下面的示例任务装入并 执行(如果没有扩展名,TM模拟器自动假设 .tm): tm fact TM simulation ( enter h for help ) ... Enter command: g Enter value for IN instruction: 7 OUT instruction prints: 5040 HALT: 0, 0, 0 Halted Enter command: q Simulation done 351 第 8章 代 码 生 成 g命令代表“ go”,它表示程序用当前 pc中的内容(装入之后为 0)开始执行到看到 HALT指令 为止。完整的模拟器命令可以用 h命令得到,将打出以下列表: Commands are: s(tep Execute n ( default 1 ) TM instructions g(o Execute TM instructions until HALT r(egs print the contents of the registers i ( M e m < b < n > >P r i n t n i M e m l o c a t i o n s s t r a r t i n g a t b d ( M e m < b < n > >P r i n t n d M e m l o c a t i o n s s t r a r t i n g a t b t(race Toggle instructions trace p(rint Toggle print of total instructions executed ('go' only) c(lear Reset simulator for new execution of program h(elp Cause this list of commands to printed q(uit Terminate the simulation 命令中的右括号指示命令字母衍生的记忆法 (使用多个字母也可以,但模拟器只检查首字母 )。 尖括号< >表示可选参数。 8.8 TINY语言的代码生成器 现在要描述 TINY语言的代码生成器。我们假设读者熟悉 TINY编译器的先前步骤,特别 是第 3章中描述的分析器产生的语法树结构、第 6章描述的符号表构造,以及第 7章的运行时 环境。 本节首先描述 TINY代码生成器和 TM的接口以及代码生成必需的实用函数。然后再说明代 码生成的步骤。接着,描述 TINY编译器和 TM模拟器的结合。最后讨论全书使用的示例 TINY 程序的目标代码。 8.8.1 TINY代码生成器的TM接口 一些代码生成器需要知道的有关 TM的信息已封装在文件 code.h 和code.c 中,在附 录B中有该程序,分别是第 1600行到第 1685行和第 1700行到第 1796行。此外还在文件中放入 了代码发行函数。当然,代码生成器还是要知道 TM指令的名字,但是这些文件分离了指令 格式的详细说明和目标代码文件的位置以及运行时使用特殊寄存器。 code.c文件完全可以 将指令序列放到特别的 iMem位置,而代码生成器就不必追踪细节了。如果 TM装载器要改进, 也就是说允许符号标号并去掉数字编号,那么将很容易将标号生成和格式变化加入到 code.c文件中。 现在我们复习一下 code.h文件中的常数和函数定义。首先是寄存器值的定义(1612,1617, 1623,1626和1629行)。明显地,代码生成器和代码发行实用程序必须知道 pc。另外还有TINY 语言的运行时环境,如前一节所述,将数据存储时的顶部分配给临时存储 (以栈方式)而底部则 分配给变量。由于 TINY中没有活动记录 (于是也就没有 fp)(没有作用域和过程调用 ),变量和临 时存储的位置可认为是绝对的。然而, TM机的LD操作不允许绝对地址,而必须有一个寄存器 基值来计算存储装入的地址。这样我们分配两个寄存器,称为 mp(内存指针)和gp(全程指针)来 指示存储区的顶部和底部。 mp将用于访问临时变量,并总是包含最高正规内存位置,而 gp用 于所有命名变量访问,并总是包含 0。这样由符号表计算的绝对地址可以生成相对 gp的偏移来 使用。例如,如果程序使用两个变量 x和y,并有两个临时值存在内存中,那么 dMem将如下所 示: 352 编译原理及实践 自由空间 在本图中,t1的地址为0(mp),t2为-1(mp),x的地址为 0(gp),而y为1(gp)。在这个实 现中, gp是寄存器 5,mp是寄存器 6。 另两个代码生成器将使用的寄存器是寄存器 0和1,称之为“累加器”并命令名为 ac和ac1。 它们被当作相等的寄存器来使用。通常计算结果存放在 ac中。注意寄存器 2、3和4没有命名(且 从不使用 1)。 现在来讨论 7个代码发行函数,原型在 code.h文件中给出。如果 TraceCode标志置位, e m i t C o m m e n t 函数会以注释格式将其参数串打印到代码文件中的新行中。下两个函数 emitRO和emitRM为标准的代码发行函数用于 RO和RM指令类。除了指令串和3个操作数之外, 每个函数还带有 1个附加串参数,它被加到指令中作为注释 (如果TraceCode标志置位)。 接下来的 3个函数用于产生和反填转移。 emitSkip函数用于跳过将来要反填的一些位置 并返回当前指令位置且保存在 code.c内部。典型的应用是调用 emitSkip(1),它跳过一个 位置,这个位置后来会填上转移指令,而 emitSkip(0)不跳过位置,调用它只是为了得到当 前位置以备后来的转移引用。函数 emitBackup用于设置当前指令位置到先前位置来反填, emitRestore用于返回当前指令位置给先前调用 emitBackup的值。典型地,这些指令在一 起使用如下: emitBackup(savedLoc) ; /* generate backpatched jump instruction here */ emitRestore() ; 最后代码发行函数 (emitRM_Abs)用来产生诸如反填转移或任何由调用 emitSkip返回的代码 位置的转移的代码。它将绝对代码地址转变成 pc相关地址,这由当前指令位置加 1(这是pc继续 执行的地方 )减去传进的位置参数,并且使用 pc做源寄存器。通常地,这个函数仅用于条件转 移,比如JEQ或使用LDA和pc作为目标寄存器产生无条件转移, 如前一小节所述的那样。 这样就描述完了TINY代码生成实用程序,我们来看一看 TINY代码生成器本身的描述。 8.8.2 TINY代码生成器 TINY代码生成器在文件 cgen.c中,其中提供给 TINY编译器的唯一接口是 CodeGen,其 原型为: void CodeGen (void); 在接口文件 cgen.h中给出了唯一的定义。附录 B中有完整的cgen.c文件,参见第1900行到第 2 111行。 函数CodeGen本身(第2095行到第2111行)所做的事极少:产生一些注释和指令 (称为标准 353 第 8章 代 码 生 成 序言(standard prelude)) 、设置启动时的运行时环境,然后在语法树上调用 cGen,最后产生 HALT指令终止程序。标准序言由两条指令组成:第 1条将最高正规内存位置装入 mp寄存器(TM 模拟器在开始时置 0)。第2条指令清除位置 0(由于开始时所有寄存器都为0,gp不必置0)。 函数cGen(第2070行到第2084行)负责完成遍历并以修改过的顺序产生代码的语法树,回想 T I N Y语法树定义给出的格式: typedef enum { StmtK, ExpK } NodeKind; typedef enum { IfK, RepeatK, AssignK, ReadK, WriteK } StmtKind; typedef enum { OpK, ConstK, IdK } ExpKind; #define MAXCHILDREN 3 typedef struct treeNode { struct treeNode * child[MAXCHILDREN] ; struct treeNode * sibling; int lineno; NodeKind nodekind; union { StmtKind stmt; ExpKind exp; } kind; union { TokenType op; int val; char * name; } attr; ExpType type; } TreeNode; 这里有两种树节点:句子节点和表达式节点。如果节点为句子节点,那么它代表 5种不同TINY 语句(if、repeat、赋值、read或write)中的一种,如果节点为表达式节点,则代表 3种表达式(标 识符、整形常数或操作符 )中的一种。函数 cGen仅检测节点是句子或表达式节点 (或空),调用 相应的函数genStmt或genExp,然后在同属上递归调用自身 (这样同属列表将以从左到右格 式产生代码 )。 函数genStmt(第1924行到第 1994行)包含大量 switch语句来区分 5种句子,它产生代码并 在每种情况递归调用 cGen,genExp函数(第1997行到第2065行)也与之类似。在任意情况下, 子表达式的代码都假设把值存到 ac中而可以被后面的代码访问。当需要访问变量时 (赋值和read 语句以及标识符表达式 ),通过下面操作访问符号表: loc = lookup(tree->attr.name); loc的值为问题中的变量地址并以 gp寄存器基准的偏移装入或存储值。 其他需要访问内存的情况是计算操作符表达式的结果,左边的操作数必须存入临时变量直 到右边操作数计算完成。这样操作符表达式的代码包含下列代码生成序列在操作符应用 (第 2021行到第2027行)之前: cGen(p1); /* p1 = left child */ emitRM("ST",ac,tmpOffset--,mp,"op: push left"); cGen(p2); /* p2 = right child */ emitRM("LD",ac1,++tmpOffset,mp,"op: load left"); 这里的tmpOffset为静态变量,初始为用作下一个可用临时变量位置对于内存顶部 (由mp寄存 器指出)的偏移。注意 tmpOffset如何在每次存入后递减和读出后递增。这样 tmpOffset可 以看成是“临时变量栈”的顶部指针,对 emifRM函数的调用与压入和弹出该栈相对应。这在 临时变量在内存中时保护它们。在以上代码之前执行实际动作,左边的操作数将在寄存器 1(acl)中而右边操作数在寄存器 0(ac)中。如果是算术操作的话,就产生相应的 RO操作。 354 编译原理及实践 比较操作符的情况有少许差别。 TINY语言的语法(如语法分析器中的实现,参见前面章节 ) 仅在if语句和while语句的测试表达式中允许比较操作符。在这些测试之外也没有布尔变量或值, 比较操作符可以在这些语句的代码生成内部处理。然而,这里我们用更通常的方法,它更广泛 应用于包含逻辑操作与 /或布尔值的语言,并将测试结果表示为 0(假)或1(真),如同在C中一样。 这要求常数据0或1显式地装入ac,用转移到执行正确装载来实现这一点。例如,在小于操作符 的情况中,产生了以下代码,代码产生将计算左边操作数存入寄存器 1,并计算右边操作数存 入寄存器 0: SUB JLT LDC LDA LDC 0,1,0 0,2(7) 0,0(0) 7,1(7) 0,1(0) 第1条指令将左边操作数减去右边操作数,结果放入寄存器 0,如果 < 为真,结果应为负值,并 且指令JLT 0,2(7)将导致跳过两条指令到最后一条,将值 1装入ac,如果 < 为假,将执行第3 条和第4条指令,将 0装入ac然后跳过最后一条指令 (回忆TM的描述,LDA使用pc为寄存器引起 无条件转移 )。 我们将以if-语句(第1930行到第1954行)的讨论来结束TINY代码生成器的描述。其余的情况 留给读者。 代码生成器为if语句所做的第1个动作是为测试表达式产生代码。如前所述测试代码,在假 时将0存入ac,真时将1存入。生成代码接下来要产生一条 JEQ到if语句的else部分。然而这些代 码的位置当前是未知的,这是因为 then部分的代码还要生成。因此,代码生成器用 emitSkip来 跳过后面语句并保存位置用于反填: savedLoc1 = emitSkip(1) ; 代码生成继续处理 if算语句的 then部分。之后必须无条件转移跳过 else部分。同样转移位置未知, 于是这个转移的位置也要跳过并保存位置: savedLoc2 = emitSkip(1) ; 现在,下一步是产生 else部分的代码,于是当前代码位置是正确的假转移的目标,要反填到位 置savedLoc1。下面的代码处理之: currentLoc = emitSkip (0) ; emitBack up(savedLoc1) ; emitRM_Abs("JEQ",ac,currentLoc,"if: jmp to else") ; emitRestors() ; 注意emitSkip(0)调用是如何用来获取当前指令位置的,以及 emitRM_Abs过程如何用于将 绝对地址转移变换成 pc相关的转移,这是 JEQ指令所需的。之后就可以为 else部分产生代码了, 然后用类似的代码将绝对转移 (LDA)反填到savedLoc2。 8.8.3 用TINY编译器产生和使用 TM代码文件 TINY代码生器可以合谐地与 T M模拟器一起工作。当主程序标志 N O _ P A R S E 、 NO_ANALYZE和NO_CODE都置为假时,编译器创建 .tm后缀的代码文件(假设源代码中无错误 ) 并将TM指令以TM模拟器要求的格式写入该文件。例如,为编译并执行 sample.tny程序,只 要发出下面命令: 355 第 8章 代 码 生 成 tiny sample tm sample 为了跟踪的目的,有一个 TraceCode标志在globals.h中声明,其定义在 main.c中。如果 标志为 TRUE,代码生成器将产生跟踪代码,在代码文件中表现为注释,指出每条指令或指令 序列在代码生成器的何处产生以及产生原因。 8.8.4 TINY编译器生成的TM代码文件示例 为了详细说明代码生成是如何工作的,我们在程序清单 8-14中展示了TINY代码生成器生 成的程序清单8-1中示例程序的代码,由于 TraceCode = TRUE,所以也产生了代码注释。这 个代码文件有42条指令,其中包括来自标准序言的两条指令。将它与程序清单 8-13中手写的程 序中的漂亮指令对比,我们可以明显看出一些不够高效之处。特别地,程序清单 8-13的程序高 效地使用了寄存器,除了寄存器之外没有再也用到内存。程序清单 8-14代码正相反,没有使用 超过两个寄存器并执行了许多不必要的存储和装入。特别愚蠢的是处理变量值的方法,其装入 只是为了再次存储到临时变量中,如下所示: 16: 17: 18: 19: LD 0,1(5) load id value ST 0,0(6) op: push left LD 0,0(5) load id value LD 1,0(6) op: load left 这可以用两条指令代替: LD 1,1(5) load id value LD 0,0(5) load id value 它们具有同样的效果。 更多潜在的不足将由生成的测试和转移代码引起。不完整的笨例子是指令: 40: L D A 7 , O ( 7 ) j m p t o e n d 这是一个煞费苦心的 NOP(一条“无操作”指令 )。 然而,程序清单8-14的代码有一个重要理由:它是正确的。在匆忙提高生成代码的效率时, 编译编写者忘记了这个原则并允许生成只有效率却不总是能正确执行的代码。这种行为如果不 做好文档并预测可能会导致灾难。 由于学习所有改进编译器代码产生的方法超出了本书的范围,本章最后的两节将仅考察可 以做出这些改进的主要范围和实现它们的技术,并简要说明某些方法如何用于 TINY代码生成 器来改进生成的代码。 程序清单 8-14 程序清单8-1示例程序的代码输出 * TINY Compilation to TM Code * File: sample.tm * Standard prelude: 0: LD 6,0(0) load maxaddress from location 0 1: ST 0,0(0) clear location 0 * End of standard prelude. 2: IN 0,0,0 read integer value 356 编译原理及实践 3: ST 0,0(5) read: store value * -> if * -> Op * -> Const 4: LDC 0,0(0) load const * <- Const 5: ST 0,0(6) op: push left * -> Id 6: LD 0,0(5) load id value * <- Id 7: LD 1,0(6) op: load left 8: SUB 0,1,0 op < 9: JLT 0,2(7) br if true 10: LDC 0,0(0) false case 11: LDA 7,1(7) unconditional jmp 12: LDC 0,1(0) true case * <- Op * if: jump to else belongs here * -> assign * -> Const 14: LDC 0,1(0) load const * <- Const 15: ST 0,1(5) assign: store value * <- assign * -> repeat * repeat: jump after body comes back here * -> assign * -> Op * -> Id 16: LD 0,1(5) load id value * <- Id 17: ST 0,0(6) op: push left * -> Id 18: LD 0,0(5) load id value * <- Id 19: LD 1,0(6) op: load left 20: MUL 0,1,0 op * * <- Op 21: ST 0,1(5) assign: store value * <- assign * -> assign * -> Op * -> Id 22: LD 0,0(5) load id value * <- Id 23: ST 0,0(6) op: push left * -> Const 24: LDC 0,1(0) load const * <- Const 25: LD 1,0(6) op: load left 26: SUB 0,1,0 op - 357 第 8章 代 码 生 成 * <- Op 27: ST 0,0(5) assign: store value * <- assign * -> Op * -> Id 28: LD 0,0(5) load id value * <- Id 29: ST 0,0(6) op: push left * -> Const 30: LDC 0,0(0) load const * <- Const 31: LD 1,0(6) op: load left 32: SUB 0,1,0 op = = 33: JEQ 0,2(7) br if true 34: LDC 0,0(0) false case 35: LDA 7,1(7) unconditional jmp 36: LDC 0,1(0) true case * <- Op 37: JEQ 0,-22(7) repeat: jmp back to body * <- repeat * -> Id 38: LD 0,1(5) load id value * <- Id 39: OUT 0,0,0 write ac * if: jump to end belongs here 13: JEQ 0,27(7) if: jmp to else 40: LDA 7,0(7) jmp to end * <- if * End of execution. 41: HALT 0,0,0 8.9 代码优化技术考察 自从50年代出现第1个编译器以来,生成的代码质量一直受到重视。质量由目标代码的速 度和大小来衡量,虽然通常速度更重要,现代编译器表明代码质量受编译过程中某些点的处理 过程影响,这一系列步骤包括收集源代码信息然后利用这些信息执行代码改进变换 (code improve transformation)改进代码中的数据结构,许多年来开发了大量的提高代码质量的技术, 称为代码优化技术(code optimization techniques)。这个术语有些误导,由于仅在一些很特殊的 情况下这些技术才能产生数学上的优化代码。不过这个名称很常用,我们还是继续使用它吧。 因为存在着许多代码优化技术,我们只能大概浏览一些最重要和最广泛使用的,甚至对这 些也不给出细节和实现。本章有更多信息的参考书目。必须认识一点,编译器编写者不能希望 包含每一种单个优化技术,而要根据语言的实际情况作出判断。哪种技术可以在增加最小编译 器复杂度的情况下大大提高代码质量,有许多描述需要极复杂实现的优化技术的论文,而这些 技术仅产生了相对有一点改进的目标代码 (也就是减少一点运行时间 )。经验通常显示一些基本 方法虽然看起来是简单方式应用却可以带来重大提高,有时甚至减少一半或更多执行时间。 衡量某个优化技术的实现是否太复杂依赖实际代码改进的代价,不仅要确定实现的数据结 构和额外代码开销的复杂度还要考虑优化步骤对编译器本身速度的影响。我们所学的所有分析 358 编译原理及实践 技术都与编译程序大小成正比。有些优化技术可能相对程序大小的平方或 3次方增加编译时间, 于是完全优化编译一个大程序时要延长好几分钟 (最差时要几个小时 )。这将导致用户回避使用 优化 (或不再使用这个编译器 ),用于实现优化的时间是巨大的浪费。 下面几节我们将先描述优化的主要来源然后介绍几种经典优化方法。接着是几种重要技术 和实现的主要数据结构。自始至终都将给出简单示例来说明讨论的内容。下一节将给出更详细 的例子说明讨论的一些技术如何应用于前面章节的 TINY代码生成器,以及实现方法的建议。 8.9.1 代码优化的主要来源 下面将列出某些代码生成器不能产生好代码的地方,粗略地减少“代价”,也就是在这些 地方代码可以获得多大改进。 1) 寄存器分配 合理使用寄存器是高效代码的最重要特征。由于历史原因,可用寄存器很 少——通常只 8个或16个,这其中包括了特殊用途寄存器,如 pc、sp和fp。这使得寄存器合理 分配很困难,因为变量和临时变量竞争寄存器空间很激烈。这种情况仍存在于一些处理器中, 特别是微处理器,解决这一问题的一个方法是可以增加直接在内存执行的操作的数量和速度, 这样编译器一旦耗尽寄存器空间,就可以避免存储寄存器值到临时变量以释放寄存器值然后再 装新值(称为寄存器溢出 (register spill)操作)的代价。另一个方法 (称为RISC方法)减少在内存直 接执行的操作的数量 (常常为0),但同时增加可用寄存器到 32、64或128。在这种结构中,合理 配寄存器变得至关重要,因为它可以保存全部或大部分全程变量在寄存器中。这种结构中分配 寄存器失误的代价是频繁装载和存入值。同时因为有了很多可用的寄存器,寄存器分配工作也 变得简单了。这样,提高代码质量的重要努力应着眼于合理分配寄存器。 2) 不必要操作 代码改进的第2个主要来源是避免产生冗余或不必要的操作代码。这种优 化从很简单的搜索局部代码到分析整个程序的语法特性各不相同。确认这些操作的方法很多, 也有许多对应技术。这种优化的一种典型例子是代码中重复出现的表达式,而且它们的值相同, 可以保存的第 1次的计算值并删除重复计算 (称为公共子表达式消除 (common subexpression elimination)) 。另一个例子是避免存储不再使用的变量或临时变量的值 (这要与前面的优化共 同进行)。 全程的优化涉及识别不可到达 (unreachable)或死代码(dead code),典型例子是使用常量标 志开关调试信息: #define DEBUG O ... if (DEBIG) {...} 如果DEBUG设为0(如代码中所示 ),那么在if语句的大括号内的代码将不可到达,于是就不必产 生这段目标代码,甚至连 if语句也可以省掉。另一个不可到达代码例子是不调用的过程 (或只在 不可到达的代码处调用)的消除,不可到达代码不总是对速度产生重大影响,不过可以真正减少 目标代码大小,这是值得的,特别是当分析中很小的代价就可以识别大部分明显的情况。有时 为了识别不必要操作,用代码生成器处理然后检测目标代码的冗余更容易些。一种情况是对在 生成跳向表示结构控制语句时产生冗余作出预测是很困难的。这些代码包含转移到相邻的下一 一个好的程序员可以避免公共表达式在源码中这样扩散,读者不应认为优化只是用于帮助差劲的编程者。许 多来自地址计算的公共子表达式由编译器产生,并不能由好的源代码来消除它们。 359 第 8章 代 码 生 成 条语句或转移到转移语句。转移优化 (jump optimization)步骤可以去除这些不必要的转移。 3) 高代价操作 代码生成器不只要寻找不必要操作,还要利用机会减少必要操作的代价。 要用比源代码更简单实现更低的代价实现操作。典型例子用低代价操作代替算术操作,例如, 乘2可以用移位操作实现。小整数据幂,比如 x3,可以用连乘x*x*x实现。这种优化称为减轻强 度(reduction in strength),它可以扩展到许多方面,比如将涉及小的整数乘法替换为移位和加法 (例如用2*2*x代替5*x + x ——两个移位和一个加法 )。 一个相关的优化是用有关常数信息删除可能的操作或预先计算一些操作。例如,两个常数 相加,比如2+3,可以由编译器计算并作常数5代替(这称为常数据合并 (constant folding))。 有时有必要确定一个变量在程序局部或全程是否有恒定值,这样变换就可应用于涉及该变 量的表达式(称为常量传播(constant propagation))。 有时相对昂贵的操作是过程调用,这里许多调用操作序列必须执行。现代处理器通过提供 支持标准调用的硬件减少了这些代价。但是去除频繁调用小过程还是能产生可观的加速。有两 种标准方法可以去掉过程调用。一个是用过程体代替过程调用 (使用合适的参数代替形式参数)。 这称为过程内嵌 (procedure in lining),有时这是语言选项比如 C++。另一个消除调用的方法是 识别尾部递归(tail recursion),也就是过程最后的操作是调用自身,例如,过程: int gcd( int u, int v) { if (v==0) return u; else return gcd(v,u % v); } 是尾递归的,而过程 int fact( int n ) { if (n==0) return 1; else return n * fact(n-1); } 则不是。尾递归等同过将新的调用参数赋给形式参数并转移到过程体的开始。例如尾递归过程 g c d可以被编译器重写为等同代码: int gcd( int u, int v) { begin: if (v==0) return u; else { int t1 = v, t2 = u%v; u = t1; v = t2; goto begin; } } (注意代码中临时变量的微妙利用 )。这个处理称为尾递归消除 (tail recursion removal)。 要提及的问题是过程调用与寄存器分配有关。过程调用之前必须准备保存和恢复在调用内 部使用的寄存器。如果提供的是多寄存器分配,将增加过程调用代价,因为更多的寄存器要保 存和恢复。有时在寄存器分配中包含调用考虑会减少花费 。但这是一个普遍现象:有时优化 会引起反面效果,因此必须考虑取舍。 在代码生成的最后阶段,通过使用目标机器上的特殊指令可以减少某些操作的代价。例如, 许多结构包括块移动操作比单独拷贝或数组元素要快许多。还有地址计算有时可以优化,当结 Sun SparcStation中的寄存器窗口表示硬件支持过程调用的寄存器分配的一个例子。参见 8.6.2节最后。 360 编译原理及实践 构允许几种地址模式或偏移计算结合到一条指令中时。同样,用于索引的自动增量和减量也是 很实用的 ( VA X 结构甚至有为循环设的增量比较分支 )。这些优化来自先前的指令选择 (instruction selection)或机器语言使用(use of machine idioms)。 4) 预测程序行为 为了实施前面描述的一些优化,编译器必须收集有关程序中变量、值和 过程使用的信息:表达式是否重用 (可变成公共子表达式 )、变量是否改变、何时改变或一直不 变、过程是否调用。编译器必须在计算技术范围内作最坏的假设,收集的信息有可能产生错误 的代码:变量在特定点可能恒定也可能不恒定、编译器必须假设它不恒定,这意味着必须将编 译器做成不是最优化的,甚至常常对程序的行为信息利用较差。实际上对程序的分析越透彻, 代码优化器可以得到的信息越多。然而,即使今天最先进的编译器也会无法发现程序中的一些 可改进之处。 另一个许多编译器采用的方法是:实际运行中采集程序行为的统计信息,然后用于预测哪 条分支最有可能运行、哪个过程经常调用以及哪部分代码最经常执行。这些信息可以用于调整 转移结构,循环和过程代码来最小化最经常执行部分的运行时间,当然这个处理要求类似梗概 编译器(profiling compiler)访问适当的数据以及 (至少一部分)包含产生这些数据的指示代码的可 执行代码。 8.9.2 优化分类 由于有许多优化方法和技术,有必要采用不同分类规划强调优化的不同质量以减少学习难 度。两个有用的分类是在编译过程中何时可以应用优化和优化应用于程序的哪些部分。 首先我们考虑应用程序在编译过程中的时间。优化可以在编译的每阶段分别执行。例如, 常数合并可以在分析时进行 (虽然通常是迟一些,这样编译器可以得到与源代码相同的表示 )。 另一方面,一些优化可以延迟到目标代码生成之后—检查并重写目标代码以反映优化。例如, 转移优化可以以这种方式进行 (有时因为通常只观察目标代码的一小部分来进行优化,所以在 目标代码上进行的优化称作为窥孔优化 (peephole optimization))。 通常,主要的优化工作在中间代码生成部分、中间代码生成后或目标代码生成部分。为了 使优化不依赖于目标机器的特性 (称为源代码级优化 (source-level optimization)),可以在依赖目 标机器结构的动作 (目标代码级优化 (target-level optimization))之前执行。有时一种优化可以同 时有源码级部分和目标码级部分。例如,在寄存器分配中,经常要计算变量引用次数并将高引 用率的变量放入寄存器。这个任务又分成了一个源码级部分,这里为选择的变量分配寄存器不 必知道有多少可用寄存器。然后寄存器赋值步骤依赖于目标机器为这些标记的变量分配实际的 寄存器,或到称为伪寄存器 (pseudoregister)的内存(万一没有可用寄存器的话)。 在不同的优化中,考虑某一优化对其他优化产生的影响相当重要。例如,应在执行不可到 达代码消除之前进行传播常量操作,这是因为在测试变量被发现是常数据之后,某些代码会变 得不可大到达。在偶然情况下会发生两种优化无法为对方发现进一步优化机会的阶段问题 (phase problem)。如下例 x = 1; ... y = 0; ... if (y) x = 0; ... if (x) y = 1; 361 第 8章 代 码 生 成 首次常量传播会产生如下代码 x = 1; ... y = 0; ... if (0) x = 0; ... if (x) y = 1; 现在第1个if体变成不可到达;消除之 x = 1; ... y = 0; ... if (x) y = 1; 现在可以进行进一步的常量传播和不可到达代码消除步骤。由于这个原因,一些编译器反复执 行几组优化以确保大部分可以利用的优化机会已经找到。 第2类优化考虑的是优化应用的程序范围。这类优化还分为局部 (local)、全程(global)和过 程间(interprocedural)优化。局部优化定义为应用于代码的线性部分 (straight-line segment of code)的优化,也就是代码中没有跳进或跳出语句 。一个最大的线性代码序列称为基本块 (basic block)。局部优化的定义限制这些基本块。扩展超出基本块,但限制在单个过程中的优 化称为全程优化 (因为限制在过程中,所以这不是真正的“全程” )。优化扩展出过程边界到整 个程序称为过程间优化。 局部优化相对易于进行,因为代码的线性特征允许信息以简单方式下传。例如,基本块中 前一条指令将一个变量值装入寄存器,只要寄存器不被再次装入,就可以在块的后面继续认为 值还存在。这个结论在转移的干扰代码时就不正确了。如下图所示: 把x装入r ... (没有r的装入) ... 把x装入r ... (没有r的装入) ... 从任意处转移 (x可能不在r中) (x还在r中) 基本块 (x可能不在r中) 非基本块 全程优化相对要困难一些,通常要求一种称为数据流分析(data flow analysis)的技术,它试图 透过转移边界收集信息。过程间优化更困难,因为要涉及可能不同的参数传递机制,非局部变量 访问以及计算相同调用的过程的同步信息。过程间优化的另一个复杂之处是许多过程可能分别编 译最后才链接到一起。于是编译器在没有链接程序基于编译阶段收集的信息的基础上得出的优化 信息时不能进行优化。由于这个原因,许多编译器只执行很基本的过程间分析或者根本就不执行。 全程优化的一个特别部分是循环。由于循环通常多次执行,应该特别注意循环内部的代码, 过程调用表示一种特殊跳转,通常它们打断直线执行代码。然而,由于它们总是返回到相邻的下一条指令, 经常可以包含在直线代码中间并由代码生成过程以后处理。 362 编译原理及实践 特别是减少复杂运算。典型的循环优化策略着眼于识别每次执行增长值固定的变量 (也称为归 纳变量(induction variable))。 这包括循环控制变量和其他依赖于循环变量的变量,选择的归纳 变量可以放入寄存器,使其计算简化。这些代码重写包括从循环中删除常量计算 (称为代码移 动(code motion))。实际上,重新安排代码也有利于提高基本块中的代码效率。通常,循环优化 的额外任务是区分程序中的循环,然后就可以进行优化了。因为缺乏结构化控制和使用 goto实 现循环,这种循环发现(loop discovery)是必要的。虽然循环发现在少数语言 (如FORTRAN)中需 要,但在大部分语言中,语法本身可以用来定位循环结构。 8.9.3 优化的数据结构和实现技术 一些优化可以在语法树的变换上实现。这些包括常数合并和不可到达代码消除,通过删除 或以简单形式替换相应的子树来实现。用于后 来优化的信息也可以在构造和遍历语法树时收 集,比如引用次数和其他有用信息,保存到树 的属性或符号表项中。 对于前面提到的一些优化,语法树不广泛 或结构不适合于收集信息并进行优化。作为代 替执行全程优化的优化器使用从中间代码构造 的过程图形表示,称为流图 (flow graph)。流图 的节点为基本块,边则是来自条件或非条件转 移(目的是作为其他基本块的开始 )。每个基本块 节点包含块的中间代码序列。例如,图 8-4给出 了对应程序清单8-2中间代码的流图 (图中基本块 标记为了以后引用 )。 流图以及每个基本块都可以从中间代码一次 遍历中构造完成。每个新的基本块识别如下 : 1) 第1条指令开始一个新基本块。 2) 每个转移目的标签开始一个新基本块。 3) 每条跟随在转移之后的指令开始一个新 基本块。 可以为还没达到的向前转移构造新的空节点, 图8-4 程序清单8-2中间代码的流图 并插入到符号表的标号名下,以备标号到达时查找。 流图是数据流分析的主要数据结构,积累信息用于优化。不同的信息要求对流图作不同处 理,而且收集的信息各不相同,对应于不同的优化要求。因为没有足够的空间在这个概览中描 述数据流分析技术的细节 (参见本章尾部“注意与参考”小节 ),描述这种过程可以处理的数据 的例子将是很有意义的。 计算所有变量的可达定义(reaching definition)集是一个标准的数据流分析问题,变量在每个 基本块的开始处。这里一个定义是一条中间代码指令,可以设置变量值,比如赋值或读入 。例 这个标准允许过程调用包含在基本块中。由于调用不对流图增加新路径,所以这是可行的。然后,当基本块 分别处理时,调用可以在需要时分离出来特别处理。 请不要与C定义混浠,这是一种声明。 363 第 8章 代 码 生 成 如,图8-4中定义的变量 fact是基本块B2(fact=1)中的一条指令以及块 B3(fact=t2)中的第3 条指令。让我们调用定义 d1和d2。如果在块开始处变量保持定义时建立的值,则这个定义称为 到达(reach)基本块。图8-4的流图中,可以建立fact的定义到达B1或B2,d1和d2到达B3以及只 有d2到达B4和B5。到达定义可以用于许多优化—常数传播。例如,如果到达块唯一定义为单个 常数值,那么这个变量可以用这个值来代替(至少在块中另一个定义到达前)。 流图很适用于表示有关每个过程的全程信息,不过基本块仍旧用简单代码序列表示。一旦 执行数据流分析,每个基本块的代码也产生了,另一个数据结构经常被构造出来,称为基本块 的DAG (DAG of a basic block)(DAG = directed acyclic graph直接非循环图) (DAG 可以在没有构 造流图时为每个基本块构造 )。 DAG数据结构跟踪基本块中值和变量的计算和赋给。块中使用的来自别处的值表示为叶子 节点。其上的操作和其他值表示为内部节点。赋给新值通过把目标变量或临时变量的名字附加 到表示赋值的节点上来表示(416页描述了这种结构的一个特例 ) 。 例如,图 8-4中基本块 B3可以用图 8-5中的DAG表示(基本块开头和尾部的转移的标号通常 包含在 DAG中)。注意在这个 DAG中拷贝操作 如fact=t2和x=t3不创建新节点,而只是简 单地用标号 t2和t3为节点加上新标签。还要 注意标为 x的叶子节点有两上父节点,其原因 在于外来值 x用于两条分别的指令中。这样重 复使用同一个值也在 DAG结构中表示出来了。 DAG的这个特点允许它表示公共子表达式的重 复使用。例如C赋值语句: 图8-5 图8-18中基本块B3 的DAG x = (x+1)*(x+1) 翻译成三地址指令 t1 = x + 1 t2 = x + 1 t3 = t1 * t2 x = t3 图8-6给出这个指令序列的 DAG,显示了表达式 x+1 的重复使用。 基本块的DAG可以通过维护两个目录来构造。第 1个 是包含变量名和常数的表,带有可返回当前赋值变量的 DAG节点的查找操作 (符号表可以用作这个表 )。第2个是 DAG节点表,带有给出操作和子节点的查找功能,返回操 作和孩子节点,若没有则返回空。这个操作允许查找已存 在的值,不用在 DAG中构造新节点。例如,一旦图 8-6中 的+节点及其子节点 x和1被构造并分配名字 t1(作为三地 址指令t1=x+1处理的结果 )。在第2个表中查找(+、x、1 将返回这个已构造的节点,三地址指令 t2=x+1只是使t2 图8-6 与C赋给x = (x+1)*(x+1) 相对应的三地址指令的DAG 这个DAG结构的描述适合使用三地址指令作为中间代码,不过类似的 DAG可以定义用于 P-代码或目标汇编 代码。 364 编译原理及实践 也被赋给这个节点。这个构造的细节可以在别处找到 (参见“注意与参考”部分 )。 目标代码或修订过的中间代码,可以通以一种可能的拓朴顺序遍历 DAG的非叶子节点来产 生(DAG的拓扑顺序是一种遍历,节点的孩子先于双亲被访问 )。因为有多种拓朴顺序,可以从 DAG中产生许多不同代码序列。哪一种更好依赖于多种因素,包括目标机器结构的细节。例如, 图8-5中3个非叶子节点的一个正规遍历序列将产生下面的三地址指令。这将代替原始的基本块: t3 = x - 1 t2 = fact * x x = t3 t4 = x == 0 fact = t2 当然,我们会希望避免使用临时变量,于是要产生下面的等价三地址码,其顺序必须固定: fact = fact * x x=x-1 t4 = x == 0 图8-6中的DAG的一个类似遍历产生下示修正三地址码 t1 = x + 1 x = t1 * t1 使用DAG的基本块产生目标代码,自动得到局部公共子表达式的消除。 DAG表示法也使 消除冗余存储(赋值)成为可能并告诉我们对每个变量的引用次数 (节点的父节点数表示引用数 )。 这为合理分配寄存器提供了有用的信息 (例如,假设一个值有多个引用,则放入寄存器;如果 看到了所有引用,这个值已消亡不必再维护了等等 )。 最后一个常用于辅助寄存器分配作为代码生成中数据维护的方法称为寄存器描述器 (register descriptor)和地址描述器(address descriptor)。寄存器描述器为每个寄存器建立一个列 表,表中列出当前值在寄存器中的变量名 (当然,在那一点它们有同样的值 )。地址描述器则为 每个变量与内存地址建立联系。这些可以是寄存器 (这种情况下,变量可以在相应的寄存器描 述器中找到)或内存或两者都有(如果变量刚从内存中装入寄存器,但值尚未改变 )。这种描述器 允许跟踪值在内存和寄存器之间的移动,并可以重用已装入寄存器的值,还有回收寄存器,不 管是不再包含后续使用变量的值或将值存入到适当内存位置中 (溢出操作)。 例如,图 8-5中的基本块 DAG,并考虑,使用 3个寄存器0、1和2对应从左到右遍历内部节 点产生TM代码。假设有4个地址描述: inRey(reg-no)、isGlobal(global-offset)、 isTemp(temp-offset)和isConst(value)(这对应于TM机上的TINY运行时刻环境,见 前面章节的讨论 )。进一步假设 x在全程位置0,fact在全程位置1,全程位置通过 gp寄存器访 问,临时变量位置则通过 mp寄存器访问。最后,假设所有寄存器中都无初始值,这样在基本 块代码产生开始之前,变量和常量的地址描述器如下所示: 变量/常量 fact x t2 t3 t4 1 0 地址描述器 isGlobal(1) isGlobal(0) isConst(1) isConst(0) 365 第 8章 代 码 生 成 寄存器描述器表为空,就不再列出了。 现在假设产生了如下代码: LD 0,1(gp) load fact into reg 0 LD 1,0(gp) load x into reg 1 MUL 0,0,1 那么地址描述器就变成 变量/常量 fact x t2 t3 t4 1 0 寄存器描述器则为 地址描述器 inReg(0) isGlobal(0),inReg(1) intReg(0) isConst(1) isConst(0) 寄存器 0 1 2 包含的变量 fact, t2 x - 现在给出后续代码 LDC 2,1(0) load constant 1 into reg 2 ADD 1,1,2 地址描述器变成: 变量/常量 fact x t2 t3 t4 1 0 寄存器描述器变成: 地址描述器 inReg(0) inReg(1) inReg(0) inReg(1) isConst(1),intReg(2) isConst(0) 寄存器 0 1 2 变量/常量 fact, t2 x, t3 1 我们把计算 DAG中剩余节点值的代码以及描述结果地址和寄存器描述器留给读者完成。 我们的代码优化技术概览到为止。 366 编译原理及实践 8.10 TINY代码生成器的简单优化 8.8节中给出的TINY语言代码生成器产生的代码效率很低。这可以从程序清单 8-14的42条 指令与程序清单 8-13中9条手写的等价程序指令的比较中看出来。基本上,低效率有两个来源: 1) TINY 代码生成器对TM机的寄存器的运用较差(实际上,从来不使用寄存器 2、3或4)。 2) TINY代码生成器为测试产生不必要的逻辑值 0和1,由于这些测试只出现在 if语句和 while语句中,简单代码即可实现。 本节希望指出相对粗糙的技术如何实质地提高 TINY编译器产生的代码的性能。实际上, 不必生成基本块或流图而是直接从语法树产生代码。唯一需要的机制是附加的属性数据和一些 稍显复杂的编译代码。我们不给出此处描述的改进的实现细节,还是留给读者练习。 8.10.1 将临时变量放入寄存器 我们首先描述的优化是一个简单方法,把临时变量保存在寄存器中,而不是经常操作内存 读出和写入。在 TINY代码生成器中临量变量总是存在位置: tmpOffset(mp) 这里tmpOffset是初始的静态值,每次临时变量存储后递减,而每次读出后则递增 (见附录B, 第2033行和第2027行)。将寄存器作为临时变量存储位置的一个简单方法是把 tmpOffset翻译 成初始指向寄存器,只在可用寄存器用完时使用实际的内存偏移。例如,假设我们要将所有可 用寄存器用于临时变量 (除pc、gp和mp),那么 tmpOffsef值0~4将翻译成寄存器 0~4的引用, 当值从-5开始时就使用偏移 (在值上加上5)。这种机制可以直接应用于代码生成器的相应测试, 可封装进辅助过程 (将命名为 saveTmp和loadTmp)。还要注意,在产生递归代码后子表达式 的计算结果应保存除 0外的其他寄存器中。 有了这些改进,TINY代码生成器产生如程序清单 8-15所示的TM代码序列(请与程序清单814比较)。这个代码缩短 20%并没有临时变量的存储 (也没有使用寄存器 5,即mp)。寄存器2、3 和4仍没有使用。这并不奇怪:程序中的表达式很少复杂到同时需要两个或 3个临时变量。 程序清单 8-15 临时变量保存在寄存器中的示例 TINY程序的TM代码 367 第 8章 代 码 生 成 8.10.2 在寄存器中保存变量 进一步的改进可以将 TM的一些寄存器用于变量存储。这比前面的优化所做的工作要多, 因为变量位置必须在代码生成和存储符号表之前确定。一个基本方案是简单选取几个寄存器存 放程序中最常用的几个变量。为了确定哪些变量“最常用”,必须给出引用次数 (使用和赋值 )。 在循环中引用的变量 (在循环体或测试表达式中 )应优先考虑,因为引用在循环执行时将重复进 行。在许多现在编译器中工作出色的一个简单方法是将所有循环内引用都乘以 10,在两层嵌套 循环中乘 100,如此类推。引用计数可以在语法分析时完成。之后作出分别的变量传递,符号 表中储存的位置属性必须能表明那些定位于寄存器的变量与定位于内存的变量的差别。一个简 单的方案使用枚举类型指示变量位置:本例中,只有两种可能 inReg和inMem。另外,第 1种 情况要记录寄存器号,第 2种情况要记录内存地址 (这是变量地址描述器的一个简单例子:不需 要寄存描述器,因为它们在代码生成过程中保持不变 )。 有了这些改变,示例程序的代码将使用寄存器保存变量 x,寄存器 4保存fact (这里只有 两个变量,所以可以都放入寄存器 )。假设寄存器 0到2仍保留给临时变量使用。对程序清单 82代码的修改给出在程序清单 8-16中。这段代码又比前面代码缩短许多,不过仍比手写的代 码长。 程序清单8-16 临时变量和变量保存在寄存器中的示例 TINY程序的TM代码 8.10.3 优化测试表达式 我们讨论的最后一个优化是简化生成的 if语句和while语句代码。因为这些表达式产生的代 码很通用,布尔值真和假应用为 0和1,尽管TINY没有布尔变量且不需要这种通用级别。这还 导致了额外的装入常量 0和1,以及由 genStmt 代码独立产生用于控制语句的额外测试。 此处描述的改进依赖于比较操作符必须为测试表达式的根节点。这个操作符的 genExp代 码只是简单地产生代码将左操作数减去右操作数,把结果放入寄存器 0。if语句或while语句的 代码将检查使用了哪个比较算符并产生相应的条件转移代码。 这样,程序清单8-16中TINY代码 i f 0 < x t h e…n 现在对应于 TM代码 368 编译原理及实践 4: SUB 0,0,3 5: JLT 0,2(7) 6: LDC 0,0(0) 7: LDA 7,1(7) 8: LDC 0,1(0) 9: JEQ 0,15(7) 将由简单的 TM 代码代替 4: SUB 0,0,3 5: JGE 0,10(7) (注意:假情况转移必须补充条件 JGE到测试算符 < 中)。 有了这个优化,为测试程序生成的代码变成了程序清单 8-17所示(我们还在这一步骤中包 括了去除代码尾部的空转移,对应于 TINY代码中无else部分的if语句。这只要在genStmt中增 加对 if语句的简单检查即可 )。 程序清单 8-17 的代码相对接近手写代码了。即使如此,还有一些特别情况可以优化,这将 放在练习中。 程序清单 8-17 变量与临时变量放入寄存器并简化表达式测试的示例 TINY程序的TM代码 练习 8.1 为Lex表示法中的C注释写出一个正则表达式 (提示:参见2.2.3节中的讨论)。给出对应 下面算术表达式的三地址指令序列: a. 2+3+4+5 b. 2+(3+(4+5)) c. a*b+a*b*c 8.2 给出对应前一个练习中算术表达式的 P-代码序列。 8.3 给出对应下列C表达式的P-代码指令: a. (x = y = 2)*(x=4) b. a(a)i=b(i=n) c. p->next->next=p->next (假设相应的结构定义。 ) 8.4 给出前一练习中表达式的三地址指令。 8.5 给出对应下列TINY程序的(a)三地址码或(b)P-代码 { Gcd program in TINY language } read u; 369 第 8章 代 码 生 成 read v; { input two integer } if v = 0 then v := 0 { do nothing } else repeat temp : = v; v := u - u/v*v; { computes u mod v } u := temp until v = 0 end; write u { output gcd of original v & v } 8.6 参照程序清单8-4的四元式给出程序清单8-5三元式的C数据结构定义。 8.7 扩展表8-1P-代码的属性文法 (8.2.1节)成 a 8.3.2节的子描述语法;b8.4.4节的控制结构 语法。 8.8 对表8-2的三地址码属性文法重复上面练习。 8.9 描述代码生成如何采用6.5.2节的普通遍历过程,这是否有意义 ? 8.10 增加地址操作符&和* (用C语法)以及二元结构域选择操作符 .到 a. 8.2.1节的表达式语法。 b. 8.2.2节的语法树结构。 8.11 a. 为8.4.4节的控制语法添加repeat-until(或do-while语句),并画出对应图8-2的合适控 制图。 b. 为语法(8.4.4节)重写语法树结构定义以包含 a部分的新结构。 8.12 a. 描述如何系统地将for语句转换成对应的 while语句,用于产生代码是否可行? b. 描述如何系统地将 case或switch语句转换嵌套的if语句,用于产生代码是否可行 ? 8.13 a. 参照图8-2的Borland 80×86 C编译器显示的循环结构画出控制图。 b. 参照图8-2为8.6.2节中Sun SparcStatin C编译器显示的循环结构画出控制图。 c. 假设一个条件转移执行时间 3倍于代码“穿过” (比如条件为假)。那么a和b部分的 转移组织是否比图 8-2有时间优势? 8.14 一种代替case或switch语句为每个case顺序测试的实现称为转移表 (jump table),其中 c a s e索引被用于索引转移的偏移对应到绝对转移。 a. 这种实现方法只有在大量不同 case在相对较小的索引范围内密集发生时才有优势, 为什么? b. 代码生成器只是在超过 10个case时才产生这种代码。确定你的 C编译器是否有一个 最小值决定是否产生 switch语句的转移表。 8.15 a. 开发类似于8.3.2节的多维数组元素地址计算公式。说明你的所有假设。 b. 假设有如下用C代码定义的数组变量 a int a[12][100][5] 假设一个整数在内存中占两个字节。用你在 a部分中的公式确定下面变量相对 a基址 的偏移 a[5][42][2] 8.16 参照8.5.2节的函数定义/调用语法给出下面程序: fn f(x)=x+1 fn g(x,y)=x+y 370 编译原理及实践 g f(x(3),4+5) a. 写出程序清单的genCode过程为该程序产生的P-代码指令序列。 b. 写出此程序的三地址指令代码。 8.17 文中没有指出 arg三地址指令在函数调用中是否使用:有些版本的三地址码要求所 有arg语句混合出现(参见8.5.1节)。讨论这两种方法的利弊。 8.18 a. 列出本章所用的全部P-代码指令,以及其意义和使用的描述。 b. 列出本章所用全部三地址指令,以及意义和使用的描述。 8.19 写出练习8.5中TINY gcd 程序的等价TM程序: 8.20 a. TM没有寄存器到寄存器移动指令,说明这是如何实现的。 b. TM没有调用和返回指令,说明如何模拟实现。 8.21 为TM设计一个浮点协处理器,可以在不改变现存寄存器和内存定义的情况下使用 (参见附录 C)。 8.22 写出TINY编译器为下列TINY表达式和赋值产生的 TM指令序列: a. 2+3+4+5 b. 2+(3+(4+5)) c. x:= x+(y+2*z),假设x,y和z分别在dMem位置0、1和2。 d. v := u - u/v*v; (来自练习8.5TINY gcd程序中的一行;假设标准的TINY运行时环境)。 8.23 为8.5.2节的函数调用设计TM运行时环境。 8.24 Borland 3.0编译器产生如下 80×86代码来计算x >= == != = ; , ( ) [ ] { } /* */ 3. 其他标记是ID和NUM,通过下列正则表达式定义: ID = letter letter* NUM = digit digit* letter = a|..|z|A|..|Z digit = 0|..|9 小写和大写字母是有区别的。 4. 空格由空白、换行符和制表符组成。空格通常被忽略,除了它必须分开 ID 、NUM 关 键字。 5. 注释用通常的 C语言符号 /*...*/围起来。注释可以放在任何空白出现的位置 (即注释 不能放在标记内)上,且可以超过一行。注释不能嵌套。 A.2 C-的语法和语义 C-的BNF语法如下: 1. program → declaration-list 2. declaration-list → declaration-list declaration | declaration 3. declaration → var-declaration | fun-declaration 374 编译原理及实践 4. var-declaration → type-specifier ID; | type-specifier ID [ NUM ]; 5. type-specifier → int | void 6. fun-declaration → type-specifier ID ( params ) | compound-stmt 7. params → params-list | void 8. param-list → param-list , param | param 9. param → type-specifier ID | type-specifier ID [ ] 10. compound-stmt → { local-declarations statement-list } 11. local-declarations → local-declarations var-declaration | empty 12. statement-list → statement-list statement | empty 13. statement → expression-stmt | compound-stmt | selection-stmt | iteration-stmt | return-stmt 14. expression-stmt → expression ; | ; 15. selection-stmt → if ( expression ) statement | if ( expression ) statement else statement 16. iteration -stmt → while ( expression ) statement 17. return -stmt → return ;| return expression; 18. expression → var = expression | simple-expression 19. var → ID | ID [ expression ] 20. simple-expression → additive-expression relop additive-expression | additive -expression 21. relop →<= | < | > | >= | == | != 22. additive-expression → additive-expression addop term | term 23. addop →+ | 24. term → term mulop factor | factor 25. mulop →* | / 26. factor → ( expression ) | var | call | NUM 27. call → ID ( args ) 28. args → arg-list | empty 29. arg-list → arg-list , expression | expression 对以上每条文法规则,给出了相关语义的简短解释。 1. program → declaration-list 2. declaration-list → declaration-list declaration | declaration 3. declaration → var-declaration | fun-declaration 程序由声明的列表(或序列)组成,声明可以是函数或变量声明,顺序是任意的。至少必须有一个 声明。接下来是语义限制(这些在C中不会出现)。所有的变量和函数在使用前必须声明(这避免了 向后backpatching引用)。程序中最后的声明必须是一个函数声明,名字为main。注意,C-缺乏 原型,因此声明和定义之间没有区别(像C一样)。 4. var-declaration → type-specifier ID ; | type-specifier ID [ NUM ]; 5. type-specifier → int | void 变量声明或者声明了简单的整数类型变量,或者是基类型为整数的数组变量,索引范围从 0到 NUM -1。注意,在C-中仅有的基本类型是整型和空类型。在一个变量声明中,只能使用类型 375 附录A 编译器设计方案 指示符 int。void用于函数声明 (参见下面 )。也要注意,每个声明只能声明一个变量。 6. fun-declaration → type-specifier ID ( params )compound-stmt 7. params → param-list | void 8. param-list → param-list , param | param 9. param → type-specifier ID | type-specifier ID [ ] 函数声明由返回类型指示符、标识符以及在圆括号内的用逗号分开的参数列表组成,后面 跟着一个复合语句,是函数的代码。如果函数的返回类型是 void,那么函数不返回任何值 (即 是一个过程 )。函数的参数可以是 void (即没有参数),或者一列描述函数的参数。参数后面跟 着方括号是数组参数,其大小是可变的。简单的整型参数由值传递。数组参数由引用来传递 (也就是指针 ),在调用时必须通过数组变量来匹配。注意,类型“函数”没有参数。一个函数 参数的作用域等于函数声明的复合语句,函数的每次请求都有一个独立的参数集。函数可以是 递归的(对于使用声明允许的范围 )。 10. compound-stmt → { local-declarations statement-list } 复合语句由用花括号围起来的一组声明和语句组成。复合语句通过用给定的顺序执行语句 序列来执行。局部声明的作用域等于复合语句的语句列表,并代替任何全局声明。 11. local-declarations → local-declarations var-declaration | empty 12. statement-list → statement-list statement | empty 注意声明和语句列表都可以是空的 (非终结符empty表示空字符串,有时写作 。) 13. statement → expression-stmt | compound-stmt | selection-stmt | iteration-stmt | return-stmt 14. expression-stmt → expression; |; 表达式语句有一个可选的且后面跟着分号的表达式。这样的表达式通常求出它们一方的结 果。因此,这个语句用于赋值和函数调用。 15. selection-stmt → if (expression) statement | if (expression) statement else statement if语句有通常的语义:表达式进行计算;非 0值引起第一条语句的执行; 0值引起第二条语 句的执行,如果它存在的话。这个规则导致了典型的悬挂 else二义性,可以用一种标准的方法 解决:else部分通常作为当前if的一个子结构立即分析(“最近嵌套”非二义性规则)。 16. iteration-stmt → while (expression) statement while语句是 C-中唯一的重复语句。它重复执行表达式,并且如果表达式的求值为非 0, 则执行语句,当表达式的值为 0时结束。 17. return -stmt → return ;| return expression; 返回语句可以返回一个值也可无值返回。函数没有说明为 void就必须返回一个值。函数 声明为void就没有返回值。return引起控制返回调用者(如果它在main中,则程序结束)。 18. expression → var = expression | simple-expression 19. var → ID | ID [expression] 表达式是一个变量引用,后面跟着赋值符号 (等号)和一个表达式,或者就是一个简单的表 达式。赋值有通常的存储语义:找到由 var表示的变量的地址,然后由赋值符右边的子表达式 376 编译原理及实践 进行求值,子表达式的值存储到给定的地址。这个值也作为整个表达式的值返回。 var是简单 的(整型)变量或下标数组变量。负的下标将引起程序停止 (与C不同)。然而,不进行下标越界 检查。 var表示C-比C的进一步限制。在C中赋值的目标必须是左值 (l-value),左值是可以由许多 操作获得的地址。在 C-中唯一的左值是由 var语法给定的,因此这个种类按照句法进行检查, 代替像C中那样的类型检查。故在 C-中指针运算是禁止的。 20. simple-expression → additive-expression relop additive-expression | additive -expression 21. relop → <= | < | > | >= | == |!= 简单表达式由无结合的关系操作符组成 (即无括号的表达式仅有一个关系操作符 )。简单表 达式在它不包含关系操作符时,其值是加法表达式的值,或者如果关系算式求值为 ture,其值 为1,求值为false时值为0。 22. additive-expression → additive-expression addop term | term 23. addop → + | 24. term → term mulop factor | factor 25. mulop → * | / 加法表达式和项表示了算术操作符的结合性和优先级。符号表示整数除;即任何余数都被 截去。 26. factor → (expression) | var | call | NUM 因子是围在括号内的表达式;或一个变量,求出其变量的值;或者一个函数调用,求出函 数的返回值;或者一个 NUM,其值由扫描器计算。数组变量必须是下标变量,除非表达式由单 个ID 组成,并且以数组为参数在函数调用中使用 (如下所示)。 27. call → ID ( args ) 28. args → arg-list | empty 29. arg-list → arg-list , expression | expression 函数调用的组成是一个ID (函数名),后面是用括号围起来的参数。参数或者为空,或者由 逗号分割的表达式列表组成,表示在一次调用期间分配的参数的值。函数在调用之前必须声明, 声明中参数的数目必须等于调用中参数的数目。函数声明中的数组参数必须和一个表达式匹配, 这个表达式由一个标识符组成表示一个数组变量。 最后,上面的规则没有给出输入和输出语句。在 C-的定义中必须包含这样的函数,因为 与C不同,C-没有独立的编译和链接工具;因此,考虑两个在全局环境中预定义的函数,好 像它们已进行了声明: int input(void) {...} void output(int x) {...} input函数没有参数,从标准输入设备 (通常是键盘 )返回一个整数值。 output函数接受 一个整型参数,其值和一个换行符一起打印到标准输出设备 (通常是屏幕)。 A.3 C-的程序例子 下面的程序输入两个整数,计算并打印出它们的最大公因子。 /* A program to perform Euclid's Algorithm to compute gcd. */ 377 附录A 编译器设计方案 int gcd (int u, int v) { if (v == 0) return u ; else return gcd(v,u-u/v*v); /* u-u/v*v == u mod v */ } void main(void) { int x; int y; x = input(); y = input(); output(gcd(x,y)); } 下面的程序输入10个整数的列表,对它们进行选择排序,然后再输出: /* A program to perform selection sort on a 10 element array. */ int x[10]; int minloc ( int a[], int low, int high ) { int i; int x; int k; k = low; x = a[low]; i = low + 1; while (i < high) { if (a[i] < x) { x = a[i]; k = i; } i = i + 1; } return k; } void sort ( int a[], int low, int high ) { int i; int k; i = low; while (i < high-1) { int t; k = minloc (a,i,high); t =a[k]; a[k] = a[i]; a[i] = t; i = i + 1; } } void main (void) { int i; i = 0; while (i < 10) { x[i] = input; i = i + 1; 378 编译原理及实践 sort (x,0,10); i = 0; while (i < 10) { output(x[i]); i = i + 1; } A.4 C-语言的Tiny Machine运行时环境 下面的描述采用了 8.7节给出的 Tiny Machine知识和第7章基于栈的运行时环境的知识。因 为C-(与TINY不同)有递归过程,运行时环境必须是基于栈的。环境的组成部分有在 dMem顶 部的全局区和在它下面的栈,朝下向 0增长。因为 C-不包含指针或动态分配,因此就不需要 堆(heap)。在C-中每个活动记录 (或栈结构)的组成如下 fp 指向这里 这里,fp是当前结构指针 (current frame pointer),为便于访问保存在一个寄存器中。 ofp(旧 结构指针 )是正文第 7章中讨论的控制链 (control link)。在FO(结构偏移 )右端的常数是每个存储 的指示值的偏移量。值 initFO是在一个活动记录中存储区开始的参数和变量的偏移量。因为 Tiny Machine不包含栈指针,对活动记录中所有字段的引用都使用带负结构偏移的 fp。 例如,如果有下列 C-函数声明: int f(int x, int y) { int z; ... } 那么x、y和z必须在当前结构中分配, f程序体代码产生的结构起始偏移量是- 5(x、y和z各占 一个地址,活动记录的簿记信息占两个地址 )。x、y和z的偏移分别是-2、-3和-4。 在存储器中全局引用可以用绝对地址找到。然而,像 TINY一样,我们更愿意从一个寄存 器的偏移量引用这些变量。通过保存一个固定的寄存器实现这一点,称作 gp,它总是指向最大 的地址。因为 TM模拟器在执行开始之前把这个地址存储到地址 0,启动时gp可以从地址0装入, 下面是初始化运行时环境的标准开始序列: 0: LD gp, 1: LDA fp, 2: ST ac, 0(ac) 0(gp) 0(ac) * load gp with maxaddress * copy gp to fp * clear location 0 函数调用也要求在一个调用序列中使用函数体的开始代码地址。我们也希望使用 pc的当前 值执行相对转移来调用函数而不是直接转移 (这将使代码潜在地可重定位 )。程序 code.h/ 379 附录A 编译器设计方案 code.c中的实用过程 emitRAbs可以用于这个目的 (它接受绝对代码地址,并通过使用当前的 代码产生地址使其相对化 )。 例如,假设要调用一个函数,其代码起始地址是 27,当前的地址是42。那么代替产生绝对 转移 42: LDC pc, 27(*) 我们将产生 42: LDA pc, -16(pc) 这是因为27 - (42 + 1) = -16。 1) 调用序列 调用者和被调用者之间的合理划分是:使调用者除了在 retFO地址存储返回 指针外,还在新的结构中存储参数的值并创建新的结构。代替存储返回指针本身,调用者把它 留在ac寄存器中,被调用者把它存储进新的结构。因此,每个函数体必须从在 (现在当前的)结 构中存储值的代码开始: ST ac, retFO(fp) 这在每个调用点保存一条指令。在返回时,每个函数通过执行指令 LD pc, retFO(fp) 用这个返回地址装入 pc。相应地,调用者逐个计算参数,在新结构压栈之前把它们压进栈中相 应的位置。调用者也必须先把当前的 fp保存进结构的ofpFO处。从被调用者返回后,通过把旧 的fp装入fp,调用者丢弃新结构。因此,对有两个参数的函数的调用将产生下列代码: ST ac, frameoffset+initFO (fp) ST ac, frameoffset+initFO-1 (fp) ST fp, frameoffset+ofpFO (fp) * store current fp LDA fp, frameoffset(fp) * push new frame LDA ac,1(pc) * save return in ac LDA pc, ...(pc) * relative jump to fuction entry LD fp, ofpFO(fp) * pop current frame 2) 地址计算 因为变量和下标数组都允许出现在赋值表达式的左边,所以在编译期间必须 区分地址和值。例如,在语句 a[i] := a[i+1]; 中,表达式 a[i]指的是a[i]的地址,而表达式a[i+1]指的是a在地址i+1处的值。这个区分 可以对 cGen过程使用一个 isAddress参数来实现。当这个参数为真时, cGen产生的代码计 算变量的地址,而不是值。对于简单变量的情况,这意味着加上 gp(全局变量 )或fp(局部变量 ) 的偏移量并把结果装入到 ac: LDA ac, offset(fp) ** put address of local var in ac 对于数组变量的情况,这意味着加上相对于数组基地址的索引值,并把结果装入到 ac,如 下所述。 3) 数组 在栈中数组的分配从当前结构偏移量开始,按下标增长的顺序在存储器中向下延 伸,如下所示: 380 编译原理及实践 数组A的基本地址 栈中数组A的元素的分配空间 注意,数组的地址通过从基地址中减去索引值计算。 当一个数组传递给函数时,仅传递基地址。基元素区域的分配只进行一次,并在数组生存 期间保持固定。函数参数不包括数组的实际元素,仅仅是地址。因此,数组参数是引用参数。 当数组参数在函数内部引用时这将引起异常,因为在存储器中保存的必须看成是它们的基地址 而不是值。因此,数组参数计算基地址时使用 LD操作代替LDA。 A.5 使用C-和TM的编程设计 基于本书中讨论的 TINY编译器(其清单在附录B中),对于一个学期编译课程来说,要求把 一个 C-语言的完整的编译器作为设计不是没有道理。这可以进行一些调整,当研究了相关的 理论后实现编译器的每个阶段。另一方面, C-编译器的一个或多个部分可以由导师提供,要 求学生完成剩余的部分。当时间较短 (如1/4学年)或者学生要产生“实际”机器的汇编代码,如 Sparc或PC(在代码生成阶段要求更多的细节 ),这就特别有用。对于仅实现 C-编译器的一部分 这就不怎么有用,因为各部分之间的相互作用和代码测试的能力被限制了。下列分列的任务清 单提供了一种安排,要注意每个任务与其他任务都不是独立的,最好完成所有的任务以获得完 整的编写编译器的经验。 设计 1. 实现适合于C-的一个符号表。要求表结构结合作用域信息,用于当各个独立的表链接 到一起,或者有一个删除机制,用基于栈的方式操作,如第 6章所述。 2. 实现一个C-扫描器,或者像 DFA用手工进行,或者使用Lex,如第2章所述。 3. 设计一个C-语法树结构,适合于用分析器产生。 4. 实现一个C-分析器(这要求一个C-扫描器),或者使用递归下降用手工进行,或者使用 Yacc,如第4、5章所述。分析器要产生合适的语法树 (见设计3)。 5. 实现C-的语义分析器。分析器的主要要求是,除了在符号表中收集信息外,在使用变 量和函数时完成类型检查。因为没有指针或结构,并且仅有的基本类型是整型,类型检 查器需要处理的类型是空类型、整型、数组和函数。 6. 实现C-的代码产生器,其根据是前一节描述的运行时环境。

Top_arrow
回到顶部
EEWORLD下载中心所有资源均来自网友分享,如有侵权,请发送举报邮件到客服邮箱bbs_service@eeworld.com.cn 或通过站内短信息或QQ:273568022联系管理员 高进,我们会尽快处理。