北邮 编译原理(上)

文章目录

第一章 编译概述


Q_1:先解决一个困扰大多数初学者的问题:编译原理和汇编语言的区别?参考编译和汇编的区别是什么?_百度知道 (baidu.com)

  • 编译: 检查语法,生成汇编代码

  • 汇编: 将汇编代码转换为机器码

在很多地方,都会把编译器的作用给放大,也就是说编译器能够直接编译并汇编语言得到机器语言,参考C语言 - Tintoki_blog (gintoki-jpg.github.io)-编译器简介;

而在有些地方编译被定义为–将高级语言翻译成汇编语言或机器语言,从这个定义来说上面的两种说法都没错,本门课程介绍的编译指的是将高级语言翻译成汇编语言,在汇编原理课程中主要讲解将汇编语言翻译为机器语言;

Q_2:编译原理这门课和形式语言与自动机紧密相关,两门课程的学习方式也基本差不多。如果只是应付期末考试,只需要最后刷刷课后题、掌握老师划出的考点就行。但是如果想要深入理解这门课程并且期望学完这门课程之后能够手动实现一个编译器或语法分析器的小项目,那还是需要在这门课程上面花时间的。

参考教材是李文生编著的《编译原理与技术 第2版 清华大学出版社》,目前没有特别好的视频课推荐(国防科大的视频课讲的比较浅,可以作为课前预习),学习方式就是结合刷题理解概念,这门课不动笔就想学会几乎不可能。

最后给一个考点汇总(可以作为期末复习的参考,有用的话希望能给个点赞~)
在这里插入图片描述

Q_3:学习资料参考:


1.翻译和解释

1.1 程序设计语言

机器语言->符号语言->汇编语言->高级语言

面向用户的、面向问题的以及面向对象的语言等统称为高级语言,机器语言和汇编语言称为低级语言。相对于低级语言,高级语言具有以下优点:

(1)更接近于自然语言、独立于机器。
程序设计人员不必了解计算机的硬件,对计算机了解甚少的用户也可以学习和使用。
一条高级语言的语句对应多条汇编指令或机器指令,编程效率高,所编程序可读性好,便于交流和维护,并且具有较好的移植性。

(2)运行环境透明性。
程序员在编写程序时,不必对程序中出现的变量和常量分配具体的存储单元,不必了解如何将数据的外部表示形式转换成机器的内部表示形式等细节,也不必了解程序运行环境是如何建立和维护的,所有这些工作都由“编译程序”完成。

(3)具有丰富的数据结构和控制结构,编程效率高。
高级语言通常都支持数组、记录等数据结构,支持循环、分支以及过程/函数调用等控制结构。这些结构的使用改善了程序的风格,便于程序设计人员采用科学的方法(如结构化的方法、面向对象的方法)来开发程序,从而提高程序的规范性、可靠性,缩短了开发周期、降低了开发费用。

1.2 翻译程序

通常将源程序翻译成另外一种表示形式的翻译器/编译程序称为编译器(即编译程序),而直接执行源程序给出运行结果的翻译器/编译程序称为解释器(即解释程序);

编译程序扫描所输入的源程序,并将其转换为目标程序,编译程序可进一步划分为汇编程序和编译程序;

通常,源程序是用高级语言或汇编语言编写的:

  • 如果源语言/程序是汇编语言,目标语言是机器语言,则该编译程序称为“汇编程序” – 汇编程序将在汇编语言中介绍;

  • 如果源语言/程序为高级语言,目标语言是某种机器的机器语言或汇编语言,则该编译程序称为“编译程序” – 全文的编译原理都是围绕这种编译的定义来讨论;

PS:图中第二层的编译程序是本课程的核心(即源语言为高级语言,目标语言是某种机器的机器语言或汇编语言);

PS:在大多数资料(包括本书)很多地方对于编译程序的限定其实非常松散:

  • 有些编译程序先产生汇编语言的目标代码进而由汇编程序生成可重定位的机器代码;
  • 有的编译程序生成可重定位的机器代码;
  • 有的编译程序直接生成可执行的机器代码;

实际上无论是哪种定义,编译的基本流程都遵守同样的规则,所以我们可以一同分析,不用过多纠结究竟是哪种定义;

2.编译的阶段和任务

按照编译程序的执行过程和所完成的任务,可以把它分成前后两个阶段,即分析阶段和综合阶段:

  • 在分析阶段,编译程序根据源语言的定义检查源程序的结构是否符合语言规定,确定源程序所表示的对象和规定的操作,并将源程序以某种中间形式表示出来;

  • 在综合阶段,编译程序根据分析阶段的分析结果构造出与源程序等价的目标程序;

编译程序需要定义一个数据结构来保存在分析过程中识别出来的标识符及其有关信息,为语义分析和代码生成提供支持,该数据结构即“符号表”;

下面给出一个典型的编译过程的表示(尽管对于不同的高级语言其编译过程略有不同,但基本的框架是相同的)

2.1 分析阶段

分析阶段的任务是根据源语言的定义对源程序进行结构分析和语义分析,从而把源程序正文转换为某种中间表示形式

分析阶段对源程序的结构进行静态分析,包括词法分析、语法分析和语义分析;

2.1.1 词法分析

词法分析是一种线性分析;

词法分析程序在扫描源程序的过程中,对构成源程序的字符串进行分解:

  • 识别出每个具有独立意义的字符串(即单词lexeme),将其转换为记号(token)加以输出,所有的记号组织成记号流;
  • 将需要存放的单词(变量名、函数名、语句标号等)保存在符号表中;
    • 对于某些记号(不只是标识符)还需要增加一个“属性值”以示区别,并根据需要把标识符存入符号表

词法分析的工作依据是源语言的构词规则(即语法,也称为模式);

单词分隔符(如空格、制表符、回车换行符等)通常在词法分析时跳过,对于源程序中出现的注释,词法分析程序同样会直接跳过;

2.1.2 语法分析

语法分析是一种层次结构的分析;

语法分析根据源语言的语法结构把记号流按层次分组,以形成短语,源程序的语法短语常使用分析树表示,而语法树是分析树的浓缩表示;

语法分析的工作依据是源语言的语法规则;

2.1.3 语义分析

语义分析是对源程序的含义进行分析,以保证程序各部分能够有机地结合在一起,并为以后生成目标代码收集必要的信息(如数据对象的类型、目标地址等):

  • 语义分析的一个重要任务是类型检查 ———— 按照源语言的类型机制,检查源程序中每个语法成分的类型是否合乎要求;

语义分析的工作依据是源语言的语义规则;

以上每个分析步骤中,编译程序都把源程序变换成便于下一个步骤处理的内部表示形式;

2.2 综合阶段

综合阶段的任务是根据源语言到目标语言的对应关系,对分析阶段所产生的中间表示形式进行加工处理,从而得到与源程序等价的目标程序;

综合阶段包括中间代码生成、代码优化和目标代码生成;

2.2.1 中间代码生成

编译程序通常需要把分析阶段产生的结果进一步转换成中间代码,中间代码应当具备两个重要的特点:易于产生和易于翻译成目标代码;

中间代码所表示的操作比源程序语句所表示的操作更详细,因为这里不但要考虑在计算机上实现时用汇编指令表示的细节,还要考虑控制流、过程调用以及参数传递等各个细节;

2.2.2 代码优化

代码优化就是对代码进行改进,使之占用的空间少、运行速度快:

  • 编译程序的代码优化工作首先是在中间代码上进行的,基于优化后的中间代码可以得到更好的目标代码;

  • 其次,还可以根据目标机器的特点,对目标代码做进一步的优化;

2.2.3 目标代码生成

生成的目标代码通常是可重定位的机器代码或汇编语言代码;

为了生成目标代码,需要对源程序中使用的每个变量指定存储单元,并且把每条中间代码语句一一翻译成等价的汇编语句或机器指令;

2.3 符号表管理

编译程序的一项重要工作是收集源程序中使用的标识符,并记录每个标识符(标识符属于token的一种)的相关信息:

  • 如源程序中使用的标识符是变量名、函数名、还是形参名等;
    • 如果是变量名,它的类型是什么;
    • 如果是形参名,参数的传递方式是什么;
    • 如果是函数名,函数有几个参数,都是什么类型的,函数是否有返回值、返回值是什么类型的等;

编译程序使用符号表来记录标识符及其相关信息,符号表的结构应支持标识符的快速查找以及数据的快速存取;

