# 编译原理 ## 0. 目录 - 编译原理之美 - 编译原理实战 ### 编译原理之美 ### 编译原理实战 #### 1. 编译的全过程都悄悄做了哪些事情? - 编译,其实就是把源代码变成目标代码的过程。 - 编译器翻译源代码,也需要经过多个处理步骤 - ![编译器翻译步骤](pic/编译器翻译步骤.png) - 词法分析(Lexical Analysis) - 第一步,就是要像读文章一样,先把里面的单词和标点符号识别出来。程序里面的单词叫做 Token,它可以分成关键字、标识符、字面量、操作符号等多个种类。**把字符串转换 为 Token 的这个过程,就叫做词法分析**。 - ![词法分析](pic/词法分析.png) - 语法分析(Syntactic Analysis) - 下一步,**我们需要让编译器像理解自然语言一样,理解它的语法结构**。这就是第二步,**语法分析**。 - 给一个句子划分语法结构, 比如说:“我喜欢又聪明又勇敢的你”, - ![语法结构](pic/语法结构.png) - 在编译器里,语法分析阶段也会把 Token 串,转换成**一个体现语法规则的、树状的数据结构**,这个数据结构叫做**抽象语法树(AST,Abstract 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 Representation,IR)。 - 中间代码(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 Automaton,FSA),或者叫做有限状态自动机(Finite-state Machine,FSM)。 - 有限自动机这个名字,听上去可能比较陌生。但大多数程序员,肯定都接触过另一个词:**状态机**。假设你要做一个电商系统,那么订单状态的迁移,就是一个状态机。 - ![状态机的例子](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。其中,第一个状态(i,initial)是初始状态,第二个状态 (f,final) 是接受状态。 - ![识别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+”,那就没有办法跳过 s,s 至少要经过一次:\ - ![识别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. 语法分析:两个基本功和两种算法思路