前言
说实话,我不是很想上这门课,确实没什么大用,虽然我觉得这门课学一学也挺好,但是我觉得弄8个大实验就真的不太够意思,不如纯理论。
吐槽归吐槽,马上就要考试了,能学多少是多少(学到了语法制导翻译的前半部分),最后考试感觉差不多能及格,这我就满意了。
虽然很多人说这个老师是念ppt,但是其实不然,ppt很复杂,你自己看还是有点麻烦,不如让老师给你指出顺序,这样也舒服,建议2倍速。
这课讲的确实不错,条理清晰,ppt也好看,果然群众的眼睛是雪亮的,听学校的课真不如课下听听这个。
概论
编译就是一个翻译的过程,将高级语言(源语言)翻译成汇编语言或者机器语言(目标语言)。
编译系统是一个很大的项目,从源程序到目标及其代码全部包揽,其中分为4部分,以C语言为例解释其作用:
- 预处理器:.c变.i。处理所有的
#
号语句。比如#include语句(导入模块),处理宏定义#define 编译器
:.i变.s,编译系统的核心,编译原理课程研究的对象- 汇编器:.s变.o,此时机器代码段已经生成,其中的机器代码都是可重定位的(只有逻辑地址)
- 链接器/加载器:链接器将.o代码段组合,加载器将完整的程序加载到内存中运行(转逻辑地址到物理地址)
我们来具体研究一下编译器的编译过程,整体上分为两个阶段,先从源语言理解出语义,变成中间代码,然后再将中间代码转化为目标的语言。这两个阶段分别叫做编译器的前端和后端。
整体过程其实和人类的翻译过程是很像的:
词法分析 — 单词词性,孤立个体
语法分析 — 单词组合,构造短语
语义分析 — 连词成句(纠错),直译句义
中间代码 — 抽象提取,句义意会
代码优化 — 同义替换,翻译润色
目标程序 — 翻译结果,成品输出
下面来逐一介绍:
词法分析(Scanning)就是把一整个句子,切成一个又一个单词,确定单词的类型并且打上标记,变成token
一词一码的单词,只需要种别码就可以了,属性值可以不要,因为已经可以区分了。对于多词一码的,需要用种别码界定大类,然后用属性值确定内容。
语法分析(Parsing)将词法分析的结果加工,把词串成短语,以语法树的形式输出。
语义分析(Semantic)比较抽象。
什么是语义呢?TODO,我感觉程序说白了就是在玩各种变量,函数,那么语义就代表着我要把变量的各种信息以及函数的各种信息都提取出来,只要把这些东西搞出来,后面转化成机器语言就很方便了。
所以语义分析的一大任务是收集标识符
的属性信息,标识符无非就是变量函数这些东西,收集他们的属性就是在搞清楚他们想干点什么。
最后这些东西记录在符号表里,之所以NAME字段是<符号表中地址,长度>的格式,是为了节省空间,毕竟长度不确定,统一长度难免浪费。
除此之外,语义分析还要做最后一步的正确性检查,确保在进行代码生成前没有错误。
中间代码生成(IC-gen)。语法树其实已经是一种中间形式了,但是这种结构还是更适合人看,机器更喜欢一条一条的代码,所以ic-gen阶段将语法树转变为一条一条的中间代码,后面生成目标代码可以直接对照生成。
中间形式叫三地址吗
,代表一条指令最多有三个操作数,一般用四元组表示,多出来的这个1指的是操作符,很像汇编语言。
给一张表,下面的地址码使用标志符表示的,不是说地址吗?这是因为,符号表里面已经记录了标志符到地址的映射,所以用标志符=用地址。
总结一下四元组表示的规律:
- 有赋值操作的
- 一般是两个源操作数作为2,3分量,不够就空开,目标数作为第4分量
- x[i]这种比较特殊的目标,以i作为目标数
- 无赋值操作的
- 一般都只有一个目标数
- call比较特殊,只有两个源操作数
目标代码生成(nc-gen)是吧中间形式映射到目标语言。一般是将中间代码映射到目标代码,有的人也会把语法树映射成目标代码,但是及其受限,还是要一步一步走才好。nc-gen的关键是寄存器的合理分配。
代码优化无非就是优化空间或者速度。
语言与文法
基本概念
字母表
字母表:符号的集合
,一个语言中最最基本的元素集合。注意,字母表里面不一定只是一个一个的符号,也可以是串,后面就会看到的。
字母表的运算如下:
-
乘积。本质上就是集合乘积,即笛卡尔积
-
n次幂。幂的定义就是连续乘积。直观理解,就是长度n的字符串集合。照这么理解,其中0次幂的结果是空串集(长度为0的字符串集合),而不是空集,也就很合理了。
-
正闭包。这个也是离散数学上的概念,直观理解为任意长度的字符串集合,正即不包含空串
-
克林闭包(Kleene closure)。相比于正闭包,多了一个空串。
串
字符串是一个老生常谈的概念,其实就是字母表上字符的任意有序排列组合(包括空串),用规范的语言说,就是字母表克林闭包的元素。
字符是语言中最基本的元素,那串就是语言中最常用的基本元素,字符本身其实也算是一种串,串是一个更大的概念。
来看下运算:
- 连接。就连起来而已,由此引申出前后缀的概念,这是相对的,有前缀就有后缀,甚至于空串都可以作为前后缀。
- 幂。n次幂=连接n次
字母表与串的联系
其实到这里你就可以发现,这两个东西似乎有一种本质的联系,说白了就是集合和元素的关系。
你会发现,串的连接运算与字母表的乘积对应,串的连接结果 ∈ \in ∈字母表乘积。串的任意组合对应着字母表的乘积组合,任意串对应着字母表的克林闭包。
其实就是元素和集合的关系啊,有趣。
文法
什么是文法?用抽象一点的话说,就是表示了基本符号
如何构成语法成分
,以及语法成分如何进一步组成更宏观的语法成分。
蚌埠住了吧,直接看例子吧。对于自然语言,基本符号是单词,语法成分是短语和句子。倒数4个介绍了基本元素如何构成语法成分,而前几条介绍了语法成分如何组合成更宏观的语法成分。
想一想语法树,是不是一层一层的,我们这个构成其实也是从下到上一层一层的。
下图仅仅是针对自然语言的,基本符号是单词,语法成分是短语和句子,如果是针对单词来说,那么基本符号就是字母,语法成分就是单词。
为了便于计算机处理和理解,我们用数学语言(形式化语言)去描述文法。总的来说,一部文法规定了一个语言的各种成分,以及转化规则:
- 终结符对应基本符号
- 非终结符对应语法成分
- 产生式集合P对应这些成分之间的转化关系
- 开始符号S对应最大的语法成分,所以开始符号归属于非终结符也是很合理的。
一部文法的元素集合其实在产生式里面就可以看出来,所以不引起歧义的情况下,甚至只写P都可以。所以我们重点研究一下产生式的表示:
这是最基本的写法,左边到右边是一个箭头,从左到右是分解,从右到左是合并。
P还可以继续简化,相同左部的产生式可以合并为一个,右边用|
隔开,代表或,右边的每一项都叫候选式。
最后说下符号约定,好复杂,我感觉不用记那么多,后面慢慢就都记住了,所以这里只把基本规律放出来:
语言
推导和规约
什么是语言?此处直接给出定义:句子的集合
听起来有点像字母表,其实字母表也算是一种很简单的语言,而我们的语言集合,可以是无穷的集合,因为语言集合是从文法推导出来的,可以说,文法与语言是对应关系。
如何判断一个句子是否属于一个语言呢?还是要着眼于文法。一条文法,从左到右是分解,我们这里叫做推导
,从右到左是合并,我们叫规约
。
推导分为0步(没推导),1步(直接推导),n步(n步推导)
直接看例子吧。
句型与句子
我们说,终结符构成的串就是语言的一个句子
,那我们在推导过程中产生的中间形式,里面又有终结符,又有非终结符,甚至全是非终结符,这又叫什么呢?句型
句型是比较抽象的,不具体的,所以就叫“型”,而句子就是一个完全的终结符串,句子也可以理解为特殊的句型。
语言与字母表
回归语言的定义,语言和字母表的元素都可以是字符串,那他们到底不同在哪里呢?本质在于,字母表是我们直接给出的集合,而语言是通过文法推导出来的。
所以我们说L(G)是文法G生成的语言L。给定一部文法,很有可能生成一个无穷集合,这就是用有限的文法表示无穷的句子,这就是语言的本质,用元素+规则表达无穷的组合。
我们来具体举几个例子。下面这部文法表示的是标识符,4条文法就表示出了标识符这个语言,而我们知道,标识符是无穷的:
- L和D可以直接指向终结符,分别代表字母和数字
- T总的来说,表达的意思就是L和D的任意组合,只要不是空串就行
- S=L或者LT,这告诉我们标识符一定要以字母(L)开头,剩下的部分字母数字随意组合就好(T)
最后再重申一下语言和标识符的关系,区别仅仅在于怎么生成的,他们从成分上来说,其实是一样的,或者说字母表本就是一种语言
。由此,运算其实是可以套用的,甚至可以把字母表通过语言的运算转化成更复杂的语言:
由此,串的运算,集合的运算,文法的推导,这三个东西其实是统一的。
文法的分类
0123四类文法,自由程度依次递减。
0型文法就是我们前面用的文法,叫做无限制文法,高度自由,左边只需要有一个非终结符就行了。
这是最基本的要求,毕竟文法从左到右是推导,你左边要是没有东西(非终结符)可推导,这也说不过去,所以虽然高度自由,但是至少得有一个非终结符。
1型文法叫上下文有关文法(CSG),在0型文法的基础上,保证上下文不变,总长度只增不减。
上下文不变还是比较好实现的,而为了保证总长度只增不减,CSG生成式的右边不能有空串。
2型文法叫上下文无关文法(CFG),是基于1型文法的。
看似有关和无关冲突,但是如果我们限制上下文为 ϵ \epsilon ϵ,1型文法就会变成2型文法(不考虑右边为空的情况)
2型文法除了上下文为空以外,相比于1型文法,右边是允许空串的。
一般来说,我们写的文法更像是2型文法。
3型文法叫正则文法,也叫线性文法,分为左线性文法和右线性文法。
之所以叫线性文法,是因为每次推导只会增加1的串长。左右代表着字符串的增长方向,比如右线性文法,增长方向和非终结符的位置一致,就是右边。
正则文法和正则式对应,我们平时编程语言中会接触很多这个东西。
最后,如果不考虑1型文法右边不允许空串,那么0-3型文法就是逐级限制的。
CFG的分析树
前面我们知道,我们一般都是用2型文法的,所以就针对CFG研究一下它在编译过程中的表示。
一颗CFG分析树有三种节点,分别为根节点,内部节点,叶节点。
把所有叶节点排起来,直观看这些叶节点整体就是一棵树的边缘
,这代表了一个树的产出
给定一个推导,推导的过程中,句型会不断变长。对应到树这里,给定一个根节点,每推导一次,树就向下生长一点,边缘/产出就会增加,推导的过程就是树不断变大的过程。
所以可以说,每个句型
都可以对应一颗分析树
注意,叶节点不一定是终结符,这说明我们的推导不一定非要推导到没有非终结符为止,提前停下来也可以,也算产出。
下图中,该句型有3个短语,其中,有一个是直接短语。
因为产生式是一对多的,就是一个左部可能有多个右部,所以只能说推导出来的右部一定是直接短语,不能说某一个右部就是直接短语。
直接短语一定是当前句型可以推导出来的右部,随便给一个右部,不一定是当前句型能推到出来的,可能是另一个句型的直接短语。
最后说一下二义性,一颗语法树代表一个语法结构,如果同一个句型可以得到两颗不同的语法树,计算机就会出现理解的歧义,计算机中不允许歧义出现。
对于一个CFS,没有一个充要条件可以判断二义性,但是有充分条件可以判断二义性。
如果出现二义性,解决办法就是修改文法。
词法分析
铺垫了这么久,终于到了词法分析了,不过上来还是得学一堆数学。
词法分析用到3型文法,之所以不用2型,是为了节约成本,所以要先学正则式。
正则式
前面学过正则语言,本质上还是语言,是集合。但是这种表示方法比较麻烦,正则式是正则语言的紧凑表示法。
一个正则式表示与其形式匹配的字符串,所有串的集合就是该正则式对应的正则语言,所以有L(r)的写法。
正则表达式的定义是递归定义:
- 空串是正则式
- 字母表中任一字符是正则式
- 正则式经过
正则运算规则
组合成的新式子也是正则式
其实正则运算规则和集合的运算是一一对应的,因为正则式本来就代表着语言(集合),正则式和正则文法一一对应,下图给出了对应关系。
下面这些代数定律,其实对应离散数学里的集合的运算定律,不用刻意去记。
正则定义
回归编译过程,正则式的意义在于,可以定义一类单词,所以本节给出各类单词的正则定义。
可以看到,正则式和文法的写法几乎一样,毕竟本来就是等价的,只是写的更加简略罢了。
有穷自动机(FA)
FA是MeCluoch和Pitts提出,这俩名好熟悉啊,去一搜才知道也是人工智能领域的老祖级别的任务。
FA的特点是,给定有限个状态,只需要不断输入,状态就可以不断切换,并且给出输出,学过数字逻辑或者计算理论的人会很熟悉自动机这个东西。
FA可以用转换图表示,圆圈是状态,有向边代表输入与转换。
FA如何识别语言呢?给定一个起始状态,然后不断输入每一个字符,状态就会不断转换,输入完毕以后,如果停留在接受态,那么就是接受了。
此外,FA有一个最长子串匹配原则,下图中,如果输入<=,那么优先匹配<=,而不是<
DFA
确定有穷自动机,即DFA。
状态,输入,转换,这三者决定了DFA的基本框架。初始态和接受态决定了我们要接受什么串。
DFA也可以用转换表来表示。DFA的显著特征是,转换图中同一个状态不会有两条相同的输入边,转换表中一个格子里面只有一个目标状态。
最后给出DFA的算法实现,还是挺简单的。
NFA
不确定的有穷自动机,即NFA
NFA和DFA的唯一区别就在于,一个状态,一个输入,可能完全走向两个结果,具有不确定性。
举个例子,你可能一下子无法接受下面这个图,0状态对于a输入有两个可能的转换。其实没什么问题,这个是NFA,我们再数字逻辑里学的是DFA。NFA是不确定的FA,有二义性,怎么走由你自己来判断,又或者交给一定的规则和概率(计算机实现)
NFA的特点在下图也可以看出来,转换表里面,一个格子里面,目标状态可以是一个集合。
FA之间的等价性
从前面以及下面这个图可以看出来,NFA虽然直观,但是有二义性,DFA复杂,但是适合计算机实现,没有歧义。
一个好消息是,NFA和DFA是等价的,这也就意味着,人可以先写出NFA,然后通过一定的算法转化成DFA,交给计算机去实现。
另一个等价性是,带空边的NFA和不带空边的NFA也是等价的。
所以我们写NFA的时候更加自由,可以带空边去写,最后逐步转化成DFA就可以。
从RE到DFA
整体流程:
- 根据词法规则写出RE
- RE转NFA:转换规则
- NFA转DFA:子集法
- DFA最小化:划分法
- DFA代码实现
RE转NFA
大致规则如下,转换的时候,按照正则式的优先级进行转换。
再举个实际的例子,在实践的时候,其实拆解顺序反而大致与运算优先级(括号,*,与,或)是相反的,从外往里拆。但是请注意,拆解顺序≠优先级,所以仍然是保持着优先级的。
在这张图里,按照括号先拆成3节,之后每一节内部再拆。具体拆解顺序还是有一点玄学,需要多练,但是总的来说意思不能变。
最后插播一个NFA转RE的例子,虽然现实中没人会这么做,但是保不准考题会这么考,看情况可以跳过这部分:
- 这个例子中用到了技巧叫
拓广
,拓广在左右两边放了两个绿色的圈儿,用epsilon弧链接附近的节点。之所以这么干,是为了统一初态和终态,很合理。 - 替换过程中,优先级同正则式优先级。但是实际上做的时候,为了方便,总是以循环为中心,顺便把两边的其他运算统筹了。比如下面这个,一次性把循环,顺序,分支全部搞定了。
- 就像这个,也是一样。在解决循环的时候,顺便把左右两边的连接一起搞定了。
- 最后变成了这个样子。把分支合并一下就是一个又臭又长的正则式了。
NFA确定化:子集法
首先介绍两个运算:
自反闭包
:写法是 ϵ − c l o s u r e ( 集合元素 ) \epsilon-closure({集合元素}) ϵ−closure(集合元素),作用是消去 ϵ \epsilon ϵ弧,得到自己+只输入 ϵ \epsilon ϵ就可以走下去的路径上的所有字母。求NFA新状态集
: I a = { 集合元素 } a I_a=\{集合元素\}_a Ia={集合元素}a。对于状态集I要做两件事:- 获取中间状态集:输入任意数量和顺序的 ϵ \epsilon ϵ和一个a,能到达的所有状态就是中间状态集
- 得到最终结果:再求中间状态集的自反闭包,再次消除epsilon弧,就是运算的结果,即新状态集。
举个例子:
- 消去空弧很合理,结果就是原来的状态+消去空弧可以达到的状态。
- 再看
I
a
I_a
Ia运算。
- 对于集合{2,5},对5输入2个epsilon以及1个a,最终可以到达3。对2输入1个a,可以到达3,所以中间状态集是{3}。之后对{3}进行自反闭包运算得到{3,8}
- 对于集合{1},对1输入1个a,可以到达5和4,对1输入1个epsilon和1个a,可以到达3,因此中间状态集是{3,4,5}。之后求自反闭包,得到结果{5,6,2, 3,8, 4,7}
了解了两个基本运算后,就可以开始应用到子集法中了。子集法的原理是将NFA图转换成状态矩阵,把矩阵再转换成DFA。因为我们用状态机,只关心输入和输出,其中的状态到底代表什么,我们其实是不关心的,我们只关心状态转换。因此我们完全可以把状态集看成一个状态。这是子集法的核心思路。来看个例子:
- 首先给定一个初态{p}。注意,最开始其实下面的几个状态是没有的,等待后续填充。
- 对初态进行 I a I_a Ia运算,得到两个新状态集1,2,这两个新状态集就作为新的状态使用。
- 然后再对两个新的状态集(1,2)分别进行 I a I_a Ia运算。
- 这么个一直运算下去,直到再也无法产生新的状态集,这张NFA状态矩阵也就铺好了。
之后就把状态集标号,用新的号作为新状态,如此NFA就变成DFA了。
注意,NFA确定化不能改变NFA的计算能力,但是可以为下面的简化做铺垫。
DFA最小化:划分法
DFA最小化能够真正地降低计算成本。
- 无关状态:走不到的状态
- 等价状态:两个状态,在接受同样的输入后,可以转到同样的新状态(新状态集)中。即输入输出转移方向都一样
DFA最小化就是要消除冗余,把无关状态全部去掉,把等价状态全部合并。无关状态顺便就可以去掉,关键在于如何合并等价状态,划分法的思路是分治思想。先把整个DFA的状态集一分为二,之后判断两个状态集内部是否可分,如果状态集内部可分,那就继续切割,继续递归分治。
什么是可分呢?其实就是状态集内部的状态不完全等价。比如{2,3,4,5},这个状态集内部23等价,45等价,那么这个状态集就可以分成{2,3},{4,5}两个新状态集,然后你再去新状态集内部判断装态是否完全等价。
看下面这个例子:
- 通常来说,最初的一分为二是将状态分为终态集合和非终态集合。
- 之后判断{0,1}集合。可以看出,0和1在做a运算的时候,都进入了{0,1}集合,而0和1在做b运算的时候,都进入了{2}集合,这两个集合是我们已经分好的,说明结果都是一样的,因此0和1等价,内部不可分,整体看做一个集合。
- 注意,是都进入了{0,1}集合,他们的结果不一定一样,就算0做a运算结果为1,1做a运算结果为0,他们也都算进入了同一个
状态集
,因此也是等价的,不可分的。所以再次强调,可不可分取决于结果的状态是否在同一个状态集,而不是结果的状态一样。
上面这个太简单,上点强度,看这个文章里的例题。万变不离其宗,关键要明白,结果只要在一个集合里就行,不一定要完全一样:
DFA识别与报错
如果能一直走下去,那么DFA就是正常识别。
如果走到某一步,其状态和输入对应的下一个状态是空,且当前状态非接受态,那说明走不动了,此时就可能有问题:
- 最长前缀匹配原则:倒序查找已扫描字符串,找到对应终态的字符,这个字符和前面的识别成一个单词。然后从下一个字符重新开始识别新的单词就可以。
- 如果没有,那就确实是出错了,采取错误恢复策略。最简单的方法是“恐慌模式”,此时再来字符就都丢弃,直到来了一个能继续走下去的字符为止。
语法分析
自顶向下分析
自顶向下和自底向上
最左推导:选择最左非终结符
最右规约:合并最右终结符
反过来类似,最右推导,最左规约,这两个都是规范的。
推导是无穷的,但是最左/最右是唯一的。虽然最右推导是规范,但是自顶向下的语法分析采用的却右是最左推导。
其实也合理,就是进来一个字符,我就进行一系列推导先推出这个字符。
递归下降分析。可以思考一下过程。
文法转换
自顶向下的过程比较机械,不是任何文法都可以用自顶向下分析的,如果不合适,就需要转换文法。
左递归的问题
消除直接左递归
间接左递归
间接左递归的原因在于,第二个产生式里面有S打头的候选式,我们把这个候选式消灭就可以。具体消灭就是代入,此时又会产生直接左递归,进一步消灭一下就好。
提取左公因子
LL(1)文法
回溯影响效率,不如提前预测,LL(1)文法具有这个能力
S文法
不允许空产生式。所以这你玩个锤子,不够灵活。
q文法
允许空产生式了,但是有一些问题需要解决。
什么时候才能用空产生式?我们用空的目标是为了在后面碰到当前的输入字符,所以我们首先要直到,当前非终结符A的后继符号集FOLLOW集
我们进一步优化,把非空也一起带上,给产生式定义一个可选集(这其实就是他可以读取的输入)
SELECT集。
这个东西就叫q_文法,同S_文法一样,都是确定的,但是适用范围仍然有限。
LL(1)文法
LL(1)文法允许右边候选式以非终结符打头,更复杂了,所以又得引入新东西。
同一非终结符的各个候选式的SELECT集不可相交,这样就不会出现不确定性。
FOLLOW、SELECT、FIRST集的计算
这三个东西感觉容易混,特此强调。
FIRST集是针对LL(1)文法的,LL(1)文法的SELECT集看似和q文法不同,但是其实是向下兼容q文法的。在有空候选式的情况下,考虑FOLLOW集,否则就直接看FIRST集。
因为q文法比较简单,候选式开头一定是串首终结符,所以其FIRST集直接看候选式开头就可以。而LL(1)文法比较复杂,还得推导一下。
FIRST集计算
举例子。首先明白,FIRST集代表可以推导出的所有可能的串首终结符集合。
下图中,245这三个,串首终结符是固定的,FIRST集已经确定。1依赖3,3又依赖5,所以直接把5的给3,3的给1。
这只是一个简单的例子,但是阐述了一个核心道理:具有依赖关系的非终结符,被依赖的那个非终结符的FIRST集,可以直接给依赖者。
给出复杂的算法,这是一个递归算法,核心就是那个依赖关系。考虑到依赖关系,你这个算法得迭代多次,直到一轮下来没有变化为止。
- 依赖关系的最底层,X本身就是终结符,X推出空集也算是终结符。
- 对于一个非终结符,从前往后扫候选式,把第一个符号的FIRST集加入就可以。
- 有时候,第一个符号可以被推导为空串,那此时实际上就是由第二个符号来作为第一个符号,所以我们把第二个符号的FIRST集加入。
- 如果前i-1个符号都可以推出空集?那就依次,从前到后一个一个加
注意,只有当X完全推出 ϵ \epsilon ϵ,此时 ϵ \epsilon ϵ才能加入到FIRST集中,如果后面还有东西, ϵ \epsilon ϵ开头算不得串首终结符。
再拓展一下,把一个非终结符扩展为一个串,其实还是依赖关系。
- 先看串的第一个符号的FIRST集
- 如果有空的可能,就把下一个符号的FIRST也加入,以此类推。
- 如果所有都可能空,就加入 ϵ \epsilon ϵ
FOLLOW集计算
计算FOLLOW集之前,应该先算出FIRST集。
先把语句结尾放到开始字符里面,之后从上往下扫描,观察产生式的右部中的每一个非终结符。
- 对每个非终结符,右边串的头部=当前非终结符紧跟的字符,所以先把其右边串的FIRST集加入FOLLOW集。
- 对处于末尾的非终结符(或者后面的串可能变成空串),产生式左部后面的东西=当前非终结符后面的东西,所以把左部非终结符的FOLLOW集加入当前非终结符的FOLLOW集
- 考虑到依赖关系,这个过程还是得扫描若干次才能稳定
SELECT集计算
终于到了最关键的地方了,SELECT集计算出来以后,如果相同左部的不同产生式SELECT集不相交,就可以针对每个非终结符,指定可以捕获的输入了。
说白了,就是利用已经计算好的,非终结符的FIRST集,去计算生成式右部串的FIRST集,如果FIRST集里没有空串,那么就直接用,否则还要带上左部的FOLLOW集。
上述分析后,可以得出,相同左部的不同产生式的SELECT集不相交,所以是LL(1)文法。构造预测分析表(是不是很像状态矩阵),每一个格子都是确定的。
LL(1)文法的预测分析法
首先要有一张预测分析表,这是所有LL(1)文法分析的基础。
递归的预测分析法
首先是主程序,读取一个TOKEN,然后从顶层分析函数开始递归过程,最后完成后,读取一下是否是EOF标记,如果是,那么就接受,否则就出错。
之后就是每个文法具体的递归处理函数了:
将文法的右部词语按顺序分成两类:
- 终结符。判断TOKEN与其是否相等
- 非终结符。读取新的TOKEN,以这个为参数调用对应非终结符的递归处理函数
由此,函数去调用函数,程序就会如同语法树一样从上而下展开。
非递归的预测分析法
下推自动机类似于FA,但是更牛逼,因为有一个栈专门用于储存信息。
下面那个例子,给FA去识别是比较难的,因为他记不住你读了几个a,只能通过增加状态数量来代表读了几个a,很有限。而下推自动机,可以在读a的时候,不断压栈,直到读了b,用b和栈顶的a去配对消消乐,如果读到最后栈里面元素为空,那ab数量就恰好相等了。
过程还算简单:
- 给左右两边都加上输入结束符号$
- 判断栈顶
- 如果栈顶一样,就消去两个一样的非终结符
- 如果是一个非终结符,一个输入符号,那就在文法分析表M中,寻找当前两个栈顶对应的表项,把非终结符进行推导,并且输出推导的生成式。
- 如此循环,直到最后两边都空
预测分析整体过程
其实递归预测没啥优点,所谓的直观性,谁去看你的代码啊,没啥用,还是非递归的好用。
预测的过程中还可能发生错误。
说白了就是在分析表里的格子是空的,此时编译器认为走不下去了。但是实际上,如果可以牺牲一个非终结符,用剩余的部分推导出完整的输入,也行。凭什么你就说弹出这个非终结符,剩余的部分就可以匹配当前输入呢?
就在FOLLOW集里,FOLLOW集代表当前非终结符后面可能跟随的终结符,如果这个终结符和输入匹配,那就可以继续走下去了。
自底向上分析
自底向上分析概述
自底向上和自顶向下反过来。我们再自顶向下分析中,采用最左推导,即每次针对最左边的符号进行推导。而在自底向上分析中,采用最左规约,看似都是左,其实最左规约是最右推导的逆序,是把已经读入的字符串的右部规约。
这个过程有点像,算法题里的括号匹配,只是我们这里的匹配规则比较复杂,要通过文法来匹配,匹配到的右部叫句柄
自顶向下的关键问题是,选择哪个产生式,而移入规约框架的关键问题类似,是选择哪个句柄进行规约,正如我们前面所说,一个产生式的右部,不见得就是当前给定输入句型的短语。
LR分析法概述
LR中的R,指的是最右推导序列,与我们使用的最左规约是等价的。
具体分析过程如下:
- 初始状态
- 移入后双栈对齐状态
- 判断Action表,进行下一步操作,移入/规约/接受
- s代表移入,n代表移入的状态
- r代表规约,n代表要使用的产生式
- acc代表接受
- 判断Action表,进行下一步操作,移入/规约/接受
- 规约后状态栈少一个的状态
- 判断GOTO表,给状态栈补上新状态
有了表以后,分析的还是很简单的,难点在于这个LR分析表从何而来。
LR分析表生成
LR(0)分析
这个点,左边的是已经进栈的,右边的是期待进栈的,因此每个产生式都有若干个状态,这若干个状态相邻的都是后继项目
,每个状态都是LR(0)分析的条目
这么多状态的转换,是比较复杂的,可以把一些等价的状态合并,变成项目集闭包。
首先画出状态图。以 I 0 I_0 I0举例:
- 起始状态闭包是 S ′ → S S^\prime\rightarrow S S′→S,是我们增广的那个项目
扩充闭包
:从起始状态开始,对新的状态进行闭包扩充- 对于已有的项目,其点后面的第一个非终结符对应的文法右部就是新的条目。
- 举个例子,比如
I
0
I_0
I0
- ·S是在等待S,S可以推导出BB,所以其实也是在等待BB。
- 现在已有的项目里又多了个等待B,B可以推导出aB和b,所以等待B也是在等待aB或者b
- aB和b都有串首终结符,所以不可能继续等下去了,就此终止
- 可以看出,闭包的过程就是一个旧的产生新的,新的再产生更新的,直到遇到串首终结符停止。
求新状态
:对一个状态扩充完毕后,就可以准备迈向下一个状态了- 如果一个状态中,只有一个规约项目,那么这就是个
规约状态
,没有边引出 - 对于
非规约状态
,给定一个输入,就可以引出一条边,边上就是点后面的符号,即我们期待的符号,然后我们可以得到项目的后继项目。后继项目不一定只有一个,如果有多个项目期待同一个输入,那么就去往同一个新状态。- 如果后继项目不可以在已有状态中找到,则以该项目为第一个项目,进行步骤2
- 如果后继项目可以在已有状态中找到,则这条边指向已有的状态
- 如果一个状态中,只有一个规约项目,那么这就是个
之后根据状态图写表:
- 特殊的接受态单独写出
- 非规约状态
- 边为终结符,则ACTION移入
- 边为非终结符,则GOTO
- 规约状态。
- ACTION全部写为规约
- GOTO不用写
我们前面还没有考虑到冲突的情况,我们说,当一个状态里,只有一个规约项目时,该状态为规约状态。那么同时有一个规约项目和非规约项目时,就会冲突,我到底是规约还是移入呢?
所以第一种冲突叫移进规约冲突
还有一种叫规约规约冲突
。这个就更离谱了,如果一个状态里面同时出现两个规约项目,就会冲突,比如下面状态2的B和T
SLR分析
SLR——Simple LR(1),用于解决LR(0)的移进规约冲突和规约规约冲突。先介绍一下SLR分析的基本思想:
对状态2来说,发生了移进规约冲突。这是因为LR(0)太过短视导致的。如果我们把T规约成E,你会发现,E的FOLLOW集里没有星号,也就是说,如果你选择规约,那么后面如果出现星号,这个文法就会出错。
所以在分析的时候,可以参考FOLLOW集与下一个输入的关系。
具体怎么做呢?首先要确定一个前提,就是1个可移进集合与n个规约项目FOLLOW集,这n+1个集合互不相交
。所谓可移进集合,指的是每个项目的点后面紧跟的符号,构成的集合。
之后,根据输入符号进行操作:
- 如果可移进集合里有这个符号,那就移进最好
- 如果FOLLOW集里有这个符号,那规约了就没什么危险,后面还会碰到
- 都没有,那就没机会喽
SLR分析是基于LR(0)分析的,对LR(0)分析表的冲突项目进行分析与调整,这就导致,分析表的规约状态里面,不一定都是规约动作了。或者说,这个状态也算不得完全的规约状态。
下面来个例子,状态2有冲突,其中两个规约项目的FOLLOW集列出了,而移进集合为{a}。由此,直接判断输入为a时,移进,b和$规约到T,d时规约到B,这样,移进规约冲突和规约规约冲突就都解决了。
当然,SLR也可直接嵌入LR(0)分析中,只需要略加修改即可,即碰到规约项目时,并不是无脑规约了,而是输入在FOLLOW集才规约。
LR(1)分析
SLR分析的大前提是,那几个集合不相交,但是如果仅仅利用FOLLOW集的信息,还是有可能满足不了这个前提,所以需要用更多的信息,就是我们的老朋友:FIRST集合。
FOLLOW集是规约的必要条件,只能代表在一个文法中,这么规约以后,后面可能碰到的第一个非终结符。很明显,我们是分析特定句型,单纯用FOLLOW集不适用于特殊情景,即FOLLOW集不是规约的充分条件,所以需要收缩集合,此时就要用到FIRST集合与展望符。
规范LR(1)项目是一个LR(0)项目+一个展望符。展望符只是用来为规约服务的,告诉你规约以后后面必须紧跟哪个终结符,如果这个项目不是规约项目,那么就没用,这个时候就看那个可移进的符号。
同LR(0)项目,LR(1)项目也有等价项目,在扩展闭包的时候要用。等价项目的生成式很容易写,但是展望符怎么得出呢?
就用FIRST集,比如下面,等价项目的左部是B,那么其后面紧跟 β \beta β,这个东西的FIRST集元素就是展望符,这个就是自生展望符,当然,考虑到他可能是空集,所以最坏的情况就是继承A的展望符。
其实这里的FIRST集很好算,你就看 β \beta β的串首终结符就好,因为我们只要一个。空就继承。
写LR(1)自动机图的过程和LR(0)基本一致,区别在于:
- 等价项目需要多考虑一下展望符,是继承还是自生。好在只需要直接看一眼就行,不用按照FIRST集的算法去算。
- 即使生成式一样,展望符不一样也是完全可能的,比如继承和自生可能就不一样。
LALR分析
上图中,有一些状态,除了展望符两两之间完全一样,那么这些状态就是同心的
。LR(1)本质上是通过用展望符,将状态分裂,细化,这么做的缺点是状态变多了,甚至翻倍。
然而这么做除了让得出自动机更方便以外,并没有什么好处,同心状态其实是可以合并的,因为其左边的行为完全一致。
LALR可能会产生规约规约冲突,不过不会产生移进规约冲突,因为我们只是去合并展望符,展望符只在规约时起作用。
同时,LALR虽然可以节省空间,但是可能会推迟错误的发现。
总结
LR(0)给出了最基本的扩展闭包,以及画自动机图的流程。
SLR加强了LR(0),但是很有限,LR(1)大大加强,但是消耗资源很多,LALR(1)缩减了LR的同心状态,分析效率会弱一些。
不过貌似实际上SLR考的比较多,可能是比较简单。
错误恢复
略。
语法制导翻译
概述
语法,语义,中间代码生成,这三个东西很容易就可以放到一起,即SDT(Syntax-Directed Translation)
SDT使用文法来指导翻译。
首先明白什么是语义,计算机里的语义,就是词的各种属性值,我们求解语义,其实就是在计算各种词的属性。
所谓的语法制导翻译,就是让语义在语法分析的过程中,顺带被一起计算了,为了打成这个目标,需要把语义和语法绑定,也就是下面的SDD。
SDT是SDD的具体执行细节,是把语义规则嵌入到产生式里面。
举个例子,看第一个产生式,他的意思是,先解析出T,然后把T.type赋值给L.inh,之后再把这个属性传给推导出的L。
语法制导定义SDD
-
综合属性。节点值依赖子节点。其对应的符号位于生成式左部
- 终结符,它一定是综合属性,值由词法分析器提供。
-
继承属性。节点值依赖父节点,兄弟节点,N本身的属性。其对应的符号位于生成式右部。
下图是带有属性值的分析树,叫做注释分析树
。下面的文法只有综合属性,所以又叫属性文法
,是单纯用来计算属性值的,没有副作用。
下图这个SDD树就是有继承属性的(也有综合属性)。
从前面的图大概也可以看出来,属性值的计算要通过依赖关系实现。依赖图
有点像注释分析树,结构一样,只是没有标出属性值。
- 相比于分析树,依赖图要在树的边上,附加箭头,被依赖的指向依赖者属性字段。
- 属性字段的写法也比较有意思
- 综合属性写右边
- 继承属性写左边
求值的顺序应该是这个有向图的拓扑排序。只有综合属性就自下而上就行,很简单,但是如果还有继承属性,就需要拓扑排序,即使如此,也不一定能保证一个SDD一定没环。
S-属性定义与L-属性定义
S-SDD就是只有综合属性。
L-SDD是S-SDD的扩充。
L-SDD允许继承属性,但是对继承属性有严格限制,L-SDD适合自顶向下的LL分析,所以计算顺序也是从左到右。
- 不能继承自综合属性,否则就会成环
- 只能依赖左边兄弟,不可以有右边兄弟出现,否则就不能从左到右算了。
- 可以依赖自己,但是不要形成环路
下图中,第五个产生式,Q.i依赖于R.s,这是右兄弟,不符合L-SDD。
语法制导翻译方案SDT
SDT将生成式与语义表达式结合起来,规定了计算的具体顺序。当进行语法分析的时候,只需要按照SDT规定的计算顺序,就可以得到目标的语义。
S-SDD的SDT实现思路
首先确定这个文法是LR文法,将S-SDD转换为SDT。
然后构造文法的自动机。
还需要做的一件事就是给属性值扩展栈的空间。一般就是一个属性,多属性可以多开点空间或者用指针,写的时候不影响。
之后针对新开的栈,把抽象动作具体化。
最后就是执行LR分析,跑自动机图就可以,每次规约的时候,就进行一次SDT操作。
最后就可以得到属性值。
L-SDD的SDT实现思路
同样,先把生成式和动作合并:
- 综合属性还是和以前一样,接到最后继
- 承属性需要插到这个属性的非终结符的左边。
然后判断一下这个文法是不是LL文法,具体判断就是计算SELECT集。如果是LL文法,那么就可以使用三种方法实现SDT。
如果只是为了应付考试,学一种就差不多的了。