符号表由若干条记录组成,每个标识符在符号表中都对应一条记录,记录了各域中保存的标识符相应的属性:

  • 标识符的各个属性值是在编译的不同阶段收集并写入符号表的;

编译程序的后续阶段通过不同的方式使用符号表中记录的信息:

  • 进行语义分析和中间代码生成时,需要根据标识符的类型来检查源程序是否以合法的方式使用它们,并为它们产生适当的操作;
  • 代码生成时,需要根据标识符的类型和存储分配信息来产生正确的目标语言指令;

2.4 错误处理

错误检测和恢复是编译程序的一项很重要的任务,在编译的每个阶段都可能检测出源程序中存在的错误;

发现错误之后应做适当的处理,使编译工作能够继续进行,以便对源程序中可能存在的其他错误进行检测。如果编译时,编译程序每发现一个错误后就停止编译,则调试程序的效率将非常低,这种情况绝不是用户所希望的;

编译过程中,发现任何错误,都应该报告给错误处理程序,以产生适当的诊断信息帮助程序员判断错误出现的准确位置,并对源程序进行适当的恢复以保持程序的一致性;

3.其他概念

3.1 前端和后端

通常可以将编译程序划分为前端(front end)和后端(back end)两部分:

  • 前端主要由与源语言有关而与目标机器无关的部分组成,通常包括词法分析、语法分析、语义分析和中间代码生成、符号表的建立、中间代码的优化,以及相应的错误处理工作和符号表操作;

  • 后端由编译程序中与目标机器有关的部分组成,这些部分与源语言无关而仅仅依赖于中间语言。后端包括目标代码的生成、目标代码的优化,以及相应的错误处理和符号表操作;

把编译程序划分成前端和后端的优点是便于编译程序的移植和构造:

  • 重写后端使得可以将该语言的编译程序移植到另一种类型的机器上;
  • 重写前端可以把一种新的程序设计语言编译成同一种中间语言;

PS:需要注意区分编译过程(分析阶段和综合阶段)和编译程序结构(前端和后端)

3.2 “遍”的概念

设计编译程序时,还需要考虑编译分“遍”(pass)的问题;一“遍”指的是对源程序源程序的中间表示形式从头到尾扫描一次,并在扫描过程中完成相应的加工处理,生成新的中间表示形式目标程序


阶段(词法分析、语法分析…)和遍的概念是不同的:

  • 一遍可以由若干阶段组成;

  • 一个阶段也可由若干遍来完成;


(1)一遍扫描的编译程序

这种编译程序对源语言程序进行一遍扫描就能完成编译的各项任务(简单理解的话就是上面介绍过的分析阶段和综合阶段的六个步骤),其典型结构如下

这种结构的编译程序的核心是语法分析程序没有中间代码的生成环节;一遍编译程序的工作过程大致如下:

(1)每当语法分析程序需要一个新的单词符号时,就调用词法分析程序。词法分析程序则从源程序中依次读入字符,并组合成单词符号,将其记号返回给语法分析程序;

(2)每当语法分析程序识别出一个语法成分时,就调用语义分析及代码生成程序对该语法成分进行语义分析(主要是类型检查),并生成目标程序;

(3)当源程序全部处理完后,转善后处理,即整理目标程序(如优化等),并结束编译;

(2)多遍扫描的编译程序

这种编译程序把编译的6个逻辑部分应该完成的工作分遍进行,每一遍完成一个或多个相连逻辑部分的工作,其结构如下

多遍编译程序的工作过程如下:

  • 编译程序的主程序调用词法分析程序,词法分析程序对源程序进行扫描,并将它转换为一种内部表示,称为中间表示形式1,同时产生有关的一些表;

  • 然后,主程序调用语法分析程序,语法分析程序以中间表示形式1作为输入,进行语法分析,产生中间表示形式2以及相关表;

  • 最后,主程序调用目标代码生成程序,该程序把输入的中间代码转换为等价的目标程序

那么一遍编译程序和多遍编译程序应该如何选择呢?这要视计算机容量的大小、源语言的繁简、目标程序质量的高低等;

分遍的好处:

  • 可以减少对主存容量的要求;
  • 可以使得编译程序结构清晰,各编译程序功能独立、单纯,相互联系简单;
  • 能够实现更充分的优化工作;
  • 通过分遍将编译程序的前端和后端分开,可以为编译程序的构造和移植创造条件;

分遍的坏处:

  • 增加不少重复性的工作(因为每遍都有符号的输入和输出,这将降低编译的效率);

4.编译程序的辅助

要将源程序转换为可执行的代码,除了需要编译程序,还必须有其他程序配合,比如我们要设计如下一个语言处理系统,除了基本的编译程序外还需要预处理器、汇编程序、连接装配程序等

第二章 形式语言与自动机

基本概念:

  • 字母表:一个有穷字符集,记为∑

  • 字母表中每个元素称为字符

  • ∑上的字(也叫字符串) 是指由∑中的字符所构成的一个有穷序列

  • 不包含任何字符的序列称为空字,记为ε

  • 用∑*表示∑上的所有字的全体,包含空字ε

其他的一些规则:

更多关于形式语言与自动机的概念参考形式语言与自动机复习笔记 - Tintoki_blog (gintoki-jpg.github.io)

第三章 词法分析

编译过程的第一步是进行词法分析(词法分析和形式语言关系非常密切),其主要任务是从左至右逐个字符地对源程序进行扫描,按照源语言的词法规则识别出一个个单词符号,产生用于语法分析的记号序列(特殊地,需要将识别出来的标识符存入符号表中,);

在词法分析过程中,还可以完成用户接口有关的一些任务,如跳过源程序中的注释和空格,把来自编译程序的错误信息和源程序联系起来,如记住单词在源程序中的行/列位置,从而行号可以作为错误信息的一部分提示给用户。有些词法分析程序可以复制源程序,并把错误信息嵌入其中;


单词符号(记号)的分类:

  • 关键字/基本字:begin,for…
  • 标识符:用来表示各种名字,如变量名、数组名和过程名;
  • 常数:各种类型的常数;
  • 运算符
  • 分界符

输出的单词符号的表示形式(单词种别,单词自身的值)(等价于(记号,属性))

1.词法分析程序和语法分析程序的关系

词法分析程序与语法分析程序之间的关系可以是3种形式之一:

  • 词法分析程序作为独立的一遍:经过这一遍的加工,可以将以字符串表示的源程序转换成以记号序列表示的源程序;

  • 词法分析程序作为语法分析程序的子程序:将词法分析程序和语法分析程序安排在同一遍中,词法分析程序作为语法分析程序的一个子程序,每当语法分析程序需要一个新的记号时就调用词法分析程序,每调用一次,词法分析程序就从源程序字符串中识别出一个具有独立意义的单词,并把相应的记号返回。这种方法不仅避免了中间文件,而且还省去了取送符号的工作,有利于提高编译程序的效率;

  • 词法分析程序与语法分析程序作为协同程序:将两个程序以协同工作的方式安排在同一遍中,以生产者和消费者的关系同步运行(将它们安排成交替执行的协同程序)

无论采取哪种方式,词法分析程序都是独立于语法分析程序的,这样的好处是:

  • 简化设计,程序结构清晰;
  • 改进编译程序的效率,利用专门的读字符和处理记号的技术加快编译速度;
  • 加强编译程序的可移植性(在词法分析程序中处理特殊的或者非标准的符号);

2.词法分析程序的输入与输出

2.1 词法分析程序的输入

根据词法分析程序的实现方法不同,其源程序的输入方法也不同,词法分析程序的实现主要有以下三种:

(1)利用词法分析程序生成器LEX,从基于正规表达式的规范说明自动生成词法分析程序。这种情况下,生成器将提供用于源程序字符串的读入和缓冲的若干子程序;

(2)利用传统的系统程序设计语言(如Pascal、C语言等)来编写词法分析程序。这种情况下,需要利用该语言所提供的输入/输出能力来处理源程序字符串的读入操作;

(3)利用汇编语言编写词法分析程序,此时需要直接管理源程序字符串的读入;


