You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

252 lines
20 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 编译原理
## 0. 目录
- 编译原理之美
- 编译原理实战
### 编译原理之美
### 编译原理实战
#### 1. 编译的全过程都悄悄做了哪些事情?
- 编译,其实就是把源代码变成目标代码的过程。
- 编译器翻译源代码,也需要经过多个处理步骤
- ![编译器翻译步骤](pic/编译器翻译步骤.png)
- 词法分析Lexical Analysis
- 第一步,就是要像读文章一样,先把里面的单词和标点符号识别出来。程序里面的单词叫做 Token它可以分成关键字、标识符、字面量、操作符号等多个种类。**把字符串转换
为 Token 的这个过程,就叫做词法分析**。
- ![词法分析](pic/词法分析.png)
- 语法分析Syntactic Analysis
- 下一步,**我们需要让编译器像理解自然语言一样,理解它的语法结构**。这就是第二步,**语法分析**。
- 给一个句子划分语法结构, 比如说:“我喜欢又聪明又勇敢的你”,
- ![语法结构](pic/语法结构.png)
- 在编译器里,语法分析阶段也会把 Token 串,转换成**一个体现语法规则的、树状的数据结构**,这个数据结构叫做**抽象语法树ASTAbstract Syntax Tree**。我们前面的示例
程序转换为 AST 以后,大概是下面这个样子:
- ![语法结构AST](pic/语法结构AST.png)
- 这样的一棵 AST 反映了示例程序的语法结构。比如说我们知道一个函数的定义包括了返回值类型、函数名称、0 到多个参数和函数体等。这棵抽象语法树的顶部就是一个函数节
点,它包含了四个子节点,刚好反映了函数的语法。
- 再进一步,函数体里面还可以包含多个语句,如变量声明语句、返回语句,它们构成了函数体的子节点。然后,每个语句又可以进一步分解,直到叶子节点,就不可再分解了。而叶子
节点,就是词法分析阶段生成的 Token图中带边框的节点。对这棵 AST 做深度优先的遍历,你就能依次得到原来的 Token。
- 语义分析Semantic Analysis
- 很小的时候就能够给大人读报纸。因为他们懂得发音规则,能念出单词来(词法分析),也基本理解语法结构(他们不见得懂主谓宾这样的术语,但是凭经验已
经知道句子有不同的组成部分),可以读得抑扬顿挫(语法分析),但是他们不懂报纸里说的是什么,也就是不懂语义。这就是编译器解读源代码的下一步工作,语义分析。
- 那么,怎样理解源代码的语义呢?
- 参考对应的语言规范标准,例如 ECMAScript也就是 JavaScript标准
- 深度遍历 AST并执行每个节点附带的语义规则我们也就能够理解一个句子的含义、一个函数的含义乃至整段源代码的含义。
- 这也就是说AST 加上这些语义规则,就能完整地反映源代码的含义。这个时候,你就可以做很多事情了。比如,你可以深度优先地遍历 AST并且一边遍历一边执行语法规
add 节点:把两个子节点的值相加,作为自己的值;变量节点(在等号右边的话):取出变量的值;数字字面量节点:返回这个字面量代表的值。则。那么这个遍历过程,就是解释执行代码的过程。你相当于写了一个基于 AST 的解释器。
- 这里的语义分析是要解决什么问题呢?
- 而把“a+3”中的 a跟正确的变量定义关联的过程就叫做引用消解Resolve
- 变量有点像自然语言里的代词,比如说,“我喜欢又聪明又勇敢的你”中的“我”和“你”,指的是谁呢?如果这句话前面有两句话,“我是春娇,你是志明”,那
这句话的意思就比较清楚了,是“春娇喜欢又聪明又勇敢的志明”。
- 引用消解需要在上下文中查找某个标识符的定义与引用的关系,所以我们现在可以回答前面的问题了,**语义分析的重要特点,就是做上下文相关的分析**。
- 在语义分析阶段编译器还会识别出数据的类型。比如在计算“a+3”的时候我们必须知道 a 和 3 的类型是什么。因为**即使同样是加法运算,对于整型和浮点型数据,其计算方
法也是不一样的**。
- 语义分析获得的一些信息(引用消解信息、类型信息等),会附加到 AST 上。**这样的 AST叫做带有标注信息的 AST**Annotated AST/Decorated AST用于更全面地反映源代码的含义。
- ![带有标注的AST](pic/带标注的AST.png)
- 在语义分析阶段,编译器还要做很多语义方面的检查工作。
- 总结起来,在语义分析阶段,编译器会做**`语义理解和语义检查`**这两方面的工作。词法分析、语法分析和语义分析,统称编译器的**前端**,它完成的是对源代码的理解工作。
- 做完语义分析以后,接下来编译器要做什么呢?
- 生成目标代码的工作,叫做后端工作。
- 熟练掌握汇编代码对于初学者来说会有一定的难度。但更麻烦的是对于不同架构的CPU还需要生成不同的汇编代码这使得我们的工作量更大。所以我们通常要在这个
时候增加一个环节先翻译成中间代码Intermediate RepresentationIR
- 中间代码Intermediate Representation
- 我们倾向于使用 IR 有两个原因。
- 第一个原因,是很多解释型的语言,可以直接执行 IR比如 Python 和 Java。这样的话 编译器生成 IR 以后就完成任务了,没有必要生成最终的汇编代码。
- 第二个原因更加重要。我们生成代码的时候,需要做大量的优化工作。而很多优化工作没有必要基于汇编代码来做,而是可以基于 IR用统一的算法来完成。
- 优化Optimization
- 那为什么需要做优化工作呢?这里又有两大类的原因。
- 第一个原因,是**源语言和目标语言有差异**。源语言的设计目的是方便人类表达和理解,而目标语言是为了让机器理解。在源语言里很复杂的一件事情,到了目标语言里,有可能很简单
地就表达出来了。
- 以 Java 为例,我们经常为某个类定义属性,然后再定义获取或修改这些属性的方法:
- 如果你在程序里用“person.getName()”来获取 Person 的 name 字段,会是一个开销很大的操作,因为它涉及函数调用。在汇编代码里,实现一次函数调用会做下面这一大堆事情:
- ![person.getName()汇编逻辑](pic/person.getName()汇编逻辑.png)
- 你看了这段伪代码,就会发现,简单的一个 getName() 方法,开销真的很大。保存和恢复寄存器的值、保存和读取返回地址,等等,这些操作会涉及好几次读写内存的操作,要花费大量的时钟周期。但这个逻辑其实是可以简化的。
- 怎样简化呢?就是**跳过方法的调用**。我们直接根据对象的地址计算出 name 属性的地址,然后直接从内存取值就行。这样优化之后,性能会提高好多倍。
- 这种优化方法就叫做**内联inlining**,也就是**把原来程序中的函数调用去掉,把函数内的逻辑直接嵌入函数调用者的代码中**。
- 第二个需要优化工作的原因,是程序员写的代码不是最优的,而编译器会帮你做纠正。
- ![编译器代码优化](pic/编译器代码优化.png)
- 而采用中间代码来编写优化算法的好处,**是可以把大部分的优化算法,写成与具体 CPU 架构无关的形式,从而大大降低编译器适配不同 CPU 的工作量**。并且,如果**采用像 LLVM 这
样的工具,我们还可以让多种语言的前端生成相同的中间代码,这样就可以复用中端和后端的程序了**。
- 生成目标代码
- 编译器最后一个阶段的工作,是生成高效率的目标代码,也就是汇编代码。这个阶段,编译器也有几个重要的工作。
- 第一,是要选择合适的指令,生成性能最高的代码。
- 第二,是要优化寄存器的分配,让频繁访问的变量(比如循环变量)放到寄存器里,因为访问寄存器要比访问内存快 100 倍左右。
- 第三,是在不改变运行结果的情况下,对指令做重新排序,从而充分运用 CPU 内部的多个功能部件的并行计算能力。
- 目标代码生成以后,整个编译过程就完成了。
#### 2. 词法分析:用两种方式构造有限自动机
- 词法分析的原理
- 你已经很熟悉词法分析的任务了:输入的是字符串,输出的是 Token 串。所以,词法分析器在英文中一般叫做 Tokenizer。
- ![词法分析的原理](pic/词法分析的原理图.png)
- 但具体如何实现呢?这里要有一个计算模型,叫做**有限自动机**Finite-state AutomatonFSA或者叫做有限状态自动机Finite-state MachineFSM
- 有限自动机这个名字,听上去可能比较陌生。但大多数程序员,肯定都接触过另一个词:**状态机**。假设你要做一个电商系统,那么订单状态的迁移,就是一个状态机。
- ![状态机的例子](pic/状态机的例子.png)
- 有限自动机就是这样的状态机,它的状态数量是有限的。当它收到一个新字符的时候,会导致状态的迁移。比如说,下面的这个状态机能够区分标识符和数字字面量。
- ![有限自动机](pic/有限自动机.png)
- 在这样一个状态机里,我用单线圆圈表示临时状态,双线圆圈表示接受状态。接受状态就是一个合格的 Token比如图 3 中的状态 1数字字面量和状态 2标识符。当这两个
状态遇到空白字符的时候,就可以记下一个 Token并回到初始态状态 0开始识别其他 Token。
- 可以看出,**词法分析的过程,其实就是对一个字符串进行模式匹配的过程**。说起字符串的模式匹配,你能想到什么工具吗?对的,**正则表达式工具**。
- 同样地,正则表达式也可以用来描述词法规则。这种描述方法,我们叫做**正则文法Regular Grammar**。比如,数字字面量和标识符的正则文法描述是这样的:
```js
IntLiteral : [0-9]+; //至少有一个数字
Id : [A-Za-z][A-Za-z0-9]*; //以字母开头,后面可以是字符或数字
```
- 与普通的正则表达式工具不同的是,词法分析器要用到很多个词法规则,每个词法规则都采用 **“Token 类型: 正则表达式”** 这样一种格式,用于匹配一种 Token。
- 然而当我们采用了多条词法规则的时候有可能会出现词法规则冲突的情况。比如说int 关键字其实也是符合标识符的词法规则的。
```js
Int : int; //int关键字
For : for; //for关键字
Id : [A-Za-z][A-Za-z0-9]*; //以字母开头,后面可以是字符或数字
```
- 所以,词法规则里面要有优先级,比如排在前面的词法规则优先级更高。这样的话,我们就能够设计出区分 int 关键字和标识符的有限自动机了,可以画成下面的样子。其中,状态
1、2 和 3 都是标识符,而状态 4 则是 int 关键字。
- ![识别int关键字和标识符的有限自动机](pic/识别int关键字和标识符的有限自动机.png)
- 从正则表达式生成有限自动机
- 我们能否只写出词法规则,就自动生成相对应的有限自动机呢?
- 当然是可以的,实际上,正则表达式工具就是这么做的。此外,词法分析器生成工具 lex及 GNU 版本的 flex也能够基于规则自动生成词法分析器。
- 它的具体实现思路是这样的:把一个正则表达式翻译成 NFA然后把 NFA 转换成 DFA。
- 先说说 **DFA**它是“Deterministic Finite Automaton”的缩写即**确定的有限自动机**。它的特点是:该状态机在任何一个状态,基于输入的字符,都能做一个确定的状态转换。前
面例子中的有限自动机,都属于 DFA。
- 再说说 **NFA**它是“Nondeterministic Finite Automaton”的缩写即**不确定的有限自动机**。它的特点是:该状态机中存在某些状态,针对某些输入,不能做一个确定的转换。
- 这又细分成两种情况:
- 对于一个输入,它有两个状态可以转换。
- 存在ε转换的情况也就是没有任何字符输入的情况下NFA 也可以从一个状态迁移到另一个状态。
- 比如“a[a-zA-Z0-9]*bc”这个正则表达式对字符串的要求是以 a 开头,以 bc 结尾a 和 bc 之间可以有任意多个字母或数字。可以看到,在图 5 中,状态 1 的节点输入 b
时,这个状态是有两条路径可以选择的:一条是迁移到状态 2另一条是仍然保持在状态 1。所以这个有限自动机是一个 NFA。
- ![NFA的例子](pic/NFA的例子.png)
- 这个 NFA 还有引入ε转换的画法,如图 6 所示,它跟图 5 的画法是等价的。实际上,图 6 表示的 NFA 可以用我们下面马上要讲到的算法,通过正则表达式自动生成出来。
- ![另一个NFA的例子](pic/另一个NFA的例子.png)
- 需要注意的是,无论是 NFA 还是 DFA都等价于正则表达式。也就是说**所有的正则表达式都能转换成 NFA 或 DFA而所有的 NFA 或 DFA也都能转换成正则表达式**。
- 理解了 NFA 和 DFA 以后,接下来我再大致说一下算法。
- 首先,一个正则表达式可以机械地翻译成一个 NFA。它的翻译方法如下
- 识别字符 i 的 NFA。
- 当接受字符 i 的时候,引发一个转换,状态图的边上标注 i。其中第一个状态iinitial是初始状态第二个状态 (ffinal) 是接受状态。
- ![识别i的DFA](pic/识别i的DFA.png)
- 转换“s|t”这样的正则表达式。
- 它的意思是,或者 s或者 t二者选一。s 和 t 本身是两个子表达式,我们可以增加两个新的状态:开始状态和接受状态。然后,用ε转换分别连接代表 s 和 t 的子图。它的含义也
比较直观,要么走上面这条路径,那就是 s要么走下面这条路径那就是 t
- ![识别s|t的NFA](pic/识别st的NFA.png)
- 转换“st”这样的正则表达式
- s 之后接着出现 t转换规则是把 s 的开始状态变成 st 整体的开始状态,把 t 的结束状态变成 st 整体的结束状态,并且把 s 的结束状态和 t 的开始状态合二为一。这样就把两个子图
衔接了起来,走完 s 接着走 t。
- ![识别st的NFA1](pic/识别st的NFA1.png)
- 对于“?”“*”和“+”这样的符号,它们的意思是可以重复 0 次、0 到多次、1 到多次转换时要增加额外的状态和边。以“s*”为例,我们可以做下面的转换:
- ![识别s星号的NFA](pic/识别s星号的NFA.png)
- 你能看出,它可以从 i 直接到 f也就是对 s 匹配 0 次,也可以在 s 的起止节点上循环多次。
- 如果是“s+”,那就没有办法跳过 ss 至少要经过一次:\
- ![识别s加好的NFA](pic/识别s加好的NFA.png)
- 通过这样的转换,所有的正则表达式,都可以转换为一个 NFA。
- 基于 NFA你仍然可以实现一个词法分析器只不过算法会跟基于 DFA 的不同:当某个状态存在一条以上的转换路径的时候,你要先尝试其中的一条;如果匹配不上,再退回来,尝
试其他路径。这种试探不成功再退回来的过程,叫做**回溯Backtracking**。
- 基于 NFA你也可以写一个正则表达式工具。实际上我在示例程序中已经写了一个简单的正则表达式工具使用了 **Regex.java中的 regexToNFA** 方法。如下所示,我用了一
个测试用的正则表达式,它能识别 int 关键字、标识符和数字字面量。在示例程序中,这个正则表达式首先被表示为一个内部的树状数据结构,然后可以转换成 NFA。
```js
int | [a-zA-Z][a-zA-Z0-9]* | [0-9]*
```
- 示例程序也会将生成的 NFA 打印输出下面的输出结果中列出了所有的状态以及每个状态到其他状态的转换比如“0 ε -> 2”的意思是从状态 0 通过 ε 转换,到达状态 2
```txt
NFA states:
0 ε -> 2
ε -> 8
ε -> 14
2 i -> 3
3 n -> 5
5 t -> 7
7 ε -> 1
1 (end)
acceptable
8 [a-z]|[A-Z] -> 9
9 ε -> 10
ε -> 13
10 [0-9]|[a-z]|[A-Z] -> 11
11 ε -> 10
ε -> 13
13 ε -> 1
14 [0-9] -> 15
15 ε -> 14
ε -> 1
```
- 我用图片来直观展示一下输出结果,分为上、中、下三条路径,你能清晰地看出解析 int 关键字、标识符和数字字面量的过程:
- ![由算法自动生成的NFA](pic/由算法自动生成的NFA.png)
- 那么生成 NFA 之后,我们要如何利用它,来识别某个字符串是否符合这个 NFA 代表的正则表达式呢?
- 还是以图 12 为例当我们解析“intA”这个字符串时首先选择最上面的路径进行匹配匹配完 int 这三个字符以后,来到状态 7若后面没有其他字符就可以到达接受状态 1
返回匹配成功的信息。
- 可实际上int 后面是有 A 的,所以第一条路径匹配失败。失败之后不能直接返回“匹配失败”的结果,因为还有其他路径,所以我们要回溯到状态 0去尝试第二条路径在第二
条路径中,我们尝试成功了。
- 运行 Regex.java 中的 matchWithNFA() 方法,你可以用 NFA 来做正则表达式的匹配。其中在匹配“intA”时你会看到它的回溯过程
```js
NFA matching: 'intA'
trying state : 0, index =0
trying state : 2, index =0 //先走第一条路径即int关键字这个路径
trying state : 3, index =1
trying state : 5, index =2
trying state : 7, index =3
trying state : 1, index =3 //到了末尾,发现还有字符'A'没有匹配上
trying state : 8, index =0 //回溯,尝试第二条路径,即标识符
trying state : 9, index =1
trying state : 10, index =1 //在10和11这里循环多次
trying state : 11, index =2
trying state : 10, index =2
trying state : 11, index =3
trying state : 10, index =3
true
```
- **从中你可以看到用 NFA 算法的特点**因为存在多条可能的路径所以需要试探和回溯在比较极端的情况下回溯次数会非常多性能会变得非常差。特别是当处理类似“s*”这样
的语句时,因为 s 可以重复 0 到无穷次,所以在匹配字符串时,可能需要尝试很多次。
- NFA 的运行可能导致大量的回溯,**那么能否将 NFA 转换成 DFA让字符串的匹配过程更简单呢** 如果能的话,那整个过程都可以自动化,从正则表达式到 NFA再从 NFA 到 DFA。
- 方法是有的,这个算法就是**子集构造法**。
- 总之,只要有了准确的正则表达式,是可以根据算法自动生成对字符串进行匹配的程序的,这就是正则表达式工具的基本原理,也是有些工具(比如 ANTLR 和 flex能够自动给你生
成一个词法分析器的原理。
#### 3. 语法分析:两个基本功和两种算法思路