这学期我们开设了编译原理这门课程,我原本想通过自身的力量整理出一份学习笔记,但是奈何时间有限,诸事缠身,未能如愿。但是在最后期末复习的过程中,我协同一些朋友一同整理出一份编译原理学习笔记,是跟随者编译原理-华保健这门课程整理出的,这份笔记是大家协同的成果,在此鸣谢所有为这份笔记贡献的朋友们!如果有的学弟学妹们有缘看到这份笔记,并能从中得到一些帮助我会很高兴的,也欢迎有问题和我联系!
资料与资源区
备注:可以尝试坚果云,比百度速度快!
划重点的课件:划重点的课件
简书网上学长的资料:简书网上学长的资料
编译原理多合一pdf:多合一pdf
编译原理b站的视频:b站视频
编译原理的软件学院往年回忆卷子网上回忆卷
编译原理实验相关实验报告参考
重点范围区
第一章
- 编译器的结构是什么(不是重点但必须掌握)
- chap1-2课件
- 编译器完成任务的流程
- chap1-3课件
- 把后面学完以后再看1-2,1-3
- 怎么实现编译器?它的过程是什么
考核重点:词法、语法、语义分析
第二章
- 词法分析器是干什么的?
- 为什么要数据结构的定义?
- 词法分析器的任务?
- 词法分析如何完成:手工方式、自动方式
- 手工编码的基础:转移图
- 转移图相关
- 如何完成自动分析
- 自动分析的理论基础:正则表达式(重点)
- 正则表达式的表现形式等等
- 有限状态自动机(重点)
- 状态机的例子搞清楚
- NFA和DFA区别
- 图具体的每一步骤搞懂
- nfa到dfa的转化(重点)知道算法是什么意思
- 子集算法怎么构造的怎么实现的
- dfa最小化:hop croft算法,要知道怎么用
- dfa的几种代码表示方法
- 知道最长匹配是什么意思
第三章 语法分析
- 每一个ppt都重要
- 语法分析的任务
- 上下文无关文法(理论基础),看懂例子
- 分析树的概念、特点
- 怎么进行分析树,怎么推导的
- 二义性文法的概念
- 一些处理方法
- 语法分析的方法:自顶向下,自底向上
- 自顶向下的思想
- 看看例子
- 算法的讨论不用关注
- 递归下降
- LL(1)(重点)
- 什么叫表驱动的算法
- 集合的运算
- 有什么方法消除冲突
- LR(0):自底向上(重点)
- LR(0)基本流程和算法思想
- SLR分析算法
- LR(0)的缺点
- 明白方法之间的关联性
- SLR和LR(0)的区别与联系
- LR0和LL区别与联系
- LR1更难,有前看符号
- LALR知道就行
- 把ppt过一遍,尤其概念
第四章 语义分析
- 概念 任务
- 怎么进行语法检查
- 算法怎么用
- 符号表基本原理、概念、作用
- 其他问题看一眼
第六章 代码生成
- 全看
- 明白原理
第七章
- 7-1一定要看
- 中间代码是干什么的
- 有几种中间代码
- 为什么要中间代码
第八章 代码优化
- 牛的话都看,不牛看8-1
总结
- 第六章全部看
- 第五章 5-1,5-2,5-3,5-4简略看
- 第四章 主要是概念 原理描述
- 第三章 、第二章是难点
- yacc不看
- LR1看不懂就算了
- 第一章1-2,1-3
第一章-编译器的介绍
编译器的概述
编译器将高级语言翻译成机器上可以运行的目标语言,并可以在机器上执行。
比如将c语言翻译成汇编程序代码,高级语言到体系结构的翻译工作都是由编译器完成的
编译器是一个程序,核心功能是把源代码翻译成目标代码。
编译器的核心功能
1.源代码经过编译器的翻译生成目标代码,静态计算的意思是对源代码翻译的过程并不去执行代码,而是对尝试以静态的方式对程序员写的代码的意思加以理解,这样做的目的是确保语义相同,源代码要实现的功能,和目标代码要实现的功能一样,保证意思是一样的。
2.生成了目标程序之后,计算机需要对目标代码执行来得到结果。计算机不一定是PC,还可能是JVM等,目的是完成动态计算得到结果。
编译器和解释器
解释器也是处理程序的一种程序,但是它和编译器有一些区别,编译器接受输入输出的是存放在磁盘上的可执行程序,(称为离线方式),而解释器输出的是程序的结果(称为在线方式)。
编译器的简史
计算机科学史上出现的第一个编译器是Fortran语言的编译器,推动了理论发展、实践发展、编译器发展
编译器的结构
编译器的高层结构
编译器是具有非常模块化的高层结构
编译器从内部结构分为前端和后端,前端主要处理输入部分,这是什么语言,要满足那些语法规则、要满足那些约束条件等等,对于后端来说主要关心要翻译到的目标机器有哪些指令集,这些指令集有哪些约束,前端的语法结构如何映射到指令集中。
为什么要分前后端? 有助于把任务隔离,使得编译器设计更具有模块性,且容易
前端功能还可以分为
- 词法分析部分
- 语法分析
后端功能还可以分为
- 指令生成
- 指令优化
抽象的多个阶段
编译器可看成多个阶段构成的“流水线”结构
- 编译器可以看成流水线的模式
- 中间有多个中间语言,最终从输入到达&输出,每个阶段完成相应任务,将输入转化成等价的输入,比如I'转化为I''
为什么要分成多个阶段?
- 高级语言相对于目标机器来说相当高级,一次到达目标语言非常困难,将这个过程切分为不同阶段,可以逐渐接近目标语言
- 从软件工程角度,来说阶段化,可以使得每个模块来说容易实现和维护
编译器结构实例
一种没有优化的
- 最开始输入是一个字符序列,就是程序代码
- 第一个阶段是词法分析,输出记号序列
- 记号序列会被语法分析处理,检查语法是否合法,在内存中会建立抽象语法树
- 之后进行语义分析,对语法的合法性做处理,变量在使用之前有没有声明,一些规则
- 当程序没有问题后,就会生成中间代码,比如三地址代码,ssa,控制流图等等
- 整个过程中都需要对符号表进行交互
更复杂的编译器结构
虽然阶段变多了,但是还是流水线的结构
总结
- 编译器由多个阶段组成,每个阶段都要处理不同的问题,使用不同的理论、数据结构和算法
- 因此,编译器设计中的重要问题是如何合理的划分组织各个阶段,使得接口清晰编译器容易实现、维护
具体实例
输入是一个加法表达式子,输出是结果
- 3是一个整型数字,直接就是结果
- 3+5,左右两个表达式3,5都是加法表达式,所以3+5是加法表达式
- (3+4)+5要保证结合性质
现在目标机器是栈式计算机,它仅有push n和add两条指令
push n 就是压栈 add 其实包括多条指令
x=pop()
y=pop()
z=x+y
push z
- 输入‘1+2+3’一个字符串
- 前端接受输入输出是一个树状结构,根节点是加法,左子树是加法节点,这个树叫做抽象语法树
- 之后会产生代码生成,产生栈式计算机的代码
- 结果是树的后续遍历(左右根),遍历规则,如果是数的话就push n,如果是加法节点就生成add指令
结果是
push 1
push 2
add
push 3
add
经过这个过程就会生成指令
小结
- 前端接受输入生成语法树的中间表示,后端做代码生成
- 中间的接口就是语法树
- 如果增加优化部分,在前端生成语法树之后,在交给优化阶段,优化阶段生成一棵优化的语法树,在交给后端
编译器的例子
该编译器接受一个源语言叫做Sum,Sum语言仅仅只有两个语法形式,语法形式一是整型的数字n,第二个形式是e1+e2,其中e1和e2是加法表达式。目标机器是一个栈式计算机,它包含一个操作数栈,这个计算机仅仅只有两个指令,其一是压栈指令push,其二是加法指令add
源语言Sum
符合这种语言的例子
1.3
2.5+6(归纳方法,左右都是,加起来也是)
3.7+8+9(注意要满足加法左结合((7+8)+9))
栈式计算机
栈式计算机和数据结构中的栈特别类似,也是先入后出的一个结构。它包含一个栈顶指针top
它包含的指令有
push n(压栈)
add(加法指令)
3+4+5,执行的过程
push(3)
push(4)
push(5)
x=pop()
y=pop()
z=x+y
push(z)
x=pop()
y=pop()
z=x+y
push(z)
编译器的阶段
语法分析 判断输入的程序由那些部分组成,并进行分析 比如把把'1+2+3'拆分成数字1,2,3,符号+,+ 2.语法树的构造 对程序抽象的内部表示,从包含了运算的顺序,比如左结合计算 3.代码生成
- 对语法树进行后续遍历(左右根)
- 如果是整型数字n生成push(n)
- 如果是加法节点生成add
先遍历到节点1
push(1)
节点2
push(2)
节点+号
add
节点3
push(3)
add
总结
编译器的构造和具体的编译器目标相关
第二章-词法分析1
词法分析的作用
编译器阶段
- 编译器接受一个源程序作为输入
- 编译得到一个和源程序等价的目标程序并运行
如果将从更为细节的结构来看,可以分为若干中间阶段,包括前端,后端,前端接受源程序得到中间表示,后端接收中间表示继续生成目标程序。前端主要处理和源语言程序相关的属性,而后端主要处理目标机和体系结构相关的属性。
前端又可以分成如下过程
- 源程序作为输入经过词法分析器得到记号
- 记号流进入语法分析器,生成抽象语法器
- 之后将抽象语法器交给语义分析器生成中间表示
词法分析器的任务
词法分析器任务读入程序员写的程序,将程序切分成记号流
例子:
将输入的程序,按照关键字进行拆分,拆分成一个个单词,词法分析的任务就是从字符流到单词流的转换
记号的数据结构定义
记号数据结构的定义
enum kind{IF,LPAREN,ID,INTLIT};
struct token{
enum kind k;
char* lexeme;
}
例子:
比如if(x>5),按照顺序读取,读取的记号:
IF,括号,变量x,它的值是x,>号,数字,括号
小结
词法分析的任务:字符流到记号流
字符流:和被编译的语言密切相关(ASCII,Unicode,or...) 记号流:编译器内部定义的数据结构,编码所识别出的词法单元
词法分析器的手工构造
词法分析器的实现方法
词法分析器的主要实现方式主要包括
- 手工编码实现方式
- 词法分析生成器
| 实现方法 | 优点 | 缺点 |
|:----|:----|:----|
| 手工编译器 | 效率高,容易控制细节 | 相对复杂、容易出错 |
| 词法分析器 | 快速原型,代码量少 | 难以控制细节 |
手工构造编译器
假设一共有6个关系运算符,<=,<>,<,=,>=,> 程序最开始的状态是0号节点start,读取第一个字符c1,分情况讨论<、=、>,如果c1是<,那么走到1状态上。接着读入字符c2,如果c2是=运算符的话,就走到2这个状态,如果c2是>的话,那么就走到3这个状态上去。2和3都是终止状态,图中它有外部两个圈,代表识别状态,返回所识别的词法符号的内部数据结构。4号状态上还有个*号,当1状态接收其他字符走到4状态,会将读到的c2扔回到缓冲中,代表回退
伪代码描述
语言中其他符号的识别,标识符的转移图
C语言中标识符以字母或者下划线开头,后面跟0个或者多个下划线或者数字
- 首先读入第一个字符,字母或者下划线
- 再次读入,如果是字母或者下划线的话,继续接受,否则就转移到2状态,状态2是接受状态,会返回已经识别的Id,*代表多读的字符扔回到程序中去
标识符和关键字
很多语言中的标识符和关键字有交集从词法分析的角度看,关键字是标识符的部分,以c语言为例,c语言的标识符以字母或者下划线开头,后面跟着0个或者多个字母、下划线、或者数字,关键字包括if else while...
识别关键字
1.在原来的状态转移图上扩展新的节点和边
原来的0到1状态转移中,把i抠出来了,所以从0到1不会识别if,从3上面接收f的话,会转移到4,否则就会转移到其他状态
关键字表算法
- 对给定语言中所有的关键字,构造关键字构成的哈希表H
- 对所有的标识符和关键字,先统一按标 识符的转移图进行识别
- 识别完成后,进一步查表H看是否是关键 字
- 通过合理的构造哈希表H(完美哈希),可以O (1)时间完成
正则表达式
什么是自动生成?
正则表达式是一种自动生成的方式,它接受的是声明规范,仅仅需要输入识别单词的规则就可以,也就是要识别的目标。有一些工具可以帮我们产生词法分析器,程序员就不用写大量的代码,仅仅需要写一个规范就可以了,可以帮助程序员减少工作量。
什么是正则表达式
首先会给一个字符集,这个字符集中有很多不同的字符。它有一个归纳的定义。
- 空串是正则表达式
- 对于任意属于字符集的字符,都是正则表达式
- 如果是M和N都是正则表达式,就可以根据以下的三种规则构造正则表达式
选择 M|N={M,N}
连接 MN={mn|m∈M,n∈N}
闭包 M*={空串,M,MM,MMM,...}
归纳情况的理解:
- 选择算符是表达的结合是M∪N,是一个并集
- 连接运算符代表两个串拼接到一起
- 闭包的意思是一元运算符,仅作用在一个字符上,它包含了空字符,1个字符拼接,两个该字符拼接,n个该字符拼接...的集合
正则表达式的形式表示
对于要定义的正则表达式的语法形式e,前面两种是基本形式,后面是三种形式选择、连接、闭包。
问题:对于给定的字符集Σ={a,b},可以写出那些正则表达式?
1.构造空串Σ
2.a,b都是正则表达式
3.Σ|Σ,Σ|a,...
4.连接运算Σa,Σb,ab,ΣΣ...
5.闭包Σ*,a*,(a|b)*
正则表达式的应用例子
if关键字
if拆分成i、连接符、f,i是正则表达式,i∈ascll码,f∈ascll码,所以连接起来也是正则表达式
c语言中的标识符
标识符是满足以字母或者下划线开头,后面跟着0个或者多个字母或者下划线,拆分成两个部分,中间用连接运算拼在一起,前面是以字母或者下滑线开头,(_|26个字母的大小写),后面是一个闭包包括0-9,26个字母大小写加上下划线组成一个闭包
(a|b|c|...|z|A|B|C...|Z|_)(a|b|c|...|z|A|B|C|...|Z|0|..|9|_)*
c语言的无符号整数
0 | (1|2|3|4|5|6|7|8|9)(0|1|2|3|4|5|6|7|8|9)*
或者用语法糖:0 | (1-9)(0-9)*
语法糖的概念
语法糖的目的是为了简化构造,减少代码量,其实是对一些关系运算的封装,可以简化写代码,语法糖不是必须的,但是使用语法糖会使得写正则表达式更加方便
有限状态自动机
为什么需要有限状态自动机?
如果要生成一个词法分析器的话,仅仅需要写一个声明式的规范,也就是所谓的正则表达式。通过词法分析器的自动生成器比如flex这样的工具,之后就会生成词法生成器。为了描述输出具体是什么,因此需要有限状态自动机。
有限状态自动机的数学概念
整体作用 它接受输入的字符串作为输出回答Yes或No,它会告诉你它能不能接受你给它的字符串,如果是的话回答Yes,否则回答No
输入的参数
- Σ是一个字母表
- S状态集合,有限自动机的状态
- q0是初始的状态
- F是终结状态集,注意它不是一个状态而是一个状态的集合
- 最后是转换函数,它描述了自动机在给定的字符串的情况下是如何动作的
自动机的实际例子
例子1
1.黄色的圆圈里面带有的数字是状态的编号,共有0,1,2三种状态
2.五元组和该例子的对应
- Σ是识别字符的集合,这里是a和b
- 状态集是0,1,2三种状态
- 起始状态是q0是单向箭头指向的节点是起始状态,这里是0号节点
- 终结状态是一个集合,在一个状态节点上画双圈就是这个状态就是接受状态,这里有一个接受状态,这里的2状态是终止状态,注意再一个自动机中可能有多个状态
- 转移函数写在了下面
3.转移函数的解释
假设读入串"abab"
- 先看第一个字符a,就会从0号转移到1号状态
- 接受b,自环边转移到1
- 接受a,转移到2
- 再接收b,自环到2
状态转移函数,已知当前的状态的情况下,再知道读入字符是什么,读取不同的字符会转移到不同的状态
转移函数的意义
{(当前状态,读入字符)->转移到的状态)....}
状态转移函数也是一个集合,它包含了各种各样的映射
什么是接受?
一开始处于起始状态,输入字符串后,如果字符已经读完了,并且顺着状态转移函数最后走到双圈的接受状态,就称这样的串可以被自动机接受,如果没走到接受状态走到了一个非接受状态就叫做非接受状态。
例子2
和之前的例子不同,当前处于0状态,在读入a之后既可以走回路边走到0,也可以走到1状态,走那个都可以。
状态转移函数的值域变化了,在读入a之后目标状态是一个状态集合
这样的一个自动机,状态转移函数是不确定的,这样的自动机叫做非确定的有限状态自动机,简写为NFA,而前一个例子每次接受一个字符走到的状态是确定的,那种自动机叫做确定的有限状态自动机DFA。
NFA与DFA对比
NFA和DFA主要区别在于‘接收’的难度是不一样的。判断接受在DFA上很容易做,但是在NFA上难以判断。比如这个例子读入a,既可以留在0号状态,不接受,也可以走到终止状态,接受该串。到底看出那种情况呢?应该是接受的,怎么判断的呢?在接受一个字符串之后,不管怎么走,只要你可以走到接受的状态就叫做可以接受。如果一条路走不通,可能还要回溯判断能不能接受,为了解决这个问题,往往要做NFA转换成DFA,为了简化这样的判断。
有限状态自动机小结
自动机 | 特点 |
---|---|
DFA | 对于任意字符,最多有一个状态可以转移到 |
NFA | 对于任意字符,有多于一个状态可以转移 |
NFA甚至可以不消耗字符,接受空串转换状态。
DFA的实现
- DFA最简单实现方式是状态转移表
- DFA可以看出边和节点的有向图
- 边上面有信息,比如字符
- 节点上也有信息包括起始和结束节点
- 表示图比如邻接表、邻接矩阵
- 状态是节点的标号,字母是转移所接受的字符
第三章-词法分析2
RE转化成NFA-Thompson算法
- 我们实现一个词法分析器往往仅仅需要写一个声明规范re正则表达式
- 进而会有工具生成词法分析器DFA(NFA具有不确定性)
- 从正则表达式到DFA,中间有许多过程
Thompson算法
基本思想
- 基于对RE的结构做归纳
- 对基本的RE直接构造
- 对复合的RE递归构造
- 递归算法,容易实现
- 在我们的实现里,不到100行的C代码
回顾正则表达式的五种形式
- 对于前两种直接构造
- 第一种:从0号节点到1号节点不需要代价
第二种:从0号节点到1号节点需要指定字符
- 剩下三种需要递归构造
- 正则表达式连接
- 先构造识别e1,再构造识别e2
- 通过空字符连接,两者转换不需要代价
- 为什么不像下面那样画呢?
两者都一样,但是上面更工整
- 正则表达式选择
正则表达式闭包
示例
递归计算左边
- 递归计算右边
- 先考虑连接
- 对连接部分整体考虑闭包
NFA转换成DFA-子集构造算法
NFA的状态转移是不确定的,所以我们需要将NFA转化为DFA,我们需要选择使用子集构造算法将NFA转化成为DFA,不用回溯
算法思想
对于以上的正则表达式用汤姆森算法构造出来的NFA是这样的
我们要构造有限状态自动机,它接受的字符和NFA是等价的。
DFA的构造可以看做是对NFA状态的模拟
我们知道走ε边是没有代价的,所以当从n0->n1的之后,还可以滑动到很多节点
1.n0状态读入a之后可以走到的节点看做一个集合q1,q1包括6个节点
2.我们可以将可以走到的节点全都连接起来,叫做边界
3.在输入a,得到q1集合的基础上,我们再次读入b看在该集合上是否可以走到状态,得到集合q2
4.q2在继续走输入能走到那些节点,得到q3
5.之后我们可以根据各个集合构造自动机
全部过程书写如下(注意q2和q3之间还有一对转化箭头):
子集构造算法伪代码
- 不断构造集合,每一个集合都是所有节点构成集合的子集
- 给定一个集合,计算集合中每一个节点,通过ε 转换走到的节点是什么
- 首先计算起始状态的闭包,防止起始状态通过ε转换
- 将q0加到Q中,Q是DFA集合里面所有的,有限状态自动机
- 工作表一开始只有q0,其实际上是一个队列
6.当队列不为空:
从工作表中取出队首元素q
对于每一字符(ascll码256遍):
t又是closure(delta (q, c))
D[q, c]<-t
if (t\not\in Q)
add to Q and workList
DFA的最小化:Hopcroft算法
为什么需要DFA最小化算法?
之前我们已经学习了汤姆森算法和子集构造算法,得到了DFA。而接下来的Hopcroft算法是作用于DFA上面的,使得DFA最小化。进而使得DFA输出成词法分析器的代码。
现在用一个例子来说明为什么需要DFA最小化算法?
由该正则表达式a(bIc)*,我们构造出了NFA
之后我们通过子集构造算法可以构造DFA出来
现在q0是起始状态,q1,q2,q3都是终止状态,我们想问这些状态都是必须的吗?换一句来说是不是有一些状态可以相互合并?如果是接受状态和非接受状态合并显然是不可以的,那么接受状态与接受状态,非接受状态与非接受状态之间是否可以合并呢?
通过观察我们可以发现,q2,q2两个终止状态接受b,c两个字符都是在他们自身或者他们两个之间进行转化,而q1接收b、c分别可以到达q2,q3状态,那我们就可以将q2和q3这两个状态合并成一个新的状态称为q4,如图所示:
我们可以发现,这和原来的形式是等价的。
然而这样还不算完,q1和q4这两个节点也是可以合并的,合并成状态q5。
这样我们就得到了最小的DFA,除此之外,我们联系我们想要表达的正则表达式的话,这个构造NFA正是我们想要的样子a连接(b或c)这个闭包在无限的循环。
由此我们可以看出在已经得到DFA的情况下,我们还需要不断的合并,来获得最小的一个DFA,这样一个DFA最后要变成内部的一个数据结构的表示,显然当DFA边或者节点越小的话,它所占用的资源也就会越少,会提高算法的运行的效率,因此我们给出算法。
Hopcroft算法
该算法的思想是一个基于等价类的思想。
关于split函数
- split是一个状态切分的函数,S是一个状态的集合
我们画一个DFA可能不画状态的名字了,里面是一些状态和转移的边。每一些状态可以构造集合S,这里一共有三个集合S,分别是s1,s2,s3叫做三个等价类。等价类的意思是说一个集合中的节点,我们是难以加以区分的。将来我们要做最小化的时候,要将他们融合成一个节点。也就是转化成最小的集合DFA它仅仅只有三个节点,对应集合s1,s2,s3.
- 对每一个字符做循环,如果c可以对这个集合做切分的话,那么我们就将集合拆分成k个小集合,注意必须是真子集,注意不能是空的集合出来,这里我们可以想问的是什么是c可以切分子集S?标准很简单,将状态和边标注上。
我们可以看到q1,q2,q3都有对a的状态的转移,但是我们在做转换的时候可以发现,q1,q2对于a来说转移到的等价类集合是s2,q3在a的作用下转移到的等价类是s3,.我们注意到s2和s3是不同的等价类。q1和q2可以看成一组,因为它们对于a的行为上是一致的。q3给人的感觉从不直观的角度看就是它叛变了,它对于a的转移上没有到达s2而是到达了s3,因此我们可以看出a这样一个字符将s1这个子集切分成了两个集合。
hopcroft 函数
- 将所有的节点先拆分成两个子集N(非接受状态)、A(接受状态),N和A是水火不容的,不存在一个状态既是接受状态又是非接受状态
2.当仍然有集合可以被切开的情况下,我们继续划分子集
Hopcroft算法的例子1
- 切分两个集合出来N集合和A集合
2.接下来对于每一个字符,我们观察是否可以继续切分
3.对于N这个集合仅仅就有一个状态,它没法再切分了
4.我们接着看A这个集合,对于b这个字符,我们发现没有转换出这个集合的边,依旧指向A这个等价类,因为它无法区分出q1,q2,q3三个状态的可能性
5.同理c也没有能力将三个状态区分开
6.因而A集合中这三个节点是没法分开的,进而三个状态划分成了一个状态q4
7.画出来新的最小化DFA
Hopcroft算法的例子2
对fee|fie这个正则表达式画出我们的DFA,来进行最小化DFA
- 首先将其划分成N集合和A集合
- 我们先观察集合A,可以看出q3和q5两个状态都不接受任何的字符更不用说转移了,因为他们是终止状态,所以不可分
- 对于N集合,当循环到字符e的时候,e导致q2和q4都发生了转换,而q0和q1接受e的话都是在N内部转换
- 所以可以将N这个集合拆分成两个集合
- 对于{q2,q4}这个集合,都是接收e转移到{q3,q5}这个集合,该集合没法继续划分了
- 对于{q0,q1}这个集合中,q1接收e和i后会转移到{q2,q4}这个集合,所以将{q0,q1}可以拆分成{q0},{q1}两个集合
- 我们可以进而将最终的自动机画出来
从DFA生成分析算法
我们需要将DFA这样的确定的有限状态自动机转化成我们实际可以运行的代码
我们回顾一下,我们现在的阶段,已经得到了最小化的DFA,我们现在就差最后一步了,也就是将DFA转化成我们最后可以实际运行的代码。
DFA的代码表示
- DFA实际上是一个有向图,每一个状态可以看做是节点,而转移的关系可以看做是边
- 理论上我们可以用数据结构与算法中方法表示这样一个有向图,但是实际上我们往往用一下的方法来表示DFA
- 转移表
- 哈希表
- 跳转表
- 其他
- 实际我们最终选取的数据结构实际上还是考虑时间与空间上的权衡
转移表
比如像这样的一个自动机:
我们将其构造出最小DFA
根据以上的DFA我们构造我们的状态转移表
这个表的行是字符,表格的列则是状态,0代表q0,1代表q1,我们这样一个表可以用一个矩阵来进行表示,比如用c语言表示:
char table [m][n] ; // m是状态个数,n字符表示ascall表256
//初始化
table [0] ['a']=1 ;
table [1] ['b']=1 ;
table [1] ['c']=1 ;
//ther table entrie
// are ERRORa//其他表格填error
最终生成的代码由两个部分组成分别是:
- 转移表(上面说的)
- 词法分析驱动代码(词法分析器的驱动代码,驱动代码负责读从文本获得的输入,根据以上的表中相应的表项做相应的控制买来回答给定的输入是否能被DFA接受)
驱动代码
- nextToken()代表下一个记号或者下一个单词
- 每当调用这个nextToken()函数就会返回它接收串中所识别的那一段单词
- 一共有两个核心的变量分别是state和stack,state代表目前它所走到的那个状态,比如state=0,代表它目前走到了q0状态,stack是栈,一开始初始化为空
- 接下来我们看一下第一个的循环
当状态!=出错的一些状态(也就是当前是有效的状态):
c=getchar()//读一个用户给定的输入
看看状态是不是接受状态:
清空栈
将状态压入栈中
拿着c查转移表,看字符转换到什么地方去
为了更好的理解这个过程,一开始
- 初始的情况下我们位于q0状态,这个时候接受a这个字符,q0不是接受状态,而且栈现在是空的
2.接下来我们将0号状态压栈,查表看q0状态输入a字符后跳转到q1状态,state站在q1的状态
3.读取下一个字符b,c='b',当前的状态是q1,q1是一个接受状态,因为是接受状态,我们就将栈清空,将当前的状态q1压栈,查找跳转表下一次跳转的状态还是q1
4.读取下一个字符c,接受状态清空栈,将q1压栈,查询读入c跳转状态还是q1
5.读取下一个字符a,状态处于接受状态清空栈,将当前状态压栈,查询q1读入a后转移状态是Error,出错,出错判断打破循环,跳转到下一个循环
5.第二个循环,当现在状况不是接受状态的情况下,从栈中弹出一个状态赋值给state,回滚,将a这个字符塞会到字符流中,指针回退指向字符串的'c'
6.当第一轮循环结束后,state=1,已经处于接受状态了,循环终止,所以识别了abc这个串,当下一次读取单词的时候,从下一个a开始,也就是下一个abc开始读取
最长匹配
在上面的那个算法中,我们用到了栈这个数据结构,使用栈的目的是实现程序员中经常用的概念也就是最长匹配。什么叫做最长匹配呢?举个例子:
假设某个语言中有两个关键字,分别是if和ifif,这两个关键字比较有意思的是,一个关键字是另外一个关键字的前缀,比如某个码农写了ifif(条件),我们要将ifif识别成两个关键字还是一个整体,其实都是可以的,看语言如何规定,但是大多数情况下是尽可能朝前看,看最长的情况下它会组成什么样子的关键字,就比如c语言中ifxyz,我们会将其尽可能向前读,看做是一个变量的名字而不是一个关键字
我们可以根据以上的规则构造,一个DFA
其中q2和q4是所谓的接受状态,现在给定一个字符串"ifif"来展示整个过程
- 初始化的情况下整个栈是空的,不停的做循环,读入if后到达如下状态
2.再这种情况下已经是处于接受状态,所以要清空栈,将q2放到栈中
3.读入i之后再次转移到q3状态,3入栈
4.读入f后,到达q4状态,因为接受状态,清空栈,之后压4入栈,4再接收任何字符都会转移到非法的状态,因为已经走到接受状态,整个过程结束了
假设我们读入的ifii的话,在到达q3之后,渴望读入的是f,实际读入的i,会出错,这时候会回滚指针,直到状态处于接受的状态
1.一开始0,1被压进去
2.q2上来,q2会导致栈被清空(因为是接受状态),并压入q2状态
3.接着读入i,会将q3压进来
4.q3状态不是一个接受状态,而且再读入i的话,就会导致错误,因此,出错的情况下,要回滚,弹栈将3弹掉,3不是接受状态,接着弹栈,走到q2状态,q2是接受状态,最后识别的是if
总结
1.并不是走到接受状态就ok了,我还要尝试去满足最长的匹配
2.这个走可能会失败,所以维持一个栈
3.一旦失败,弹栈回滚到最近接受状态上来
跳转表
我们研究对DFA另外一个代码的表示,表示的名称叫做跳转表
- 我们有一个分析函数叫做next_token,一开始有一个初值,有一个空栈(为了支持最长匹配)
- goto函数跳转到q0
- q0内部类似于之前的一次循环
- 比如先读入字符,如果栈是接受状态,那么清空栈,并将当前状态压栈,但是后面确实当接收的字符是'a',就跳转到q1状态,相当于不用转移表,直接通过代码实现跳转
- 如果将每段代码看做一个状态,自己通过代码来实现跳转
思想
-
跳转表的拓扑结构和自动机的拓扑结构是完全一样,但是区别在于它不需要数据结构实现个跳转,而是通过自己内部的代码实现跳转
-
将每一个状态变成一段代码,将状态之间的转移看做是跳转
-
每个状态负责识别字符和实现跳转
跳转表VS转移表
跳转表 | 转移表 |
---|---|
不需要维护一个很大的数组,节约内存 | 占内存 |
每次执行仅仅执行一小段代码 | 加载代码慢 |
效率高 |
第四章-语法分析1
语法分析的任务
语法分析的简介
编译器是具有流水线的系统,它可以分为前端、中端、后端等不同的阶段,我们正在研究前端,从前几讲我们学习了词法分析,它输入是源程序,得到的是记号流,记号流会输送给后一个阶段也就是我们所研究的语法分析的阶段,这个阶段在编译器设计的早期主要检查输入的记号中包含的语法是否合法,如果合法的话可能直接生成某目标体系结构的代码,如果不合法的话它可能会返回比较精确的不合法的信息来指导程序员对其的修改,程序员修改之后可以重启以上的流程。之后的语法分析更为复杂了,可能语法分析要生成抽象语法树这样一个中间表示,这个中间表示可能会输出给后续阶段,语义分析器或者代码生成器来做进一步的处理。这样的划分将语法分析器的任务划分的更为明确,主要是接受输入产生抽象语法树。
语法分析器的任务
- 语法分析器的输入是记号流,输出是抽象语法树这个中间表示
- 除此之外还要研究在给定记号流输入的情况下,这样一个输入是否是合法的
- 语法分析器判别是否合法需要一个标准,相当于隐含的第二种输入
- 除了抽象语法树之外我们其实还有一个隐含的输出,就是是否合法,也就是YES/NO
语法分析器错误处理例子
当输入如上的存在错误的源代码的时候,编译器通常会给出一定的错误报警,比如
- 第一行缺少圆括号(值得思考的是,这里为什么没有报多了一个‘(‘,这样想符合直观)
- 第二行缺少;
- 第三行期望;,而是实际你给了,
编译器对于语法的处理可以做的非常的精确,它可以对于错误的定位,以及诊断的信息,都是非常的灵活的。里面的报错也会非常的灵活的,可以告诉你少了那些符号,少了的符号是什么
我们可以看出程序的开发过程是一个不断取悦编译器(被编译器毒打,hhhh)的过程
- 程序写出来可能会有各种各样的语法错误,编译器会给出报错,并给出诊断的信息
- 程序员根据诊断信息来对其进行不断的修改
- 直到编译器能够通过为止
- 如果编译可能通过的话,那么我们就要做得事情就是,语法树的构建,也就是建立起后端需要的数据结构
- 经过以上的过程后,程序终于修改正确了,语法分析会生成抽象语法树,并将其放到内存中去
- 这是一个三层语法树,有三个子树,分别根据字符判断,>,=,=,编译器后期会对其处理
- 在这个阶段,生成抽象语法树,包含我们后期需要用到的所有信息,我们之后的流程就看抽象语法树就可以了,就不需要再看字符串了。所以构建语法树,我们要将将来用到的信息都放到抽象语法树上
后续的路线图
- 上下文无关文法已经成为了描述语法规则标准的数学工具也就是描述程序的语法
- 自顶向下分析算法
- 递归下降分析算法
- LL分析算法
- 自底向上分析
- LR分析算法
上下文无关法和推导
什么是上下文无关文法?
上下文无关文法是描述程序语法的一个强有力的数学工具,通过对这样一个数学工具认真研究之后,我们可以根据文法来设计一些高效的算法。
我们之前讨论过前端的一个核心的结构,语法分析其实在整个前端中处于一种比较核心的地位。
我们除此之外还开了输入输出的整个接口,输入是词法分析器返回来的记号流,输出是语法树这样一个数据结构,在这个过程中因为要判断是否满足语法规则,还要告诉编译器语法规则究竟是什么?其实也就是判断一个程序是否合法的一个标准。为了描述语法规则的话,如果需要形式化的描述语法规则的话,可能就需要一些数学工具。而历史上就是非常有意思的,当我们需要一些规则,我们通常不用实际上去发明这些规则,数学家已经帮助我们构造好了这一系列的工具。
历史背景:乔姆斯基文法体系
用数学的工具为自然语言构造了一系列的工具和方法,其中就包括了上下无关的文法。他给出了从0到3一共四类的文法,分别有四个名字,叫做三型文法(正则文法)、二型文法、一型文法、零型文法,正则表达式和三型文法存在一定的等价关系、二型文法(又称上下文无关文法),这样的文法正好可以描述语法结构,而三型语法可以描述(词法部分),一型文法又称为上下文有关文法,零型文法又称为有关文法,每一个圆弧是一个逐渐放大的关系,每一个文法都相应的比内部的文法表达能力更加强大,文法之间实现的互相嵌套的关系,自然语言所构造的工具如何放到计算机中去呢,前面已经说过了,三型对应正则表达式(词法分析),二型(对应上下文无关),0型和1型文法目前还未被广泛应用。
形式化的描述语法
我们可以从历史的角度上来看乔姆斯基所给出的意义和作用是什么。
我们可以研究一下,自然语言中句子的典型应用,我们知道不用管什么语言,要构造一个句子的话肯定都是要有一定之规的。一个合法的句子通常包括主语、谓语、宾语,其实从另外一个角度来看整个结构是名词、动词再加上名词这样一个结构。
假如我们给出一个具体的例子
名词中我们有四个对象分别是{羊、老虎、草、水},动词有两个对象分别是{吃、喝},我们小学的时候学习造句基本上就是这样一个形式,假设我们按照主谓宾的语法规则,我们看能够通过这些集合造出怎样的句子。我们可以重会小学,练习一下造句。
羊吃草
我们根据语法造出了句子,我们将这个符合该语法的句子叫做符合语法规定。
羊喝水
老虎吃草
老虎吃老虎
草吃老虎
羊吃老虎
...
我们可以看到其中有些句子是比较符合语法规则的,而有些句子其实是不符合语法规则的(老虎吃草、草吃老虎)。我们可以看出自然语言其实是非常复杂的,我们很难仅仅依靠语法规则就可以判断语句是合法的。根据这样一个例子的话,我们可以将其形式化。
看清楚以上的例子时候,我们可以将这些例子进行形式化。我们可以引入一些符号来代表动词(吃、喝),名词(老虎、羊)。
- 大写S代表一个句子
- ->代表推出
- 右侧有N V N三个大写的符号,实际上再说一个句子可以推出一个名词加上一个动词再加上一个名词,说明了什么是一个合法的句子呢?它只有一种表现形式就是一个名词加上一个动词再加上一个名词
- 什么是名词呢?这里N->推出,我们可以递归的来说。
- s是sheep的意思
- |的意思是或者的意思,什么是合法的名词呢,它可以是羊或者老虎或者.....
- t代表了tiger(老虎)
- g代表了grass(草)
- w代表了water(水)
- 什么是动词呢?一共有两个分别是吃和喝
- drink(喝)
- eat(吃)
通过形式化的方式,我们可以将什么是合法的句子,里面包动词、名词,以及动词和名词分别又包含了那些东西全部都有了形式化表示。我们可以将这里大写小写的记号给其一个严格的名词,我们将S N V这样大写的符号叫做非终结符,这些小写的符号叫做终结符,并且我们有一个开始符号S,开始符号的意思是我们有如此多的规则,那么我们究竟从那一条规则来开始理解。为什么叫非中介符合终结符呢?其实比较好理解,我们的目的是通过这些规则来进行造句,我们用S来造句得到 N V N,这还不是整个句子造句的最终形式,这还仅仅是一个中间过程,之后N V N还要继续造句,到这里还没有终结。
N我们可以选择一个单词是s(sheep),动作V选择e(eat),N选择(grass),我们看一旦这三个大写字母确定的情况下,我们这个句子就会造出来了,所有的符号都出现在终结符这里,没办法向下继续扩展了它已经终结了。
上下文无关文法的定义
- 数学作为工具要结合具体例子来理解
- G是四元组G=(T,N,P,S)
- T是终结符的集合
- N是非终结符号的集合
- P是一组产生式规则的集合,每条规则的意义是这样的,x1推出β1,β2,βn,.....左侧有一个非终结符,右侧有一个这样的推出符号,β1,β2,...n,一共有n个,n>=0的
- βi∈{T∪N}这样的集合,i是一个符号,它要不是终结符,要不是非终结符
- S属于唯一的开始符号(必须非终结符)S∈N
上下文无关文法的例子
为了更好更好的理解定义,我们可以将定义与例子紧密的联系起来。
第一个例子
- S N V是这样的三个非终结符号
- 终结符T集合={s,t,g,w,e,d},将之前的那些非终结符都放进来了,{sheep,tiger,grass,.....}
- 产生式的语法规则,这里已经有了就是类似于一下的形式
第二个例子
这个例子是算数表达式的例子,但是运算仅仅包含加法和乘法.
- G也是一个四元组{N,P,T,S}
- 非终结符包含E这样一个元素
- 终结符T由四个元素所组成分别是{num,id,+,*}
- 开始符号E
- 产生集合如下
解释:如果我们严格的写出产出式的四条的话,结果应该是:
- E->num
- E->id
- E->E+E
- E->E*E
我们可以看出这四条规则的左边其实是完全一样的,所以我们用了简化用了这样一个|(或)的符号来表示,将公共的左边省略掉
如何区别终结符合非终结符呢?我们这里定义所有的非中结符都是大写的符号,所有的终结符都是小写符号,都是单个的。
文献中经常用的是BNF范式:
- 非终结符要放到一对尖括号中
- 所有的终结符要加上下划线id
- 这样就可以区分开那些是终结符,而那些是非终结符了
推导的概念
- 给定文法G一个四元组,我们从G的开始符号S开始(S是唯一的),用产生式的右部不停的替代左侧的非终结符,想象一下,我们的左侧终结符->(右侧非终结符),我们这个过程不断的用右侧的非终结符来替代左侧的终结符
- 这个过程不断的重复和继续直到句子中不再出现非终结符号为止
- 最终生成的称为句子
为了更好的理解推导,这里有一个例子
- 一开始我们只有一个非终结符S,我们要用S的右部来替换它也就是N V N
- 经过这个过程S消失了,S变成了N V N 被替换掉了
- 当前生成的是N V N这样一个串,三个都是非终结符
- 这个过程还要继续,知道句子中不出现非终结符为止
- 我们可以接下来任意替换,比如我们先替换V,V用它的右部替换,e或者d
- 这个过程还要继续因为还有两个非终结符,替换任意一个N,从{s,t,g,w}中选,这里选g
- 同理继续替换另外一个N
- 这个过程已经无法继续下去了,当前串中没有非终结符,串中包含的都是终结符,这样最终的串我们把它称为句子
问题S可以推导出多少个不同的句子?
4(N)x2(V)x4(N)=32个
最左推导和最右推导
推导这个概念我们可能并不陌生,或者说似曾相识。这里的推导非常类似中学时候学的多项式化简,或者拖式计算的样子。
最左推导
最左推导:每次总是选择最左侧的符号进行替换
简单的来说,每次替换,都替换最左侧的非终结符
最右推导
最右推导:每次总是选择最右侧的符号进行替换,同理,我这里还是写一下吧,每次选最右侧的非终结符替换
s->N V N
->N V s
->N d s
->s d s
什么是语法分析?
给定文法G和句子s,语法分析要回答的问题:是否存在对句子s的推导?
- 句子的意思是是一个串,这个串中仅仅包含非终结符
- 问题是是否存在对s推导如果存在的话,是啥?
S是一个句子,S=s e s,我们想知道在这样一个文法的条件下,是否存在对这样一个句子的推导,换句话说它能否从大写的S出发,做若干部的推导,最终生成出这样一个句子出来,可能S=s e s(羊吃羊)我们要回答Yes,如果像S=s s s(羊 羊 羊(恒源祥,滑稽))就回答No
再看语法分析器任务
- 拿着记号流,也就是一个句子s
- 接受语言语法规则,也就是G表示
- 要回答G中是否对s的推导,Yes/No
- 负责任的编译器还要告诉码农,哪里不符合语法,你怎么改
分析树和二义性文法
分析树和二义性对深入理解上下无关性文法有着非常重要的意义
推导与分析树
我们再回顾亿次语法分析器的例子,它接受记号流(句子),和相应的语言的语法规则,输出通过语言的语法规则能否推导出s,回答yes/no.
给定一个语法G,我们不断的进行推导,不断地用非终结符替换终结符:
除了用这种方式来表示推导的过程,我们还可以用树来表示推导的过程:
- S作为根节点,从根节点出发,有一个根节点,和三个子节点三个子节点就对用它右部这样一个串的形式
- 中间的V进一步被替换成为了d,我们可以连接一条边
- 第一个N被替换成了s
- 最右边的N被替换成了w
- 如果我们将水平型的推导变成树状来画出来的话,就是如图那样的例子。这棵树有一个名字叫做分析树,有的文献中会说它叫做语法分析树。这样分析树它的特点:
- 任何的一个推导步骤进行当中,比如说第一步这棵树本来仅仅存在S节点,我们画出一个抛面
- 经过了第一次推导后S变成了N V N,树所具有的一个边界变成了第二个抛面,其实就是S被替换后变成了N V N ,将这三个连起来
- V节点被换成了d,这样就成了第三个抛面N d N,因为原来是N V N,现在V被替换成了d,变成了N d N,将这三个连起来,那么就形成了N d N 这个抛物面
- 同理第一个N被替换为s,产生第四个抛面s d N,因为原来是N d N,现在N被替换了,所以变成了s d N,连起来,成为了第四个抛面
- 最后一步,最后一个N被替换成了w,整体变成了 s d w,连起来,形成了s d w这个平面
- 在这里我们可以将每一个抛面称为我们语法推导的边界,最终的边界,即这个句子已经被遇到出来的时候,肯定是树当中所有的叶子节点我们可以看出,整个过程从根节点出发,不断的向外扩展,最终到达了叶子节点,完成了相应的目的。我们也思考对这样的一颗分析树,我们做什么样子的遍历才能得到句子?(树有前序、中序、后序等等)
分析树的具体概念
- 我们可以将推导的过程画成树状结构,这个树就叫做分析树
- 树的结构和推导的顺序是无关的(最左、最右、其他)
- 树中每个内部节点代表非终结符
- 每个叶子节点代表终结符
- 每一步推导代表如何从双亲节点生成它的直接孩子节点的过程
- 替换之后树的边界要继续向下扩展
表达式的例子
我们研究表达式的例子,看看能否根据G这个语法规则推导出3+4*5这个例子
- 我们从开始符号E,出发逐步的做替换,为什么不能选择前两个呢?因为前两个是终结符,到哪里就结束了,而且得到一个num,或者id,并不符合3+4*5这个算式,换句话来说就是根本不是我们想要的
- 第二步中,我们要从E+E,这两个E,中选择一个展开,到底选择哪个E来展开呢?这是一个问题?现在我们先随意选一个(选一个实际上正确的),其实我们知道随便选其实是有风险的,因为你要是选择一个E展开,很可能会走到一个错误的方向上去。我们选择最左侧的E展开为3
- 我们下一步将E替换成为E*E
- 我们将最左边的E替换成4
- 最右边一个E替换成为5
- 到这里我们可以说明这个推导是Yes,通过该语法规则我们可以推导出3+4*5
- 我们可以看出在这个过程中,我们采用的是最左推导
其实我们可能还有其他的推导方式
其他的推导
- 选择乘法来进行表示E
- 最左边的E替换成为加法
- 继续做最左推导,E被替换成3
4.最左边的E替换成为4
5.最后E替换成为5
我们可以看出整个串,一共可以有两种不同的推导那么这两种不同的推导它的分析树是怎样的呢?我们可以直接画一下,这个过程我们就不再详细的话出来了。
对于第一种方法:
我们整棵树的所有叶子节点连出来的话,可以发现就是我们推导出来的句子。
之后我们再看第二种推导它的语法树:
我们可以看到它的叶子节点连接起来,所生成的串是我们期待的句子,但是它和上面的树的结构是不一样的。这里面它的根节点是先计算乘法后计算加法。
我们思考这两颗树如此结构不一样,那么会有怎样的影响呢?答案是肯定的,因为分析树的含义取决于树的后序遍历的顺序。假设我们要写一个栈式计算机这样的代码生成的程序,或者我们写一个计算器那样的工具,我们可以对树进行后序遍历。
关于如何做后序遍历?
我们先研究第一种方法得到的语法树
- 先遍历3,因为E等于3,所以3就拿到E上面来了
- 后序遍历,我们注意不能遍历+号,因为根节点的E等于这个+号,所以提上去,加号放到根节点
- 我们接着遍历整体这棵树的右子树
先遍历这颗树(红框中)的左子树也就是4,遍历这颗树的右子树5,然后计算这棵树的根节点4*5=20,
4.回到总的根节点,20+3=23
再研究第二种方法得到的语法树。
注意,这里如果看不懂,微信问我,可以保持答疑服务,其实我写的都可以,hhh,滑稽...
同理,第二棵树后序遍历结果是35
我们可以看到两棵不一样的树做求值运算得到的结果是不同的,一个值是23,另外一个值是35,那个是对的呢?因为常理3+45=23,肯定得23是对的(第一种),为啥呢?因为我们日常认为乘法优先级比加法高3+(45)=23实际上,而不是(3+4)*5,所以其后序遍历往往决定了计算的结果。我们可以看出这样的推导是有问题的,从相同的规则可能会推导出亮哥完全不同的结果,程序存在着歧义
二义性文法
- 给定文法G,如果存在句子s,它有两棵不同的分析树,也就是两个不同的推导,那么称G是二义性文法
- 从编译器的角度来看二义性文法存在着严重的问题
- 同一个程序会有不同的含义
- 因此程序运行的结果也是不唯一的
- 解决的方案:通过文法重写,来防止二义性
表达式的文法重写
对文法的重写是一个具体问题具体分析的过程,也就是不存在一种算法,给定任意的文法,都可以使其从二义性变成非二义性。接下来我们会用算数表达式例子来展示算式表达式的文法重写。
这是整体上的语法规则:
我们可以具体的分析一下,这个过程
E->E+T //因为左边E和右边E是一样的,所以可以递归
|T
//那么俄罗斯套娃开始(禁止套娃orz)
E->E+T
->E+T+T //E变成了E+T
->E+T+...T
->T+T+.....(最少一个T,最多任意个T)
T->T*F
->T*F*F
->T*F*F*...*F
->F*F*F......*F(最少一个F,最多任意个F)
我们通常将T看为一个term项,F看做一个因子factor
类比 1*2*3+5*6*7+8*10*11
1*2*3称为一个项
其中对于 1、2、3是因子
之后我们的推导过程如下:
E->E+T
->T+T // E变T
->F+T //T变F
->3+T //F变3
->3+T*F //....,不能太啰嗦了,hhhhh
->3+f*F
->3+4*F
->3+4*5
- E扩展成E+T
- 第一个E扩展成T,替换为F,替换成3
- 加号不可被替换
- T被替换成T*F
- 被替换为F,F替换成4
- 另外F替换为5
这样的语法分析树的遍历顺序得到结果和我们所期待的结果达成了一致,因为加号在比较高的层次上,乘号在比较低的层次上,计算时候,会计算左子树、后计算右子树,最后再算加法,保障先乘法后加法
假设我们要推导的句子变成了另外一种形式3+4+5:
过程同理,不再赘述,我们画一下抽象语法树
对其进行后序遍历的话
- 先计算3+4
- 再计算5
- 两者相加3+4+5
实际上我们的优先级顺序是这样的:
说明这样一个分析树保障了加法的左结合性,讨论题写成右集合怎么做
自顶向下分析
语法分析器的任务是判断能否从语法规则G推导出语句s,回答Yes/No.这一讲的内容是实现这样一个功能,语法分析器的内部实际上是如何实现的。这包括其中用到的数据结构和算法。这些算法有很多在,这里主要讲自顶向下分析。
基本思想
- 从G的开始符号出发,随意推导出某个句子t,比较t和s
- 如果t==s,就回答是
- t!=s?我们可以直接回答否吗?好像我们不可以如此笼统的回答否,因为我们没有看s的结构非常盲目的得出的t,可能我们推导的并不对,我们需要回溯将之前的过程打倒,重新推导t',然后看t'和s是否相等,如果还是不成立的话,我们继续推导t'',看t''是否等于s,这个过程可以一直继续,直到我们枚举出t(n)==s,或者干脆推导不出来t使其等于s
为什么称为自顶向下法?
自顶向下实例
语法规则
按照规则随意推导
推导的串和目标串做匹配
匹配的过程中我们发现这两个串并不相等,返回一个假
为了让其相等,我们故意随机推导出了一个串gdw与目标串相等,这时候两个句子做匹配句子是完全相等的
这样的例子告诉我们在匹配的过程中,我们通常需要回溯,我们不妨以分析树的形式将这个过程再画一下
- 首先,我们将S,推出N V N
- 将最左边的N替换成为s
- 我们可以将决策边界画出来,然后我们发现句子第一个字母是是s,肯定和目标的句子gdw是不匹配的
- 基于这个想法我们可以判断出这次推断出来的句子肯定不符合要求,我们要回溯,将现在已经做得事情进行推倒重做,也就意味着我要将s拿掉
- 之后我再尝试其他的字符,依次尝试 s、t、g、w,下一次将t替换N,会继续失败,接着尝试用g来与之匹配,最后获得成功
- 游标向下移动,尝试与下一个字符d进行匹配
- 接下来仅仅需要匹配从d开始的子串就可以了,V先尝试将其替换成为e
- 发现e和当前的d并不匹配,之后向后回溯,回到根节点,尝试V的第二个分支d
- d和目前这个输入是匹配的,游标继续下移
- 接下来N与按照上面的道理依次匹配,依次替换为s(失败)、g(失败)、w(成功)
- 如果所有情况都回溯了,但是就是失败了,那说明,可能该规则推不出该句子
自顶向下算法描述
- tokens是一个数组,是词法分析做完之后将所有的符号返回到这个tokens中来
- 游标i指向tokens中下一个要匹配的记号的位置
- Stack中存放的是所有终结符合非终结符所在的一个序列,最开始的时候栈顶上仅仅只有S这样一个非终结符,S是文法的开始符号
- 算法的主体是一个循环,当栈不为空的时候,一直要做
- 循环里面是一个条件判断
- 如果栈顶元素是一个终结符t的话
- 那么就比较t是不是等于tokens[i++],如果相等的话,将t从栈中弹出去,如果不相同的话意味着匹配是失败的,那么我们就进行回溯的操作
- 如果栈顶元素是一个非终结符的情况下,那么出栈,push下一个T的右部,靠右侧的符号先压栈、靠左侧的符号后压栈
结合例子来看自顶向下算法
- 一开始,我们将需要推导的句子gdw放到我们的tokens里面
- i指向的是0这个下标
- 开辟一个栈,这个栈放在这里,这个栈内仅有一个符号,也就是起始的符号。
- 栈顶的指针top,数据结构中top开始指向-1,这里一开始指向是
- 栈当前肯定不是空的,判断栈顶的元素是什么?看栈顶元素是终结符还是非终结符然后做相关的操作,显然我们现在栈顶的元素是起始符号,它是一个非终结符,先弹栈,然后将它另一个没有考虑过的右部考虑压栈,注意这里是从右到左依次压栈分别对应N2 V N1
- 从这样的概念应当如何理解呢?对应它的语法分析树我们先画出来。
它所有树中的节点从右到左的来进行压栈,先压右子树、中间节点、最左节点,这也相当于在用栈在显式的实现树的后续遍历,也就是非递归的实现栈的后序遍历。
- 接着我们循环重新回来,继续做下一轮
- 判断栈不等于空,栈上面有三个元素非终结符,肯定不空,栈顶元素还是非终结符,将栈顶元素pop出去,将它下一个没有考虑过的右部压进来。一步步来,先将栈顶元素pop
再将N的第一个右部s压进去:
- 循环继续进入下一轮,判断栈不为空,继续进入循环体,这个时候因为栈顶是终结符,所以我们走到了第一个if上,如果当前的栈顶元素等于i指向的tokens中的元素的话,就将其pop出去,否则就做一个回溯,显然这里s!=g,所以要回溯,将s弹出去,将原来弹出去的N压回来,恢复原来状态
回溯的话对于输入流也要做这样的操作,i要向前倒回来
- 再次判断,当栈不为空,栈顶元素又是N1,N1是非终结符,我们再将N1弹出去,再将N1的下一个右部压进来,之前我们尝试过s,下一个要尝试的就是t
- 栈顶现在是非终结符t
- 之后还有好多这种,就按照这种套路一直走,可能各位看得有点迷,觉得难,核心思想就是回溯,简称觉得不行推倒重来!
- 这个算法不断的运行下去的话,最终整个栈肯定是空的,这时候有两种情况,一种是tokens所有的符号都已经吃光了,这时候返回一个真,如果tokens已经吃光了,但是栈还是不为空,这样就算失败了,我们可以看到这是一个相当昂贵的算法,整个过程包括向前的尝试和向后的回溯
算法的讨论
自顶向下算法缺点是回溯所带来种种效率问题,其他算法目的就是要克服这个缺点!
递归分析算法和LL1分析算法思想概述
核心思想:用前看符号避免回溯
- 第一步是毫无疑问的抽象语法树
- 当替代N的情况下,我们一共有四个可以选择,但是当我们选择错误的符号的时候,就会面临回溯的问题
- 我们应该考虑既然tokens已经有了的情况下,我们看一下输入串,可能会给我们提示
4. 这里一看N的右部里面有g,那么我们可以匹配g,那么就只用考虑下一个字母就可以了
- 接下来看v的右部中有没有d,显然有,那么我们下一步只要看N的右部中有w没有就可以了,这里显然是有的,所以整个串都可以匹配上
- 这样的推导是从左到右看就可以了,不用回溯,线性时间
- 但是这里我们也引出一个问题哈,假如N还可以取两个符号,我们加一个gN
- 这时候我在选择的时候,虽然已知是g开头,但是我不知道选g还是gn
- 从原则上看这两条元素都可以选,如果你选的不对的话这个回溯还是可以出现
- 假设我们选的是gn,但是与后面的不匹配,我们还得回溯,这个问题的解决可以向后看两个符号等等,但是我们需要系统的方法告诉我们怎么处理这样的冲突。
递归下降算法
递归下降分析算法是自顶向下分析算法中的一类。
递归下降算法概述
递归下降算法的基本思想
递归下降算法概述
首先,我们根据右边的语法规则,画出语法分析树。
- N可以由g来替换
- V可以由d来替换
- N可以由w替换
我们可以看出某一个子树是和推导句子的一部分对应起来的
这样的一种对应体现了计算机中一种非常重要的思想,那就是分治法的思想。我们来考虑这个问题,S这个初始符号能否推出一个句子,S可以分为N V N,进而递归的变成,N这个非终结符能否推出g,V这个非终结符能否推出d,N这个非终结符能否推出w,通过这样的思想将这个问题由大化小。
我们可以写三个函数
- 分析S的函数叫做f
- 分析N的函数叫做k
- 分析V的函数叫做h
f函数可以递归调用k,h, k,h完成自己工作就可以了
parse_N()
token读取下一个句子中的符号
如果读取到的符号在{s,t,g,w}这个四个字符中
匹配成功
否则
报错
parse_V()
同理
parse_S()
递归调用上面的函数
一般的算法框架
- 这个是算法框架,具体和实际的代码形状和规则是相关的
左侧非终结符x,右侧有若干个右部,比如β11...β1i,总共i个符号,从β11...β1i都是终结符或者非终结符,同理β21...β2j一共有j个符号,β31...β3k一共有k个符号,就是这样的。针对这样的上下无关代码,我们可以写出如下的文法。
- 终结符变成了这样一个函数,它的名字叫做parse_X(),token=取下一个token,接着对token做分情况讨论,token可能好几种不同的情况,如果是第一个情况下,那么就对应第一条β11...β1i,总而言之就会走到不同的分支上去,否则的话就会报一个语法的错误。
3.我们发现β11...β1i共i个字符中,其中可能有终结符,或者非终结符,实际上是有讲究的在这里
4.当i=4的时候,其中有两个是终结符,另外两个是非终结符
5.当我们发现token=a的时候,我们走到了一个分支上,既然a已经走完了,走下一个输入是小写的b,这时候就要递归调用了,parse_B
7.走完c之后光标会指向d,递归调用parse_D
8.规则处理完
对算术的递归下降分析
我们先写parse_E,先读入一个数组,我们之后判断如果是等于数字的话,我们要走哪一个分支呢?我们假如走第一个分支的话,先parse_E吃掉+号,然后在parse_T,或者我们直接调用parse_T
决定走哪一个分支可能要用到回溯,但是我们可以根据经验避免掉回溯其实。
- 我们知道E可以写出E=T+T...T,E最少有一个T,最多可以有无数个T,通过这个规则我们可以知道将(+T)看做一个组合,可以有0个或者n个组合,后面顺序是可选的,F可以写成;
- 根据这些规则可以写出第二个版本的递归下降
parseE的时候先parseT一下,看看后面有没有跟加号,如果有那就要继续套娃。 parseT的时候也一样,要先parseF一下,看看后面有没有跟乘号。 啥时候结束? 最后应该有个EOF被读到token里,就结束了。
第五章-语法分析2
总结精炼
- FIRST与 FIRST_S的区别
前者针对 某个非终结符
后者是基于整体,如 0,1,2,3,4,5,6 全看一遍
实际做题的话两个结合着用
- LL(1)和LR(0)****就是为了产生语法分析表
视频要点截取
LL(1)
前看符号:用一个未处理的符号作为辅助,用于辅助判断做推导
自顶向下的时候,分析栈面临回溯的问题。
分析表可以提供,什么时候做移入字符、什么时候做展开的提示
语法分析器先根据输入的语法生成分析表,之后再对记号流做处理。(核心
对自顶向下的改进 next -> correct,就不用backtrack了。
现在问题变为,如何得知correct?
LL(1)分析表
(这些都是初次演示,如何生成分析表请看后文)
我们想生成上图那样的分析表,我们发现每一个产生式都是一个串的形式,每一个串的开始符号是什么?
FIRST集
因为N的FIRST集为{s,t,g,w}
所以S的FIRST集为{s,t,g,w}
FIRST_S 集
S代表sentence,就是包含序号的这个 →
LL(1)分析表的冲突
分析表中只有一个元素,可称该分析表对应表项为LL(1)文法
如果多于1个元素,则出现冲突(如下图 (N,w) = 4,5 )
上述为不考虑 ε的情况,下面先讲NULLBALE集
NULLBALE集
要注意↓
这里是所有的非终结符都为NULLABLE
故,
FIRST集合的完整公式
如下
2的结果如下:
Z:{d,c,a}
Y:{c}
X:{c,a}
FOLLOW集
看非终结符后面能跟随哪些符号,随意推出一个句子看那些终结符能跟在后面。
结合FOLLOW集推导的 FIRST_S集
(建出了表,有三个冲突)
对自顶向下分析法的改造
1、当栈顶符号为非终结符时,弹掉。
然后push( [当前弹掉的非终结符,前看符号])
这个坐标会告诉你“对应产生式的编号,然后把i: X -> βn —> β1 逆序入栈”
2、当栈顶符号为终结符时,如果有错直接报错,不再回溯。
LL(1)冲突处理
面对冲突,不知道用2还是用3做替换。
消除左递归
附上个人计算:
提取左公共因子
LL(1)是有极限的,
我不做自顶向下了
LR(0)算法!自底向上!
LR分析算法(移进归约算法)
LR不需要改写+消除左递归
移进:吃一个符号
归约:对栈顶元素做处理
1、保证S’不出现在任何一个产生是的右部
2、对文件结束符进行显示化处理
LR(0)分析表
s2:shift2 移进
r2:reduce2 规约
g5:goto5
(弹出栈顶符号,该文栈中元素包含1、x、y等终结符,2、状态①②③④,所以假设有β个元素要出栈,实际出了2β元素)
答案:在SLR解决冲突部分
Closure
把非终结符的产生式规则也写出来
以上,LR(0)结束。
SLR
对LR(0)做改进
错误发现时机被延迟。
问题1:错误定位
3号状态下,
s的follow集合只能是{$},但假如后面的输入不是$,LR(0)需要到状态4才能发现错误,而非在状态3.
(可听视频,更为直白, 05:30开始
https://www.bilibili.com/video/BV1m7411d7iS?p=60
)
问题2:冲突
只对 y∈follow(x)添加action[i,y] (只对 y∈follow(x)做归约)
深入理解第五章(不一定深入,尽力)
第六章-语法制导的翻译与抽象语法树
语法制导翻译的任务
编译器在做语法分析的过程中,除了回答程序语法是否合法外,还必须完成后续工作,这些工作通常由语法制导翻译完成,可能的工作包括:(包括但不限于)
- 类型检查
- 目标代码生成
- 中间代码生成
语法制导翻译的实现原理
语法制导翻译的基本思想
给每条产生式规则附加一个语义动作:一个代码片段
语义动作在产生式“归约”时执行:
- 即由右部的值计算左部的值
LR分析中的语法制导翻译
上图中,对于每一条产生式,我们在右部添加了一个语义动作ai,在βi进行规约的时候,执行ai。对于LR分析,上图左边部分显示了规约操作的伪码,t是下一个将要读入的元素,s是当前的栈顶元素,在执行具体的规约之前,即将βi出栈,需要执行语义动作ai。
一个例子 :计算表达式
0:E -> E+E {E = E1 + E2}
1: | n {E = n}
具体的语义动作实际上对应后序遍历的序,先子节点后根结点。
如何进行语义动作的执行?
在分析栈上维护三元组:
对于上面计算表达式的例子,我们可以做出如下的项目集和转换关系图
首先增加一个平凡的起始符号S,S->·E$,之后加入这一项的闭包项,因为S->·E$表明我们接下来期望看到一个可以从E推到得到的串,因此我们把E->·E+E和E->·n两项加入到状态0中。
如果在状态0的基础上读入一个n就到达了状态1,此时·出现在了最右面,表示此时是一个规约状态。
如果在状态0的基础上读入一个E就到达了状态2,其中S->E·$是一个可能的接收状态,而把E->E·+E则表明期待读入一个+号,读入+号就来到了状态3,即E->E+·E,此时期待读入一个E,同时与状态0类似,我们需要加入相应的闭包项。
对于状态3读入n则回到状态1,读入E来到状态4。状态4的两种情况分别对应着规约和移进。如果我们规定+号是左结合的,这里就按规约处理,因为此时左面已经完整的识别出来一个E+E,先对其进行规约,之后再读入后面的+号。
对于7+8+9应用
初始状态:
读入7:<7, 7, 1>
规约,弹出状态1,进行语义动作E=n:
读入+号,进入状态3:<+, value, 3>
读入8:<8, 8, 1>
规约,弹出状态1,执行语义动作E=n:
此时为状态4,规约,执行语义动作$$=$1+$3,弹出,进入状态2:
读入+号,进入状态3:<+, value, 3>
读入9:<9, 9, 1>
规约,弹出状态1,执行语义动作E=n:
此时为状态4,规约,执行语义动作$$=$1+$3,弹出,进入状态2:
读入$,接受
分析树 VS 抽象语法树
抽象语法树是有语法分析器生成的,用于进行语义分析的内存结构。
对于表达式15*(3+4)的分析树和抽象语法树如下
可以看出分析树编码了句子的推导过程,但也体现了许多不必要的信息,这些信息会占用额外的存储空间,构造抽象语法树需要去掉那些不必要的信息。由于优先级和结合性都已经在语法分析过程中处理完毕,因此,在编译过程中,只需要知道运算符和运算数。
具体语法和抽象语法
具体语法是语法分析器使用的语法。
- 必须适用于语法分析,如分隔符、消除左递归、提取左公因子
抽象语法是用来表达语法结果的内部表示。
- 现代编译器一般都采用抽象语法作为前端(分析部分)和后端(代码生成)的接口
下图中,对于蓝框内的文法和绿框内的表达式构造了相应的分析树。
对于图中的文法,我们可以改写成如下形式:
E -> n
| E + E
| E * E
注意,这里的文法是具有二义性的,因为它没有体现加法和乘法的优先级,不能用于语法分析,但是对于生成一个抽象语法树来说足矣。
抽象语法树数据结构
在编译器中,为了定义抽象语法树,需要使用实现语言来定义一组数据结构
早期的编译器有的不采用抽象语法树数据结构
- 直接在语法制导翻译中生成代码
- 现代编译器一般采用抽象语法树作为语法分析器的输出
- 更好的系统支持
- 简化编译器的设计
对于上面简化后的文法,可以用C语言进行如下的数据结构定义:
首先用枚举类型和一个结构体体现语句的形式,这里是三种,整型数,加法和乘法。
之后分别对三种形式定义结构体。
三种形式的“构造函数”如下:乘法与加法类似
手工编码2+3*4:
优美打印,将编码结果按正常的优先级进行输出
求树的规模:
从表达式到栈式计算机Stack的编译器:
List all; //存放生成的所有指令
void compile(E e)
{
switch (e->kind){
case E_INT: emit(push e->n); return;
case E_ADD: emit(push e->left);emit(push e->right); reurn;
case E_TIMES: emit(push e->left);emit(push e->right); reurn;
default: error ("compiler bug");
}
}
抽象语法树的自动生成
LR分析中生成抽象语法树
在语法动作中,加入生成语法树的代码片段
- 片段一般是语法树的“构造函数”
在产生式归约的时候,会自底向上构造 整棵树
- 从叶子到根
对应于上面例子:
源代码信息的保留和传播
抽象语法树是前端与后端的接口
- 程序一旦被转换成抽象语法树,则源代码被丢弃
- 后续的阶段只处理抽象语法树
抽象语法树必须编码足够的源代码信息
- 例如,必须编码每个语法结构在源代码中的位置
- 这样。后续的检查阶段才能精确的报错
- 或者获取程序的执行剖面
第七章-语义分析
语义分析概览
语义分析的任务
总的来看,编译器的前端是由三个核心模块和两个核心的数据结构组成的
模块的阶段无关性:讨论语义分析的时候,仅仅需要关心的输入是抽象语法树,输出中间表示
语义分析的任务:负责检查程序(抽象语法树)的上下文相关的属性,例如变量声明、表达式类型、函数调用与定义一致等。
语义分析的示例
示例中的语义错误有:
- 没有变量x的声明
- 没有函数p的定义
- c语言不支持两个字符串用加号连接(注意不同语言的特性)
- f()的函数调用和函数定义不匹配
- void类型返回值不能和整形数5相加
- break没有出现在循环语句中
- return没有返回值和主函数类型int不一致
语义分析器概念上的结构
向语义分析器中,输入抽象语法树和程序语言的语义,语义分析器判断语义是否合法,如果合法将会向后生成中间代码。
在这个阶段之后,程序不应该再包含任何语义和语法的错误。在后续阶段如果再出现错误,肯定是编译器代码有问题,而不是源代码的问题。
程序语言的语义
语义规则及实现
一个C--语言的例子
类型检查函数:如果语义正确,返回表达式的类型,否则报错
类型检查算法
enum type: 枚举类型的数据类型
e: 表达式类型的参数
case EXP_ADD: 递归调用左右子树
case EXP_AND: t1 = check_exp (e->left);
t2 = check_exp (e->right);
if (t1!=BOOL || t2!=BOOL)
error ("type mismatch");
else return BOOL;
示例
判断加法的过程其实是树的后续遍历,先遍历左右子树再进行判断,过程上是递归调用
变量声明的处理
D是一个自循环的结构,转到空为止,实际上产生出来的是T id这一串序列
T id里面,T是一个类型,id是一个标识符(变量名)
类型检查算法(进阶版)
加入了变量声明处理的类型检查算法:
Table_t table:定义一个符号表,相当于变量名id(关键字)映射到类型type(值)的字典结构
我们需要写三个检查函数,分别检查程序P、变量声明D和表达式E
- check_prog函数:检查程序P。先检查变量声明d,并将其赋值给符号表table,再检查表达式e,并返回检查表达式的结果
- check_dec函数:检查变量声名d。注意变量声明可能是一串,做一个简单循环,对这一串声明做一个表的插入操作,构建全局变量的符号表,如上面第一张图所示
- check_exp函数:检查表达式e。这里只考虑了case是ID的情况,其他情况参考之前讲过的内容。先做一个lookup的查表操作,如果id在表里没查到则报错,查到了就返回类型t
实际上三个检查函数也是一个后序遍历
语句的处理
加入了语句处理部分的类型检查算法:
变量声明和表达式部分,和前面没有变化
三个语句分别是:赋值语句、打印整形表达式的值、打印bool形表达式的值
check_stm函数:检查语句s。当检查赋值语句id=E时,对id查表返回id类型,对赋给的值E调用表达式的检查函数check_exp,返回其类型,再判断类型是否一致;当检查PRINTI语句时,需判断要打印的表达式E的类型;检查PRINTB与其类似,不再详述。
符号表
符号表的概念
符号表:用来存储程序中的相关变量信息,如类型、作用域、访问控制信息等
(1)作用域:变量在哪一层进行声明的,是程序最外层还是局部,还是里面进一步的模块等
(2)访问控制信息:文件能够访问,还是整个包能够访问
总的来说,符号表要把所有关于变量的信息都存储起来,以方便检查时候和程序后续阶段的使用
特点:1. 内容要足够丰富,涵盖变量的全部信息
2. 因为程序规模很大,所以必须通过合理的组织方式使符号表高效
符号表的接口
定义数据结构 + 定义功能(新建、插入、查找)
符号表的典型数据结构
关键字(变量)定义为字符串类型,值可能不止一种,所以定义一个结构体,里面字段是想维护的变量上的各种信息。
符号表的高效实现
时间高效:哈希表(但会消耗空间) 时间复杂度为O(1)
空间高效:红黑树(但会浪费时间) 时间复杂度为O(lgN)
在实际工程中,选择符号表的数据结构时,需要权衡时间空间的开销
符号表处理作用域
作用域例子里面,不同变量x的作用域不同,解决方案如下:
- 方法1:一张表的方法,进入作用域插入元素,退出作用域删除元素。如下图所示
例子:加入新的映射,形成新的符号表o1和o2,在退出这个结构的时候,做关于这个映射的符号表删除操作,o2退回到o1,下面同理
屏蔽:注意到,o2和o4形成了对外层变量x的屏蔽,新加入的变量x对哈希表才用头插法(哈希表指针指向新变量x)
- 方法2:采用符号表构成的栈(多张表),进入作用域插入新符号表,退出删除
当赋值x等于6的时候,要先从栈顶的符号表开始查找该变量,没查到再向栈底方向继续查找
当退出该作用域的时候,栈顶的符号表需要被弹掉(删除)
符号表处理名字空间
名字空间:在一个程序里面,可以有好几个不同的地方用到同一个变量名,但是这些变量名的作用是不一样的,它们是可以同时存活的
在例子里,每一个list属于不同的分类:1.结构体名字 2.变量名 3.结构体的域名 4.标号名
每个名字空间(每个分类)用一个符号表处理,用的时候,到对应的符号表里面查找变量即可
语义分析中的其他问题
需要考虑的其他问题:
- 类型相容性(相等是相容的一部分)
- 错误诊断:语义错误的诊断信息反馈给程序员
- 代码翻译:考虑如何在做语义分析的同时,能够生成后面阶段所需的中间代码
(注:这章PPT上内容较全,不再截图)
类型相等
- 示例一:名字相等vs结构相等
采用名字相等的语言可以直接比较(AB不等)
采用结构相等的语言需要递归比较各个域(AB相等)
- 示例二:面向对象的继承
如果光看类型的话,y的类型是B,x的类型是A,y和x是不相等的
但实际上,将子类的对象赋给父类的对象是合法的
因此,我们需要维护面向对象语言中类型的继承关系
错误诊断
- 尽可能准确的出错信息
- 尽可能多的出错信息(最好可以从错误中进行恢复修正)
- 尽可能准确的出错位置(编译器是一个流水线的模式,每个模块仅仅依赖于前后两个模块的中间表示,因此在该位置准确报错,需要将程序代码的位置信息从前端保留和传递过来)
代码翻译
现代的编译器语义分析模块,除了要做语义分析外,还要负责生成中间代码或目标代码
代码生成过程也同样是对树的某种遍历(将会在下面章节具体讨论)
总结:因此语义分析模块往往是编译器中最庞大也是最复杂的模块,实际工程中需多加小心
第八章代码生成
讨论编译器的中间端和后端
橙色区域为中间端,橙色区域以后是后端。
中间端和后端的过程:前端产生抽象语法树,抽象语法树作为输入传递给翻译1,产生第一个中间表示作为输出;中间表示1作为输入传递给翻译2,翻译2产生第二个中间表示作为输出;中间表示2传递给更多的翻译和中间表示生成汇编代码。
注意:采用多少个中间表示以及它的翻译以及采用什么样的中间表示和编译器设计者的选择密切相关,并不存在唯一的所谓正确的一个方案。根据不同的编译器的目标,根据所编译语言的特点,根据所要生成的目标代码的特点灵活掌握。
编译器做的事就是把高级语言的抽象层次一层层降低,逐渐向生成的目标代码靠近,一直到生成目标代码为止。
编译器切分的过程和具体语言相关
最简单的结构
一遍生成代码, 早期流行,现在不流行
要处理的抽象层次变化相当大,维护上难度非常大
代码生成的任务
负责把源程序翻译为“目标机器”上的代码
目标机器可以是真实的物理机器:x86,ARM等
可以使虚拟机:JVM等
编译器完成的最重要的目标是保证源程序和目标代码的等价性:源代码做什么,生成的目标代码就做什么
代码生成的两个重要子任务:
给源程序的数据分配计算资源:把源程序的数据放到机器合理的位置上
给源程序的代码选择合适的指令来实现:选择合适的指令首先要考虑等价性、对于负责任的编译器还要考虑实现的效率(对于高层代码的实现很可能不是唯一的)
给数据分配计算资源
数据区可以存放全局变量,代码区可以存放编译出来的汇编代码或二进制代码,栈区用来做函数调用返回,堆区用来做动态的数据分配
要根据程序的特点和编译器的设计目标,合理的为源程序的数据分配计算资源。如果选择错误的计算资源可能会导致程序运行结果错误。
一般,变量优先放在寄存器里
给源程序级别的代码选择合适的机器指令
例如源程序S对应的机器指令是I1,I2,...,In,它们从外观表示上必须是等价的。
源代码选择合适的机器指令时要考虑表达式的运算、语句、函数等;考虑机器指令的算术运算、比较、跳转、函数调用返回
用机器指令实现高层代码的语义时一是要保证等价性,二是设计者对ISA的熟悉程度会对给源代码选择合适的机器指令产生影响。
两种不同的ISA上的代码生成技术
栈计算机Stack
栈计算机代表了现在相当大一类的虚拟机的设计技术
讨论栈式计算机的原因:生成代码最容易、有很多栈式虚拟机
栈式计算机的结构
内存:存放所有变量 ,一个变量占一个内存空间
栈:进行计算的空间,比如要计算加法,那么被加数,加数,求和的结果 都要放在栈里面
注意:栈的top指针指向存放数据的最后一个位置,不是要插入数据的位置,要和数据结构的栈的top指针进行区分。
执行引擎:负责解释和执行指令
栈计算机的指令集
指令的语义:push
指令的语义:load x
将x从内存读到栈顶上
指令的语义:store
将x从栈顶存储到内存上
指令的语义:add
从栈上取被加数和加数,计算出结果后将被加数和加数从栈上去掉,再将计算结果入栈。
整个栈的大小减少了1,新的栈顶元素是u+v
变量的内存分配伪指令
.int x前面的“.”说明是伪指令,伪指令不会被ALU执行,它是为了stack机器装载程序时事先为变量分配内存的。
举一个C--的例子:
int x; int y; int z;三条变量声明对应三条.int伪指令。过程:内存分配3个空间,分别叫做x,y,z
x=10;过程:把10压栈,再把10弹出来存放到x的内存空间
y=5;过程:把5压栈,再把5弹出来存放到y的内存空间
z=x+y;过程:栈顶指针增1,从x的内存空间取出数据存放到栈顶,栈顶指针再增1,从y的内存空间取出数据存放到新的栈顶位置,将两个数据相加的结果赋值给临时变量temp,栈顶指针-2,栈顶指针再+1,再将temp的值赋给栈顶,最后取出栈顶元素temp的值存放到z的内存空间,栈顶指针-1
y=z*x;栈顶指针+1,从z的内存空间取出数据存放到栈顶,栈顶指针再+1,从x的内存空间取出数据存放到新的栈顶位置,将两个数据相乘的结果赋值给临时变量temp,栈顶指针-2,再+1,再将temp的值赋给栈顶,最后取出栈顶元素temp的值存放到y的内存空间,栈顶指针-1
面向栈计算机的代码生成
递归下降代码生成算法:从C--到Stack
不变式的概念:在所有可能的情况下都会成立的一个约定
表达式的代码生成 不变式:表达式的值总在栈顶
扫描表达式,
①立即数:栈顶指针+1,立即数入栈
②变量名:栈顶指针+1,将数据从变量名对应的内存空间取出,压到栈顶
③布尔值true:栈顶指针+1,将1压栈(注意:只有1对应true,不包括其他非零常数)
④布尔值false:栈顶指针+1,将0压栈
⑤加法运算:执行e1的Gen_E函数(栈顶指针+1,将e1数据压栈),执行e2的Gen_E函数(栈顶指针再+1,将e2数据压栈),弹出add指令——add指令对应的是计算e1+e2,结果赋值给临时变量temp,栈顶指针-2,栈顶指针再+1,将temp的值压栈。
语句的代码生成 不变式:栈的规模不变
扫描语句,
①赋值语句 id=e:递归的调用Gen_E(e)(栈顶指针+1,将立即数e压栈),执行指令store id(栈顶元素赋值给变量id,栈顶指针-1,将id的值存放在名为id的内存空间)
实际上我们是在做什么?抽象语法树的后序遍历:
先扫描左子树(id,没有要生成的指令),再扫描右子树(立即数e,递归地调用Gen_E(e)函数),最后扫描根节点(“=”,执行emit指令)
语义分析和代码生成可以并行来做
②打印立即数语句printi(e):递归地调用Gen_E(e),执行printi指令(这时要扩充栈式计算机的指令集,添加printi进去)【在栈上表现为:栈顶指针+1,e压栈,e出栈,打印e,所以栈的规模没变】
③打印布尔值语句printb(e):递归地调用Gen_E(e),执行printb指令(这时要扩充栈式计算机的指令集,添加printb进去)【在栈上表现为:栈顶指针+1,e压栈,e出栈,打印e,所以栈的规模没变】
类型的代码生成 不变式:只生成.int类型
接受类型参数t,
①整型数:生成.int伪指令
②布尔型:生成.int伪指令,因为目标机器只支持整型数,1对应true,0对应false
变量声明的代码生成 不变式:只生成.int类型
变量声明T id,后面跟着一串尾巴D
比如扫描int x;D
扫描int时递归地调用Gen_T(T)生成伪指令.int,再扫描变量名x,生成伪指令“空格x”,扫描到D时再递归地调用Gen_D(D)
程序的代码生成:(比较简单)
调用Gen_D(D)处理变量声明,调用Gen_S(S)处理语句
如何运行生成的目标代码?
第三个方案很重要:在非栈式计算机上模拟栈式计算机的行为
JVM的JIT(即时编译):把Java的代码动态翻译成x86的代码,让生成的代码模拟Java代码的行为
寄存器计算机Reg
代表了现在非常主流的RISC
寄存器计算机Reg的结构(简化版)
寄存器计算机Reg的指令集
movn n,r:n是一个整型的常量,将整型常量n挪到叫r的寄存器
mov r1,r2:由寄存器r1赋值给寄存器r2
load [x],r:将名为x的内存空间内的数据赋值给寄存器r内
store r,[x]:将寄存器r内的数据写到名为x的内存空间内
add r1,r2,r3:r1+r2的结果赋值给r3
给变量的寄存器分配伪指令
注意和栈式计算机不同的是:表达式生成伪指令的函数有返回值,返回值为寄存器类型
表达式的代码生成
int i=0;
fresh(){
return i++;
}
每次调用fresh函数都会生成一个唯一的寄存器编号
扫描表达式:
①整型数n:寄存器编号增1,将整型数n移到寄存器r内,返回寄存器r
②变量名id:寄存器编号增1,将id内的值移到寄存器r内,返回寄存器r
③布尔值true:寄存器编号增1,将整型数1移到寄存器r内,返回寄存器r
④布尔值false:寄存器编号增1,将整型数0移到寄存器r内,返回寄存器r
重点研究
⑤表达式e1+e2:递归调用Gen_E(e1),返回寄存器r1,递归调用Gen_E(e2),返回寄存器r2,生成新的寄存器编号对应的是r3,执行add语句——将寄存器r1和r2的值相加,结果放在寄存器r3,返回寄存器r3
⑥表达式e1&&e2:递归调用Gen_E(e1),返回寄存器r1,递归调用Gen_E(e2),返回寄存器r2,生成新的寄存器编号对应的是r3,执行and语句——将寄存器r1和r2的值相与,结果放在寄存器r3,返回寄存器r3
语句的代码生成
扫描语句:
①id=e:递归调用Gen_E(e),返回寄存器r,执行mov语句——将寄存器r内的值移到寄存器id内
②printi(e):递归调用Gen_E(e),返回寄存器r,执行printi语句(这时要扩充寄存器计算机的指令集,添加printi进去)
③printb(e):递归调用Gen_E(e),返回寄存器r,执行printb语句(这时要扩充寄存器计算机的指令集,添加printb进去)
类型的代码生成(和栈式计算机相同,不再赘述)
变量声明的代码生成(和栈式计算机相同)
程序的代码生成(和栈式计算机一致)
示例
什么时候两个虚拟的寄存器可以共享一个物理寄存器,什么时候不能。
第九章-中间代码与程序分析
中间代码的地位和作用
中间代码有哪些??
- 树和有向无环图(DAG)
高层表示,适用于程序源代码
- 三地址码(3-Adress code)
底层表示,更靠近目标机器
- 控制流图(CFG)
更精细的三地址码,程序的图状表示。适合做程序分析、程序优化等。
- 静态单赋值形式(SSA)
更精细的控制流图,同时编码控制流信息和数据流信息
- 连续传递风格
更一般的SSA
中间代码的作用
中间代码的作用是可使编译程序的结构在逻辑上更为简单明确,特别是可使目标代码的优化比较容易实现中间代码,即为中间语言程序,中间语言的复杂性介于源程序语言和机器语言之间。
为什么要有不同的中间代码?
编译器工程上的考虑
- 阶段划分:把整个编译过程划分成不同的阶段
- 任务分解:每个阶段只处理翻译过程的一个步骤
- 代码工程:代码更容易实现、除错、维护和演进
- 程序分析和代码优化的需要
两者都和程序的中间表示密切相关
- 许多优化在特定的中间表示上才可以或才容易进行
三地址码的基本思想
三地址码的优缺点
控制流图的优点
控制流图相关的基本概念
数据流分析
第十章-代码优化
代码优化概述
这是现代编译器设计中非常重要的一个课题。
优化能力怎么样是衡量一个编译器的重要指标。
编译优化的位置
如果在抽象语法树阶段做了优化,有可能会遗漏一些程序员写错的东西。有时会影响程序员理解代码。
所以至少是应该在语义分析器之后(语义分析器到中间表示)
优化不是单独进行的,在每个中间表示都可以做在汇编阶段也可以做。
在抽象语法树阶段的优化叫早期优化,中间的地方叫中间代码优化,最后汇编叫做目标代码优化或是晚期优化 三个阶段。但是是否容易进行要具体分析
什么是代码优化?
- 代码优化是对被优化得到程序进行的一种语义保持的变换
- 语义保持:程序的可观察行为不能改变(比如程序和外界交互的行为不能变)
- 变换的目的是让程序能够比变换前:更快、更小、cache行为更好、更节能等
不存在完全优化
等价于停机问题:
给定程序p,能否写出算法Q,判断P运行能否会终止? 不存在
没法找到opt函数
所以不存在完全优化的问题,就是说可以一直优化下去。
“编译器从业者永不失业!”
代码优化很困难
- 不能保证优化总能产生好的结果
- 优化的顺序和组合很关键
- 很多优化问题是非确定的
- 优化的正确性论证很微妙
一些正确的观点
- 把该做对的做对。不是任何程序都会同概率出现所以能处理大部分常见情况的优化就可以接受。
- 不期待完美编译器。如果一个编译器有足够多的优化,那他就是一个好的编译器