(本节主要参考编译原理总结提炼 - 简书 (jianshu.com)

最基本的词法分析器包含扫描器、预处理子程序、扫描缓冲区、输入缓冲区,其核心为扫描器,对于将一个扫描缓冲区分为左右两个大小相同的半区的,我们称为双缓冲区输入模式:

  • 输入缓冲区:源程序进入输入缓冲区;

  • 预处理程序:取消注释、剔除无用的空白、回车、换行等;

  • 扫描缓冲区:从输入缓冲区输入固定长度的字符串到另一个缓冲区(扫描缓冲区),词法分析可以直接在此缓冲区中进行符号识别;

主要概念说明:

  • 超前扫描:有时词法分析程序为了得到某一个单词符号的确切性质,只从该单词本身所含有的字符不能做出判定,需要超前扫描若干个字符之后才能做出确定的分析(如x=(y++)+z,只有超前扫描才能确定运算符“+”和“++”)
  • 双缓冲区:扫描缓冲区的大小是有限的,有可能出现一个情况,就是从输入缓冲区预处理完的串装进扫描缓冲区时候,一次没有装完,末尾的某个单词被分开了,为了解决这个问题,就需要扫描缓冲区最好使用一个如下所示的一分为二的区域,设置双缓冲区

  • 起点指针 (lexeme Begin) :用来指示正在扫描的单词的起点;

  • 搜索指针 (forward) :用于向前搜索,寻找单词的结束;

注意:假定每个半区可容N个字符,而这两个半区又是互补使用的,如果搜索指示器从单词起点出发搜索到半区边缘还没有达到单词终点,就会调用预处理程序,把后续的N个输入字符装进另一个半区,搜索指示器(搜索指针)进去那个半区再扫描就好了,这就不存在断掉的问题了,相当于是个循环链表;

还有没有可能出现意外呢?当然可能,假如某个标志符或者常数的长度超过2N了,这神仙也没办法,所以应该在长度上加以一定的限制;

2.2 词法分析程序的输出

词法分析程序将源程序字符串转换为记号序列的形式,在此之前我们介绍几个特定概念:

  • 记号token:指的是某一类单词符号的类别编码,比如我们令标识符的记号为id,常数的记号为num等;
  • 模式pattern:指的是某一类单词符号的构造规则,比如标识符的模式是“由字母开头的字母数字串”
  • 单词lexeme:指的是某一类单词符号的一个实例,比如一个具体的标识符position就是一个单词

大多数程序设计语言都包含如下记号:关键字、标识符、常数、运算符以及标点符号,在描述程序设计语言语法结构的上下文无关文法中记号是终结符;

词法分析程序识别出一个记号之后,要把与之相关的信息作为它的属性保留下来:

  • 记号影响语法分析的决策;
  • 属性影响记号的翻译;
  • 对于标识符来说,记号的属性是它代表的单词在符号表中的入口指针;对于常数来说它的属性是它所表示的值;

对关键字、运算符和标点符号来说,如果每一个关键字、运算符或标点符号作为单独的一类,则记号所代表的单词是唯一的,不再需要属性(但无论如何输出的单词符号的表示形式为(单词识别,单词自身的值)这样的二元组)

  • 若记号所代表的单词不唯一,如表3-1中的记号relop,则需要给出属性;
  • 若将所有的关键字归为一类,则对某一关键字的输出,除了类别编码外,还应该指出它在关键字表中的位置;

3.记号的描述和识别

结论1:识别单词是按照记号的模式进行,一种记号的模式匹配一类单词的集合;

正规表达式和正规文法是描述模式的重要工具,正规表达式和正规文法都可以用来描述程序设计语言中单词符号的结构,二者具有相同的表达能力:

  • 用正规表达式描述,清晰而简洁;
  • 用正规文法描述,则易于识别;

通常,先用正规表达式来描述单词符号的结构,然后根据需要,把正规表达式转换为等价的正规文法,正规定义式为这种转换提供了条件(这部分实际就是上学期形式语言与自动机的内容,这部分书上讲的非常混乱不建议阅读);

4.词法分析程序的设计与实现

4.1 词法分析程序的设计

实现一个词法分析程序的大致流程为给出描述该语言各种单词符号的词法规则,接着构造它的状态转换图,最后根据状态转换图构造词法分析程序

主要步骤如下(书本P65详细实现了一个词法分析程序,可供参考):

  • 首先给出描述该语言各种单词符号(记号)的词法规则

  • 使用正规文法描述各种单词符号(记号),我们这里均使用的是右线性文法(下图仅展示部分不全)

  • 接着构造其状态转换图:根据每种记号的文法构造出相应的状态转换图,让这些状态转换图共用一个初态,就可以得到词法分析程序的状态转换图

  • 最后根据状态转换图构造词法分析程序:有了状态转换图,只要把语义动作进一步添加到状态转换图中,使每一个状态都对应一小段程序,就可以构造出相应的词法分析程序;

“使每一个状态对应一小段程序”这句话是什么意思呢?我们举例说明:

  • 在开始状态,首先要读进一个字符。若读入的字符是一个空格(包括blank、tab、enter)就跳过它,继续读字符,直到读进一个非空字符为止。接下来的工作就是根据所读进的非空字符转相应的程序段进行处理;

  • 在标识符状态,识别并组合出一个标识符之后,还必须加入一些动作,如查关键字表,以确定识别出的单词符号是关键字还是用户自定义标识符,并输出相应的记号;

  • 在无符号数状态,可识别出各种常数,包括整数、小数和无符号数。在组合常数的同时,还要进行从字符串到数字的转换;

  • 在“<”状态,若读进的下一个字符是“=”,则输出关系运算符“<=”;若读进的下个字符是“>”,则输出关系运算符“<>”;否则输出关系运算符“<”;

  • 在“/”状态,若读进的下一个字符是“ * ”,则进入注释处理状态,词法分析程序要做的工作是跳过注释,具体做法就是不断地读字符,直到遇到“ * / ”为止,然后转开始状态,继续识别和分析下一个单词;若读进的下一个字符不是“*”,则输出斜杠“/”;

  • 在“:”状态,若读进的下一个字符是“=”,则输出赋值号“:=”;否则,输出冒号“:”;

  • 在其他算术运算符和标点符号状态,只需输出其相应的记号即可;

  • 若进入错误处理状态,表示词法分析程序从源程序中读入了一个不合法的字符。所谓不合法的字符是指该语言不包括以此字符开头的单词符号。词法分析程序发现不合法字符时,要做错误处理,其主要工作是显示或打印错误信息,并跳过这个字符,然后转开始状态继续识别和分析下一个单词符号;

注意一点,在词法分析过程中,为了判断是否已经读到单词符号的右端字符,有时需要向前多读入一个字符,比如在标识符状态和无符号数状态,因此词法分析程序在返回调用程序之前,应将向前指针后退一个字符;

4.2 词法分析程序的实现

4.2.1 定义输出形式

假设我们的词法分析程序使用的是下面的翻译表,那么在分离出一个单词之后,对识别出的记号以二元形式输出,记为<记号,属性>;(我们观察这个表还能发现这个语言只有3个关键字,分别是if、then和else,且这三个关键字各自是一类,因此相应记号就唯一的代表了一个关键字,不再需要属性标识)

4.2.2 定义全局变量和过程

词法分析程序的规模大小一般和状态转换图中的状态数和边数和成正比:

  • 对转换图中的每一个状态分别用一段程序实现
    • 若某状态有若干条射出边,则程序段首先读入一个字符,根据读到的字符,程序控制转去执行下一个状态对应的语句序列

现在回到正题,程序中使用的一些全局变量和需要调用的函数/过程如下(基于上述例子):


Q:过程和函数的区别?

A:实际上程序的子程序分为两种,过程和函数,函数是有参数有返回值的(函数的定义就是从一个非空集合到另一个非空集合的映射),除了函数以外其他的子程序都被称为过程;(过程这个概念在数据库中使用的比较多,编程语言中应该是只有函数这一种子程序)


4.2.3 程序框架

这里使用伪代码的方式给出大致的词法分析程序的主体框架

state=0;															   //初始状态,状态码设置为0
DO{
    SWITCH(state){
        CASE 0:														    //初始状态													
            token=";  												 	//token字符数组,存放当前正在识别的单词字符串,因为刚开始什么都没有所以只存储了开始的"符号
            get_char();												   //get_char过程,每调用一次,根据向前指针forward的指示从输入缓冲区中读一个字符,并把它放入变量C中,然后移动forward,使之指向下一个字符
            get_nbc();												   //get_nbc过程,每次调用时,检查C中的字符是否为空格,若是,则反复调用过程get_char,直到C中进人一个非空字符为止
            SWITCH(C){												    //字符变量C,用于存放当前读入的字符
                 CASE'a':state=1;break;
                 CASE'b':state=1;break;
                 ...
                 CASE'z':state=1;break;									//设置标识符的状态
                 CASE'0':state=2;break;
                 CASE'1':state=2;break;
                 ...
                 CASE'9':state=2;break;								    //设置常数符状态
                 CASE'<':state=8;break;									//设置'<'符状态
                 CASE'>':state=9;break;									//设置'>'符状态
                 CASE':':state=10;break;								//设置':'符状态
                 CASE'/':state=11;break;								//设置'/'符状态
                 CASE'=':state=O;return(relop,EQ);break;			  //返回'='的记号,并回到初始状态	
                 CASE'+':state=0;return('+',-);break;				  //返回'+'的记号
                 CASE'-':state= O;return('-',-):break;				  //返回'-'的记号
                 CASE'*':state= O;return('*',-);break;				  //返回'*'的记号
                 CASE'(':state=0;return('(',-):break;   			//返回'('的记号
                 CASE')':state=0;return(')',-);break;				//返回')'的记号
                 CASE';':state=0;return(':',-):break;				//返回';'的记号
                CASE'\'':state=0;return('\'',-);break;				  //返回"的记号
                 default:state=13;break;							   //设置错误状态
            }
	   break;
       CASE 1:														//标识符状态(注意这里标识符是有可能包含关键字的需要判断)
           cat();												   //将C中的字符连接在token中的字符串后面
           get_char();											   //get_char过程,每调用一次,根据向前指针forward的指示从输入缓冲区中读一个字符,并把它放入变量C中,然后移动forward,使之指向下一个字符
           IF(letter()|| digit())								   //letter函数和digit函数,分别判断C中的字符是否是字符或数字,若是则返回ture
           		state=1;											  //如果已经不是字符或数字,代表很有可能一个单词已经结束,此时对token中的单词进行判断
           ELSE{
               retract();											    //前向指针forward后退一个字符
               state=0;
               iskey=reserve();											//根据token中的单词查询关键字表,若token中的是关键字则返回该关键字的记号,否则返回-1
              IF(iskey!=-1)
                   return(iskey,-);							 		   //1.如果识别出的是关键字,则返回该关键字的记号(这里没有返回属性是因为只有三个关键字)
              ELSE{														//2.如果识别出的是用户自定义标识符,将识别出来的用户自定义标识符(即token中的单词)插人符号表,返回该单词在符号表中的位置指针,即返回该标识符在符号表的入口指针
                identry=table_insert();									
                return(ID,identry);										//return二元组(ID,identry)
               };
           };
	   break;
       CASE 2:														  //常数状态
           cat();
		  get_char();
           SWITCH(C){
		  CASE'0':state= 2;break;
           CASE'1':state= 2;break;
		  ...
		  CASE'9':state= 2;break;
          CASE'.':state=3;break;
          CASE'E':state=5;break;
          DEFAULT:													   //如果token后面已经不跟着数字或者小数点或者E,则表示已经是一个整常数,此时可以进行处理
			retract();												//前向指针forward后退一个字符
             state=0;
             return(NUM,SToI(token));								//返回整数
             break;
       };
       break;
      ...														       //之后就是各种上面列举的状态的处理,我们就不再赘述   
    }
}

5.LEX软件

LEX全称为LEXical compiler的缩写,主要功能是根据LEX源程序生成一个C语言描述的词法分析程序:

  • LEX源程序是词法分析程序的规格说明文件;

使用LEX生成词法分析程序的流程图如下(Linux环境下)

5.1 LEX源程序结构

一个LEX源程序由声明、翻译规则以及辅助过程三部分组成,各个部分间使用"%%"分隔:

  • 声明部分:包括变量的声明、符号常量的声明以及正规定义(正规定义中定义的名字可以出现在翻译规则的正规表达式中),C语言声明语句一定要使用“%{}%”括起来;
  • 翻译规则部分:由正规表达式和相应动作组成的具有如下形式的语句序列;
P1 {动作1}
P2 {动作2}

其中Pi是正规表达式,描述一种记号的模式;动作i是C语言描述的程序段,表示当一个符号串匹配模式Pi时词法分析程序执行的动作;
  • 辅助过程是对翻译规则的补充:对于翻译规则部分中某些动作需要调用的过程或函数,若不是C语言的函数库则需要在此给出具体的定义;

PS:LEX源程序中翻译规则部分是必须的,声明部分和辅助过程部分可以没有

5.2 LEX使用指南

原文链接:Lex使用指南 - 火雨(Nick) - 博客园 (cnblogs.com)

第四章 语法分析

1.语法分析简介

语法分析的目的就是根据源语言的语法规则,从源程序记号序列中识别出各种语法成分同时进行语法检查,为语义分析和代码生成做准备;

语法分析工作由语法分析程序完成;

需要注意的是语法分析有两个前提:

常用的语法分析方法有自顶向下和自底向上两大类:

(1)自顶向下的分析方法:语法分析程序从树根到树叶自顶向下地为输入的记号序列建立分析树。如LL1分析程序采用的就是自顶向下的分析方法;
(2)自底向上的分析方法:语法分析程序从树叶到树根自下而上地为输入的记号序列建立分析树。如LR分析程序采用的就是自底向上的分析方法;

自下而上自上而下
从输入串开始,逐步进行归约,直到文法的开始符号从文法的开始符号出发,反复使用各种产生式,寻找"匹配"的推导
归约:根据文法的产生式规则,把串中出现的产生式的右部替换成左部符号推导:根据文法的产生式规则,把串中出现的产生式的左部符号替换成右部
从树叶节点开始,构造分析树从树的根开始,构造分析树
算符优先分析法、LR分析法递归下降分析法、LL1分析法(递归/非递归)

无论采用的是哪种分析方法,语法分析程序对输入记号序列的扫描均是自左向右进行的,每次读入一个记号


通常用户要求编译程序在工作过程中能够识别出源程序中存在的错误并能确定错误出现的位置和性质,而源程序中出现的错误大多是语法错误;

语法分析程序主要处理语法错误:如算数表达式的括号不匹配、缺少运算对象等,错误处理的基本目标为:

  • 能够清楚而准确地报告发现的错误,如错误的位置和性质;
  • 能够迅速地从错误中恢复过来,以便继续诊断后面可能存在的错误;
  • 错误处理功能不应该明显地影响编译程序对正确程序的处理效率;

语法分析程序可以采用的错误恢复策略主要有如下:

  • 紧急恢复:一旦发现错误,分析程序每次抛弃一个输入记号,直到向前指针所指向的记号属于某个指定的同步记号集合为止。同步记号通常是定界符,如语句结束符分号、块结束标识END等,它们在源程序中的作用是清楚的;
    • 由于常常跳过一段记号不做分析,这要求编译程序必须选择合适的同步记号;
  • 短语级恢复:一旦发现错误,分析程序便对剩余输入做局部纠正,用可以使分析程序继续分析的符号串代替剩余输入串的前缀;
    • 典型的局部纠正有用分号代替逗号、删除多余的分号、插入遗漏的分号等。但如果总是在当前输入串前面插入一些记号的话,这种策略就可能使分析陷入死循环,所以编译程序的设计者必须仔细选择替换串;
  • 出错产生式:通过增加产生错误结构的产生式,扩充源语言的文法,然后根据扩充后的文法构造分析程序。如果分析程序在分析过程中使用了这些扩充的产生式,表示输入记号序列中的这个错误结构已经被识别,产生适当的错误诊断信息;
  • 全局纠正:使用全局纠正策略的分析程序在处理不正确的输入符号串时,作尽可能少的修改,即给定不正确的输入串x和文法G,获得串y的分析树,使把x变成y所需要的插入、删除和修改量最少;

2.自顶向下分析方法

前面已经说过,自上而下的分析实质上就是从文法的开始符号S推导出字符串w的过程

在每一步的推导过程中我们需要思考如下两个问题:

  1. 替换当前句型中的哪个非终结符;
  2. 用该终结符的哪个候选式进行替换;

关于第一个问题,我们规定了两种选择:

  • 最左推导:最左推导中,总是选择句型最左非终结符进行替换;
  • 最右推导:最右推导中,总是选择句型最右非终结符进行替换;
  • 我们称最左规约为规范规约,相应的最右推导称为规范推导;(不要问为什么,这就是规定没有为什么)

  • 自顶向下的语法分析采用最左推导方式;(是不是觉得很神奇??最右推导是规范的但是我就是要用最左…)

无论是最左推导还是最右推导,它们的推导结果以及产生的分析树一定是唯一的(除非这个句子是有二义性的)

解决了第一个问题就该解决第二个问题了,这个问题就引出自顶向下分析将要面临的两个困境:

  • 回溯问题:分析过程中,当一个非终结符用某一个候选匹配成功时,这种匹配可能是暂时的(很可能因为后面的非终结符匹配不成功而重新匹配-回溯)
  • 文法左递归问题:因为我们规定了使用最左推导,所以如果出现这样的产生式P->Pa,那就永远别想推导出一个终结符串了;

2.1 消除左递归

发现了问题就需要解决问题,我们先来消除文法的左递归(因为形式语言学过),再来消除回溯;

关于消除左递归我们参考形式语言与自动机复习笔记 - Tintoki_blog (gintoki-jpg.github.io),左递归分为直接左递归和间接左递归:

  • 引入带下标的变量A1、A2来替换原有变量,利用规则“产生式左部变量下标要小于右部第一个变量下标”来调整变量下标消除间接左递归(间接左递归不是课程考察的重点);
  • 通过构造产生式组(实质上就是将左递归变为右递归)消除直接左递归;

2.2 消除回溯

引起回溯的原因是,在文法中某个非终结符A有多个候选式,需要使用A匹配当前的输入符号的时候,无法确定选用唯一的候选式只能逐一试探,这将引起回溯;

为了消除回溯,必须保证对文法的任何非终结符,当要它去匹配输入串时,能够根据它所面临的输入符号准确地指派它的一个候选去执行任务,并且此候选的工作结果应是确信无疑的(这不是废话,引起回溯的原因就是选的式子不唯一);

解决回溯的关键:已知所有产生式以及当前匹配符号串,选择一个最好的产生式;

消除回溯的方法是提取左公共因子,假定关于A的规则(产生式)是(其中β是含有其他终结符的句型)

那么,可以将这些规则改写成

2.2.1 First集合

First集合的定义:令G是一个不含左递归的文法,对G的所有非终结符的每个候选产生式α定义它的终结首符集FIRST(α)为FIRST(α)={a│α⇒∗ a…,其中a∈VT },特别是,若α⇒∗ ε,则规定ε∈FIRST(α)。

简单来说就是有产生式A->bB,且当前匹配到的符号串是cb指针指向b,那么就直接选这个产生式相对来说最好;特别地,如果产生式恰好产生的是空串则需要特别处理;

结论1:如果非终结符A的所有First集两两不相交,即A的任何两个不同候选产生式ai和aj,有FIRST(ai)∩FIRST(aj)=空集(举个例子,First(a1)={b,c},First(a2)={d,e},则两个集合不相交),则当要求A匹配输入串时,A能根据它所面临的第一个输入符号a,准确地指派某一个候选去执行任务,这个候选就是那个终结首符集含a的α产生式

所以现在我们需要做的就是如何将每个非终结符的所有First集变成两两不相交的,实际上前面的提取左公因子就已经保证了这一步;

2.2.2 Follow集合

上面说到的非终结符推出空串需要特殊处理,这里我们来详细介绍一下怎么特殊处理;

当非终结符的产生式中有空串,则需要求这个非终结符的Follow集,便于在推导过程中是否应该选择空产生式;

上面的例子中我们只需要关注E·以及T·的Follow集即可(因为只有它们两个的产生式中有空产生式)

结论2:如果当前某非终结符A与当前输入符a不匹配时,若存在A→ε,可以通过检查a是否可以出现在A的后面,来决定是否使用产生式 A→ε(若文法中无 A→ε,则应报错(当然这是尝试了所有的匹配之后))

我们再次举个例子来看Follow集的必要性

因为能够跟在B后面的终结符只可能是c和a(“紧跟”这个词需要好好理解),所以第二个符号串我们不可能使用"B->空串"来匹配adBC得到目标符号串ade,而因为另外两个B的产生式也不匹配,所以语法分析器会直接返回符号串不匹配错误;


Follow集合的定义:假定S是文法G的开始符号,对于G的任何非终结符A,我们定义A的FOLLOW集合

FOLLOW(A)={a│S⇒∗ …Aa…,其中a∈VT },特别是,若S⇒∗ …A,则规定#∈FOLLOW(A) (也就是说如果从开始符号推出的A的后面没有终结符,则把#语法结束符号加入Follow(A)集)

结论3:只要给定了文法,就能求出唯一不变的First集和Follow集;

2.2.3 First和Follow集合的构造

文章参考:[编译原理求FIRST集、FOLLOW集和SELECT集 | 言曌博客 (liuyanzhao.com)](http://liuyanzhao.com/8279.html#:~:text=First集合顾名思义就是求,一个文法符号串所可能推导出的符号串的第一个终结符的集合 。)

我们总结一下计算First集合和Follow集合的方法;

First集合:一个文法符号(串)可能推导出的所有符号串的第一个终结符的集合

  • 计算单个终结符号的First(a):单个终结符的Fistr集合就是它自己

  • 计算文法符号X的First(X)(即单个非终结符的First集合)

  • 计算文法符号串X1X2X3...Xn的First集合(这里的Xi可以是终结符也可以是非终结符)

Follow集合:文法符号后面可能跟随的终结符的集合(Follow集不包括空串!!!)

注意终结符的Follow集合没有定义,只有非终结符才会有Follow集合;Follow集合中的符号一定是终结符且不包含空串;

  • 计算非终结符A的Follow集合

简单来说:

  1. 开始符号的Follow集合初始为 { # };

  2. 当有产生式A->…Ua…,则a属于Follow(U);

  3. 当有产生式A->…UP:

    • 当P推导为空串的时候,Follow(A)属于Follow(U);

    • 当P推导不是空串的时候,First§属于Follow(U);

2.2.4 Select集合

Select集合直观上理解就是上面我们介绍过的产生式右边的符号串的First集合(特殊情况下,即产生式推出空的时候Select集合是产生式左边的非终结符的Follow集合)

Select集合:产生式左部的可能的推导结果的起始符号集合

Select(A->B)就是求这个产生式中A可能推导出的结果(即B)起始符号集合(不包含空串,但是可能包含 #(结束符)),可分为如下情况:

  • A->X(X为任意文法符号串,不仅限于非终结符或单个符号,X不能推导出空串),此时Select(A->X)=First(X);
  • A->X(X为任意文法符号串,不仅限于非终结符或单个符号,X能推导出空串),此时不仅First(X)属于Select(A->X),同时Follow(A)也属于Select(A->X);

Select集合的作用:构造预测分析表

2.3 LL1文法

LL1文法是构造不带回溯的自上而下分析的必要条件,实际上我们前面的步骤都是为了构造LL1文法;

我们对LL1文法的定义如下

对于第三点做一个解释,并不是说所有的First集合和Follow集合都不能有交集,而是说当一个非终结符A可以推出空串的情况,才需要保证First(A)交Follow(A)=空集

即B后面可能跟a,同时B也可以推出a,这就将导致我们在选择产生a的时候面临两个选择,这是需要避免的;


Q:上面也没讲怎么避免First和Follow出现交集啊?如果我在求解过程中出现交集了咋办??

A:假如First和Follow集出现了交集,则这就不符合LL1文法,那就不能用自上而下的分析方法(我们之后会用自下而上的方法解决这个问题);


关于LL1名称的解释:

  • L:从左到右扫描输入串;

  • L:最左推导;

  • 1:每一步骤只向前查看一个符号;

那么当我们已经拥有一个LL1文法,就可以使用严谨的推导规则对输入串进行有效的无回溯的自顶向下的分析(之所以要避免First集和Follow集有交集,也是因为下面的if else只能选择其一):

LL1文法具备一个非常强的性质:当且仅当文法为LL1时,该文法的预测分析表M不包含多重定义入口,简单来说LL1文法不是二义的;

2.4 预测分析表

现在我们来总结一下如何把一个普通的文法改造为LL1文法后进行自上而下的分析:

  1. 消除文法的左递归;
  2. 提取左公共因子;
  3. 计算First集合和Follow集合;
  4. 如果原文法为LL1文法,使用LL1文法的分析进行自顶向下的不带回溯的分析;

之所以必须使用ll(1)文法进行自上而下的分析,是因为在使用ll(1)文法对符号串进行自上而下的推导(匹配)的时候,可以明确唯一的选择产生式,也就对应了预测分析表中的唯一一个选项;

同时给定一个LL1文法就能唯一构造一个预测分析表;

下面我们介绍如何构造一个预测分析表(基于下面的文法G(E),注意预测分析表只和文法有关,和具体要匹配的符号串没有关系)

构造文法G的分析表M[A,o],确定每个产生式A->a在表中的位置:

  • 对每个终结符o属于FIRST(a) ,把A→a加至M[A,o]中;

  • 若空串属于FIRST(a),则对任何b属于FOLLOW(A) 把A→a加至M[A,b]中;

  • 对文法G的每个产生式A→a执行第1步和第2步;

  • 把所有无定义的M[A,o]标上“出错标志”;

下面给出预测分析表的构造伪代码

for(文法G的每一个产生式A->a){
	for(每个终结符号o属于First(a)){
		将A->a放入表项M[A,o]中;
	}
	if(空串属于First(a)){
		for(每个 b属于Follow(A)){
			把A->a放入表项M[A,b]中;
		}
	}
for(所有无定义的表项M[A,o]){
	标志错误标志
}
}

根据上述构造步骤,首先根据所给文法构造每个非终结符的First集合和Follow集合(这里的First集合不是用来构造分析表的,而是用来计算Follow集合的)

构造空白分析表,左侧为所有非终结符,上侧为所有终结符(不包括空串)

然后按照规则依次把每个产生式填入分析表中(看规则可能觉得很难理解,实际上就是先看第一个产生式E->TE’,因此把这个产生式填在E行,First(TE’)列(即i列和(列),接着是第二个产生式,注意把多个产生式分开填写),将所有的产生式都填写完毕后就构造出了该文法的预测分析表

2.5 LL1的非递归预测分析

LL1的递归预测分析不是考试重点所以我们不介绍;

非递归的预测分析程序模型如下

  • 输入缓冲区:用于存放被分析的输入符号串,串尾结束符号#或者$;

  • 分析栈:存放一系列的文法符号,使用符号#或$标识栈底,接着是文法的开始符号…

  • 分析表:二维表M[A,a],其中A是非终结符号,a是终结符号或#,分析表用于给出分析动作指引;

  • 输出流:分析过程中采用的产生式序列;

  • 预测分析控制程序:总控程序;

总控程序也被称为预测分析控制程序,是预测分析程序的核心部分,由分析表驱动,它总是根据栈顶符号X和当前输入符号a来决定分析程序应该采取的动作,有如下四种可能:

整个非递归预测分析程序的结构为

  • 输入:输入符号串w以及文法G的预测分析表M;
  • 输出:如果该符号串w属于文法G识别的语言,则输出符号串w的最左推导,否则报告错误;

输入和输出的中间过程如下

下面我们给出一道例题直观的感受一下自顶向下的非递归预测分析过程(题目中的i1、i2以及i3实际上都是终结符i,只是为了表明在匹配过程中始终用栈顶符号去匹配输入符号串的第一个符号)

预测推导过程如下(非递归的预测分析过程),其中输出栏表示在分析符号串的过程中采用的产生式

2.5.1 错误处理程序

这一节不作为考察重点;

当出现如下两种情况表示源程序中出现语法错误:

  1. 分析栈栈顶符号是终结符号,但是与当前输入符号不匹配;
  2. 分析栈栈顶符号是非终结符号A,当前输入符号为a,但分析表中M[A,a]为空;

一种“应急”处理方式是:

  • 情况1预测分析控制程序将栈顶的终结符号弹出;
  • 情况2预测分析符号移动指针跳过若干输入符号,直到可以继续分析为止;

这需要一种新的分析表,额外有同步信息synch(对非终结符A,终结符号 b属于Follow(A),如果表项M[A,b]为空,则填入synch)

给出文法4.4的First集合和Follow集合

下面是文法4.4带同步信息的预测分析表

到此,综合处理步骤如下:

  • 如果栈顶符号是终结符号,但它与当前输入符号不匹配,则将此终结符号从栈顶弹出;

  • 如果栈顶符号是非终结符号A,当前输入符号是a,预测分析控制程序在分析表中查表项M[A,a]:

    • 若它是空白,则移动向前指针,使它指向下一个符号;
    • 若它是synch,则从栈顶弹出A;

我们给出实例演示错误处理程序(这里的输出栏有点问题,应该除了第一行其他整体向下移动一行)

3.自下向上分析方法

自底向上分析方法试图自下而上的为输入符号串构造一棵分析树,即从树叶开始向上构造,直到树根;

在采用自左向右扫描、自底向上分析的前提下,这种分析方法是从输入符号串开始,通过查找当前句型的“可归约串”,并使用规则把它归约为相应的非终结符号,得到一个新的句型,重复这种“查找可归约串-归约”的过程,直到最后归约到文法开始符号为止。自底向上分析方法的关键在于找出“可归约串”,然后,根据规则辨别将它归约为哪个非终结符号;

常用的自底向上分析方法有优先分析法和LR分析方法

  • 优先分析法又分为简单优先分析法和算符优先分析法:
    • 简单优先分析法是按照文法符号(包括终结符号和非终结符号)之间的优先关系确定当前句型的“可归约串”,其分析过程实际上是一种规范归约,但这种方法分析效率低,且只适用于简单优先文法,使用价值不大;
    • 算符优先分析法是按照算符的优先关系和结合性质进行语法分析,适合分析表达式;
  • LR分析法(重点掌握):规范归约,句柄作为可归约串;

自下而上和自上而下的对比如下

下面我们给出一个简单的自下而上的分析示例

3.1 基础概念

3.1.1 移进-归约

自下向上分析的基本思想是"移进-归约":用一个寄存符号的先进后出栈,把输入符号一个一个地移进到栈里,当栈顶形成某个产生式的候选式时,即把栈顶的这一部分替换成(归约为)该产生式的左部符号;

概念可能很抽象,我们给出一个示例帮助理解

此时b作为栈顶可以进行归约,替换为A

此时bA部分可以进行归约,替换为A

…以此类推,最终栈中部分aAcBe可以归约为开始符号S,归约成功

移进-归约的核心问题是识别可归约串

连续出现的单词序列并不是可归约串,短语才是可归约串(praised the并不是一个可归约串,而the student才是一个可归约串),当遇到连续单词序列而不是短语的时候是不能归约的,需要继续移进;


Q:语法树和分析树的区别?为什么大部分的教程都把这两个概念混用?

分析树和语法树不是一种东西,习惯上,我们把前者叫做“具体语法树”或“分析树”,其能够体现具体的推导过程;后者叫做“抽象语法树”,其不体现过程,只关心最后的结果;

为了明确区分,之后我们都是用英语表示,分析树就是语法树paser tree,语法树全称抽象语法树AST

  • paser tree

    • 根由开始符号标记,叶子由终结符,非终结符,或ε标记,内部节点由一个非终结符标记。父节点A->孩子节点XYZ…是一个产生式。若A→ε,则标记为A的结点可以仅有一个标记为ε的孩子;

    • 叶子从左到右构成句型,若仅由终结符标记则构成一个句子;

    • 最左推导与最右推导的paser tree可能不同(这意味着该文法G存在二义性)

AST

  • 根与内部节点由表达式中的操作符标记;叶子由表达式中的操作数标记;用于改变运算优先级和结合性的括弧,被隐含在AST的结构中;

  • paser tree与AST的区别
    • paser tree的内部节点是非终结符,AST的内部节点是操作符(运算符),简单理解就是AST中省略了反映分析过程的非终结符;
    • paser tree可以通过最左推导和最右推导构造得到,AST需要通过逆波兰表达式或者三元式来得到;

3.1.2 短语和句柄

定义:令G是一个文法,S是文法的开始符号,假定αβδ是文法G的一个句型(其中α和δ都可以为空):

  • 如果有S⇒∗ αAδ 且 A⇒+ β,则β称是句型αβδ相对于非终结符A的短语;

  • 如果有A⇒β,则称β是句型αβδ相对于规则A->β的直接短语;

下面我们举例说明短语和直接短语(这个概念确实难以理解,需要多琢磨一下)

在一个句型对应的分析树中(实际上这里是一个谬论,我们的目的就是根据可规约串(短语)构建分析树,但是这里却直接给出了分析树,这是为了方便我们理解短语和直接短语):

  • 以某非终结符为根的两代以上的子树的所有末端结点从左到右排列就是相对于该非终结符的一个短语;
  • 如果子树只有两代,则该短语就是直接短语;


Q:关于上面所说的谬论

A:因为在给出文法G和句型的情况下,我们直接构造出该句型的分析树,然后根据这个分析树来判断短语、直接短语和句柄;但是我们自下而上的要求是根据短语、直接短语和句型来归约分析树,这两个要求是互相矛盾的;所以现在的问题就是:这个分析树到底是怎么被建立出来的?既然都有了句型的分析树为什么还要归约?这不是吃饱了没事做?(关于这个我们后面会介绍,这其实不是谬论)

  • 每棵分析树的叶子结点从左到右排列构成一个句型

    • 一个句型的分析树中任一子树叶结点所组成的符号串都是该句型的短语
    • 当子树中不包含其他更小的子树时,该子树叶结点所组成的字符串就是该句型的直接(简单)短语
    • 一个句型的最左直接短语汇称为该句型的句柄(句柄的意义就在于栈中出现句柄必须要立刻处理);
  • 每棵分析树的子树的叶子结点从左到右排列构成一个短语

  • 每棵分析树的简单子树(只有父子两层结点)的叶子结点从左到右排列构成一个简单(直接)短语


3.2 算符优先分析法

这部分是自学内容,只是针对某一类特殊的文法(算符文法)进行分析,我们不具体介绍分析方法,只是介绍一下相关概念;

算符文法的定义:一个文法,如果它的任一产生式的右部都不含两个相继(并列)的非终结符,即不含…QR…形式的产生式右部(Q、R代表任意非终结符),则我们称该文法为算符文法

算符优先分析法主要是用于解决二义性的,因为对于某些特殊文法(算符文法)来说它就是有二义性,要么你选择改写产生式,要么引入优先级:

  • 如果规定算符的优先次序,并按这种规定进行归约,则归约过程是唯一的;

3.3 LR分析法

3.3.1 LR分析器模型

LR分析器的名称含义:

  • L:从左到右扫描输入串
  • R:自下而上进行规范归约

算符优先分析的结果一般并不会等于规范规约的结果(注意这里不是因为二义性,是因为分析方法都不一样,所以说“分析树不一定完全一致”)

先来介绍几个常用概念:

  • 规范规约:出现句柄就进行归约,规范规约是最左归约;
  • 规范句型:由规范推导(即规范推导)推出的句型被称为规范句型;
  • 对于一个文法,如果能够构造一张分析表,使得它的每个入口均是唯一确定的,则这个文法就称为LR文法;
  • 一个文法,如果能用一个每步顶多向前检查k个输入符号的LR分析器进行分析(简单来说就是向右查看输入串符号的个数),则这个文法就称为LR(k)文法(当k省略的时候默认k等于1);

LR分析法的本质就是规范规约,LR分析器的工作过程主要如下:

(1)在总控程序的控制下,从左到右扫描输入串,根据分析栈和输入符号的情况,查分析表确定分析动作;

(2)分析表是LR分析器的核心,根据文法构造,它包括动作表(Action)和状态转换表(Goto)两部分,总控程序根据分析表确定分析动作;

(3)分析栈包括文法符号栈X[i]和相应的状态栈S[i]两部分,总控程序通过判断栈顶元素和当前输入符号查分析表确定下步分析动作 —— 符号栈和状态栈永远保持同步;

具体工作流程可以参考视频:2-LR分析器模型_哔哩哔哩_bilibili(强推!!!)

LR分析器的逻辑结构如下

规范归约的关键是寻找句柄,LR法是根据已“移进”、“归约”的符号串及即将读入的符号串进行分析,以确定是否有句柄可归约;LR分析器的性质:

  • 栈内的符号串和扫描剩下的输入符号串构成了一个规范句型;

  • 一旦栈的顶部出现可归约串(句柄),则进行归约;

    • 栈内的符号串和扫描剩下的输入符号串构成了一个规范句型;
    • 栈内的如果出现句柄,句柄一定在栈的顶部;
    • 栈内永远不会出现句柄之后的符号;

下面我们简单举一个例子说明LR分析如何进行(假设已经知道产生式以及分析表)

3.3.2 LR分析表

我们已经知道LR分析的关键是确定句柄,并不是直接识别句柄,而是构造一个DFA,识别规范归约过程中出现在栈中的符号串,该DFA的状态中包含有句柄是否形成的信息(终态表示句柄形成),所以我们可以说一个LR分析器实质上是一个DFA;至于这个DFA是如何构造的我们将在之后介绍(我们当然可以试探性的根据产生式构造DFA,但实际在做题过程中是有形式化的逻辑的);

得到了文法的DFA之后,我们可以根据该DFA构造该DFA的矩阵表示,也就是我们前面提到的LR分析表(现在我还不是很清楚这个分析表是如何构造的,我们只需要知道这个分析表中的字母代表什么意思即可);

注意:不同的文法其分析表不同(因为DFA不同);相同的文法采用不同的LR分析器(LR(0)、SLR(1)…)得到的分析表也不同,之后会具体介绍;

3.3.3 可归前缀和活前缀

上面我们说到LR分析思想是构造一个DFA,识别规范规约过程中出现在栈中的符号串,我们将这一段称之为活前缀/可行前缀;

活前缀的特点:

  1. 因为栈里的文法符号与剩余符号串一起构成了规范句型,那么栈中的文法符号串就是规范句型的前缀;
  2. 活前缀不包含任何句柄之后的符号(因为当栈顶形成句柄马上就会被归约,句柄之后的符号根本来不及移入栈)

活前缀的定义:规范句型的一个前缀,这种前缀不包含句柄之后的任何符号;

可归前缀的定义:活前缀恰好包含句柄,也就意味着这是一个可以归约的前缀;

3.4 LR(0)分析

本节的目的就是构造识别文法所有活前缀的DFA,我们这里构造DFA并不是像前面一样试探的根据产生式推测性的构造,而是通过一个形式化的算法得到;

在学习本节之前强烈建议温习一遍LR分析法的整个流程,参照视频2-LR分析器模型_哔哩哔哩_bilibili(强推!!!)

3.4.1 基本概念

首先我们需要介绍几个前置概念;

(1)文法的拓展

将文法G(S)拓展为G’(S’),这实际上非常简单,只需要增加一条表达式S’->S,其意义在于当符号栈出现S’的时候我们可以判断整个分析过程结束(如果不扩展的话存在S->Sa这样的表达式不知道是否应当向下继续)

PS:即使原开始符号S不出现在任何产生式右部,为了统一起见仍然需要增加S’->S产生式进行拓展

(2)LR(0)项目

在产生式右部加上·的每一条产生式我们称之为LR(0)项目

对于每一个DFA的状态来说,我们称之为LR(0)项目集 —— 所有等价的项目组成的一个项目集,称为项目集闭包,每个项目集闭包对应自动机的一个状态 —— 当原点后面存在非终结符时,就有与之等价的项目;

对于整个DFA来说,它是状态的集合,我们称之为规范LR(0)项集族

PS:对于空产生式A->空串,仅有项目A->·,并不存在什么A->·空集这种形式

LR(0)项目主要有以下几个类别:

  • 移进项目:A->α·aβ(·后跟着终结符)
  • 待约项目:A->α·Bβ(·后跟着非终结符)
  • 归约项目:A->α·(·已经移动到最右边)
  • 接受项目:S’->S·(意味着此时可以将S归约到S’,整个分析结束)
(3)闭包函数

在状态转移的过程中,如果出现了·右部是非终结符的情况,我们需要添加所有该非终结符的产生式,这个过程称之为闭包函数

也就是说我们不仅得到状态I0读入a转移到I2的核S->a·AcBe以外,还需要对该核求一个闭包;

核的定义:除了S’->·S以外,圆点不在产生式右部最左边的项目

闭包函数的基本逻辑如下

(4)状态转移函数

状态转移函数GO的定义如下

3.4.2 LR(0)分析表

结论:如果一个文法G的LR(0)分析表不含多重定义,则称G为LR(0)文法;

3.4.3 LR(0)冲突

移进-归约冲突

一个项目集中,移进和归约项目同时存在,即:

  • A->α·aβ(点后面是终结符表示移进项目)
  • B->γ·(点在产生式最右边是归约项目)

归约-归约冲突

一个项目集中归约和归约项目同时存在,即:

  • A->β·
  • B->γ·

结论:LR(0)文法是指文法G的拓展文法G’的活前缀识别自动机中的每个状态都不存在移进-归约冲突或归约-归约冲突;

3.5 SLR(1)分析

实际上大多数程序设计语言的文法都不满足LR(0)文法的条件,这就意味着我们对其构造分析表总是会出现多重定义;

我们思考为什么会出现这样的问题:因为LR(0)分析表在构造的时候其归约项目并不查看下一个字符,直接进行归约,这就导致了可能发生的冲突;

相应的解决办法就是修改LR(0)分析表的构造方式,即通过向前查看一个符号来检查LR(0)分析表中是否存在无效归约;

注意:

  • 待移进的终结符b一定不能在A和B的Follow集中;
  • Follow(A)与Follow(B)相交应当是空集(如果不是空集则SLR解决不了这种冲突,需要使用更高级的分析方法);
  • 实际上做题一般都只会有一个归约项目,所以只需要考虑待移进的终结符b不在Follow集当中(表现为两个集合相交为空)

下面我们来看例题

结论:SLR文法(其中S是指simple)是指SLR分析表中不含多重定义的文法;

当然SLR文法也不能解决所有的重定义问题,即针对非SLR文法,我们还可以使用更高级的分析方法,即下面要介绍的LR(1)和LALR分析方法;

3.6 LR(1)分析

举例说明SLR可能发生的冲突

对于状态I2,当我们采用SLR分析:当下一个输入符号为 = 的时候,因为 = 属于 R 的FOLLOW集,所以采用归约操作;但是我们又发现, = 又是S–>L . =R 中圆点后的一个字符,按照规则应该采用移进操作;这就在造成了重定义的问题;

SLR分析仍然可能存在语法冲突,因为SLR只是简单地考察下一个输入符号b是否属于与归约项目A→α相关联的FOLLOW(A),但b∈FOLLOW(A)只是归约α的一个必要条件,而非充分条件;

简单来说就是如果输入下一个字符是b,我们采用了归约操作,那么就一定可以说明b属于A的FOLLOW集。但我们不能说:如果b属于A的FOLLOW集,那么就一定可以对A采用归约操作

从上图右边生成树可以我们得知,此时 L=R 中的R后面跟着的终结符只能是 # ,不可能是 = ,但是R的FOLLOW中却包含了 = ,这明显是范围扩大了;

又比如说下一行中的 * R,R下一个终结符只可以是 = ,不会是 # ;

综上,只凭FOLLOW集合判断是否采用归约是不合适的(如果使用FOLLOW相当于归约的条件放宽了),所以引入了LR(1)分析来解决这种问题;

LR(1)的基本思想:对于产生式A->α的归约,在不同的使用位置,A会要求不同的后继符号;

3.6.1 基本概念
(1)LR(1)项目

介绍LR(1)之前先说一下前面介绍过的LR(0)、SLR(1)以及下面要介绍的LR(1)、LALR之间的联系与区别

LR(0)的基础上才有SLR(1) —— SLR分析方法只用在分析表上,DFA与LR(0)相同;
LR(1)的基础上才有LALR(1) —— LR(1)的DFA合并同心项才能为LALR(1);

这四个文法的本质区别实际都只在一点 —— 构造的分析表中ACTION部分的归约动作的区别:

  • LR(0) 状态有归约状态,整个状态都会进行该归约动作;
  • SLR(1) 状态中针对FOLLOW集中有的终结符号会进行该归约动作;
  • LR(1) 状态中针对展望符对应的总结符号进行该归约动作(一般为FOLLOW集的真子集);
  • LALR(1) 状态中也是针对展望符对应的总结符号进行该归约动作(一般为FOLLOW集的真子集);

理论上LR(1)的分析效果最好,但是实际开发中LR(1)的状态数会非常多,这是不合理的,所以出现了LALR(1)分析方法(相应的,LALR(1)分析法也存在一些问题);


在LR(0)分析的时候,我们并没有考虑下一个输入的字符。而对于LR(1),我们就需要对每一个项目多考虑一项:下一个输入字符(也就是展望符,这是整个LR(1)的难点和核心)

LR(1)项目定义:将一般形式为 [A→α·β, a]的项称为 LR(1) 项;

其中A→αβ 是一个产生式,a 是一个终结符(这里将#视为一个特殊的终结符),它表示在当前状态下,A后面必须紧跟的终结符,称为该项的展望符(lookahead)

  • LR(1) 中的1指的是项的第二个分量的长度,也就是:往后多看一个字符

  • 形如[A→α·β, a]且β ≠ ε的项中,展望符a没有任何作用(尽管没作用但是构造的过程中仍然要写) —— 当LR(1)中的点号不在项的最右侧,向前看符号没有任何意义;

  • 形如[A→α·, a]的项在只有在下一个输入符号等于a时才可以按照A→α 进行归约:这样的a的集合总是FOLLOW(A)的子集,而且它通常是一个真子集 —— 当点号在最右端时,只有下一个输入的符号和向前看符号一致时,我们才归约这个产生式;

(2)LR(1)等价项目

有项目 [A->α·Bβ,a],则该项目的等价项目形式如下 [B->·γ,b],其中b∈FIRST(βa);

当β->+ ε时,称b=a叫做继承的后继符,否则称b为自生的后继符;

而仅仅只是利用上述规则还不足以求出所有情况的展望符,我们给出通用求解展望符的算法:

  1. 项目[S’->·S,#],这个最基本的项目的展望符固定为#
  2. 与项目[A->α·Bβ,a]等价的项目[B->·γ,b],其中b∈FIRST(βa)
  3. 除了上述自动生成的展望符外,展望符还有两种传播方式:
    • 项目[A->α·Bβ,a]中,β->+ ε时,该项目的a纵向传播到项目[B->·γ,a]
    • 项目[A->α·Bβ,a]中,展望符a无条件横向传播到项目[A->αB·β,a](本质上就是第二个情况)
3.6.2 LR(1)自动机

文章参考:(1条消息) 编译原理学习笔记(十)~LR(1)分析_海轰Pro的博客-优快云博客_lr(1)

假设我们有如下文法

构造LR(1)自动机的步骤主要如下:

  1. 拓展文法G得到G’;
  2. 类似LR(0)分析的方法构造得到LR(1)自动机(与LR(0)不同的是多了展望符)

由0)可以推导出I0式,因为I0式中的S是非终结符,所以又可以继续推出等价项目I2和I3。又I2式中圆点后面的L属于非终结符,继续推出I4、I5式【注意:I4、I5中的展望符是=,因为I2式中L后面有终结符=,求展望符就是这么简单】然后继续对左边橙色框中的式子进行相同算法的推导即可;

注:如果除展望符外,两个LR(1)项目集是相同的,则称这两个LR(1)项目集是同心的

3.6.3 LR(1)分析表

拥有自动机之后就可以得到分析表了,LR(1)与LR(0)分析表类似,只是在归约的某些地方做了更加严格的限制,我们先看一下课堂上讲的LR(1)和SLR(1)分析表的构造方法的对比

根据上述规则,根据上面我们得到的LR(1)自动机可以构造得到下面的LR(1)分析表

相应的,如果LR(1)分析表中没有语法冲突(具体什么冲突课堂上也没说…),则称给定的文法为LR(1)文法;

3.7 LALR(1)分析

一般不会考察LALR(1),了解即可

在进行LR(1)分析的过程中,我们会发现存在一些同心项目,经过简化后的这些项目就被称为LALR分析,其基本思想如下:

  • 寻找具有相同核心的LR (1) 项集,并将这些项集合并为一个项集。 所谓项集的核心就是其第一分量的集合
  • 然后根据合并后得到的项集族构造语法分析表

我们还是用之前的例子举例

从上图我们可以发现:I4和I11、I8和I10、I7和I13、I5和I12都是同心的,那么我们就可以将这些同心的项目集合并,构成如下的自动机,再利用自动机构造分析表(分析表的构造方法和LR(1)相同)

注:

  • 合并同心项集,不会产生移进-归约冲突,但是会产生归约归约冲突;
  • 合并同心项集后,虽然不产生冲突,但可能会推迟错误的发现;

3.8 二义性文法的LR分析

实际上所有的语言都有二义性(因为二义性文法更加简短和自然),注意任何二义性文法都不是LR的;

解决二义性的方法也很多,最常用的就是优先级和结合性解决冲突;

关于二义性和错误处理我们不在这里详细讨论,感兴趣可以参考链接4-15二义性文法的LR分析_哔哩哔哩_bilibili

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

坂.y

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值