编译的基本流程
从学习编程开始编译器就是一个绕不开的话题。代码文件在编译器的作用下编译为操作系统中的一个可执行文件。作为一个程序员,了解编译器的基本原理是非常有必要的。编译器可以大致分为编译前端以及后端两个部分。编译器前端负责将代码文件转换为抽象语法树。而编译器后端则将抽象语法树编译为可执行的二进制文件。当然一些解释性语言的后端部分并不一定产出可执行的二进制文件,而是直接执行抽象语法树。
随着编译技术的发展又出现了中端的概念,中端负责将抽象语法树转换为被 JVM 或 LLVM 支持的中间码(IR)。采用中端的好处是,只需要实现少量的转换代码,就能依靠 IR 来实现跨平台的特征。
本文主要介绍编译器前端中的词法分析部分。
词法分析
词法分析的基本目的和方法
词法分析是编译中的第一个步骤,旨在将源代码分割为一个一个的 Token,也叫做词素。得到的 Token 序列将作为语法分析器的输入。进行词法分析的组件也叫做词法分析器(Lexer),或者叫扫描器(Scanner)。一段常见的 python 代码 total = cnt + 10
可能被词法分析器分割为以下的 Token 序列:
scss
(total, Identifier)
(=, Eq)
(cnt, Identifier)
(+, Plus)
(10, Number)
一个 Token 常常包含以下信息:
- 对应的文本
- Token 的类型,例如上面的 total 类型就为标识符
- Token 所在源代码的位置,便于词法/语法分析出现错误时给出详细的错误位置提示
实现词法分析有很多种方法,例如:
- 手动词法分析: 先定义好语言所支持的词法类型,按照这些类型编写对应的解析代码。手动词法分析的优点是逻辑简单清晰,便于调试位置。缺点是如果词法类型有新增或修改是词法分析的逻辑。
- 基于正则表达式: 通过定义正则表达式来完成对词法类型的定义。优点是词法类型修改时不用修改核心逻辑,只需要修改对应的正则表达式即可。缺点是正则表达式的逻辑较为复杂,并且由于核心为状态机实现,调试较为复杂。
- 采用其他成熟的工具: 例如 Yacc, Antlr 等完成。
本文主要介绍方式 2,如何基于正则表达式来完成词法分析。
正则表达式
基于正则表达式的词法分析首先需要借助正则表达式来定义所支持的词法。然后一个字符一个字符的遍历源代码,并将源代码分割为 Token 序列。例如,定义下列的正则表达式及其所匹配的类型:
less
[a-zA-Z_][a-zA-Z_0-9]* -> Indentifier // 识别标识符
var -> Keyword var // 识别关键字 var
[0-9]+ -> Number // 识别数字
+|-|*|/ -> Operator // 识别运算符
\n|\r| |\t| -> Skip // 识别需要跳过的字符比如制表符,空格等
通过或运算将它们组合成一个正则表达式,就可以用于对 var cnt = 5 + 1
这样语句进行匹配。其基本逻辑为:
css
进入初始状态
读取字符 v, 可以被 [a-zA-Z_][a-zA-Z_0-9]* 或者 var 匹配,继续
读取字符 a, 可以被 [a-zA-Z_][a-zA-Z_0-9]* 或者 var 匹配,继续
读取字符 r, 可以被 [a-zA-Z_][a-zA-Z_0-9]* 或者 var 匹配,继续,此时已经匹配上关键字 var 的正则。记录匹配上的 Token 为 (var, Keyword var),继续
读取字符空格,没有任何表达式能匹配 'var ' 这样的字符串。此时输出上一个记录匹配到的 Token。重置为初始状态。匹配空格,记录匹配上的 Token 为 ( , Skip)。
读取 c, 没有任何表达式能匹配 ' c'这样的字符。此时输出上一个记录匹配到的 Token。重置为初始状态。c 可以被 [a-zA-Z_][a-zA-Z_0-9]* 匹配,继续
读取 n, 可以被 [a-zA-Z_][a-zA-Z_0-9]*, 继续
读取 t, 可以被 [a-zA-Z_][a-zA-Z_0-9]*, 继续
读取字符空格, 没有任何表达式能匹配 'cnt '这样的字符串。此时输出上一个记录匹配到的 Token。重置为初始状态。
...
可以看出,通过不断的读取字符进行匹配就可以将源代码分割为一个一个的 Token。这里需要注意的是,匹配的过程要按最长匹配去进行。例如当匹配到 var 时虽然也命中了正则表达式中的 var,但是可能此时的 var 是处于标识符 var1 中。不能在读到 var 时就停止匹配,这样 var1 就被分割为了 var 和 1, 这是显然不对的。必须让正则表达式匹配完一个最长的字符串,才能得到正确的分割结果。
如何实现上述这样的正则表达式是本文的重点。先从最基础的正则表达式开始,一个最简单的正则表达式只能匹配单个字符,或者匹配一个空字符ε。但正则表达式可以利用以下操作进行扩展:
- 连接: 连接两个正则表达式,例如正则表达式 a, b 分别匹配 a, b 两个字符。那么 ab 则是匹配 ab 这个字符串。
- 或: 将两个正则表达式进行或操作, 通过
a|b
来表示。既可以匹配a,也可以匹配 b - 克林闭包(Kleene Closure): 某个正则表达式的克林闭包可以匹配这个正则表达式零次或多次,记作 a*。例如 a* 可以匹配空字符串,a, aa, ...
利用以上的操作可以即扩展正则表达式来达到描述一门语言的词法的目的。
有限状态机(Finite-State Machine, FSA)
正则表达式可以通过状态机来实现。一个状态机由多个状态组成。它读取一个字符串,按照预定义的规则转移从一个状态转移到另一个状态。当一个字符串输入完成后,如果停留在一个可接受状态 ,则表示该状态机接受该字符串串,否则拒绝。另外,如果当处于到某个状态时,其输入的字符不在该状态可以转移的字符范围内,同样也可以认为拒绝该字符串。例如正则表达式 abc
的状态机可以表示为:
其中状态 4 为可接受状态,通过一个同心圆环来表示。
状态机可以分为确定性状态机(Deterministic Finite State Machines, DFA)和非确定性状态机(Nondeterministic Finite State Machines, NFA),一个 DFA 需要满足以下条件:
- 从同一个状态开始,只能通过同一个输入转移到一个确定的状态
- 不能通过空字符串ε进行转移(ε 转移)
一个状态机只要不满足上述条件中的任意一条都可以称为非确定性状态机(NFA)。DFA 和 NFA 都是等价且可相互转换的。但是它们具有各自的特点,在下面的内容会详细介绍。
正则表达式转换为 NFA
首先来看表示一个字符的正则表达式如何转换为 NFA:
然后是各种运算的表示:
连接运算
或运算
克林闭包
一般来说,对于或运算倾向采用 ε 转移来实现,因为这样可以在不修改原有状态机的情况下完成。对于编写代码来说更加简单:
同样,对于克林闭包也可以得到:
从上面的例子来看,正则表达式转换到到 NFA 之后往往存在 ε 转移。因此正则表达式往往不容易直接转换为 DFA,但比较容易转换为 NFA。
而对于 NFA 来说,从同一状态出发可能转移到两个状态,这不利于我们进行代码实现。因为在面对一个输入能转移到多个状态的情况,只能先猜测其转移到某个状态,如果最该猜测的转移没有接受这个字符串。那么再继续下一个猜测,直到进入接受状态,或将所有可能转移都尝试一遍。这对代码实现和性能都不友好。而 DFA 总是通过一个输入转移到一个确定状态,因此不存在需要猜测的情况,代码实现也比较简单。因此,结合 DFA 和 NFA 的特点,整个词法分析的流程往往为 定义正则表达式 -> 转换为 NFA -> 转换为 DFA
。
NFA 转换为 DFA
如上面提到,最终需要将 NFA 转换为 DFA 后更利于代码实现。再来复习一下 DFA 的两个要求:
- 从同一个状态开始,只能通过同一个输入转移到一个确定的状态
- 不能通过空字符串进行转移(ε转移)
因此将 NFA 转换为 DFA 的思路也非常简单。如果一个 NFA 状态能根据同一个输入转移到多个 NFA 状态,那么将这些通过同一个输入能转移到的所有的 NFA 状态都记录下来,将其组成一个新的状态,这个状态就是一个 DFA 状态。并且,这些 NFA 能够通过ε转移能到达的状态(ε 闭包)也应该被这个 DFA 状态所包含。计算从一个状态通过ε转移能达到的所有状态的步骤,称为这个状态的 ε 闭包(ε-closure)。 因此,将一个 NFA 转换为 DFA 的算法如下
less
一个 DFA 状态由多个 NFA 状态来构造。如果构造两个 DFA 的 NFA 状态相同,那么这两个 DFA 视为相同状态。
求初始 NFA 状态的 ε 闭包作为 DFA 初始状态。
将 DFA 初始状态放入栈 S 中
循环,直到栈为空:
取出一个 DFA 状态,设为 D1,以及构成这个 DFA 状态的所有 NFA 状态。
循环,对于这些 NFA 状态集合能够进行转移的每个输入 i:
计算所有 NFA 从 i 出发能够达到的 NFA 状态及其 ε 闭包集合设为 Ni,
使用该集合构成一个 DFA 状态 Di,注意如果 Ni 中有一个可接受状态,那么 Di 也是可接受状态
则有 D1 --i--> Di,即 D1 能够通过 i 转移到 Ni 构成的 Di
如果 Di 不存在于 S,则压 Di 入 S
在得到一个 DFA 状态机后,就可以得到该 DFA 的状态转移表以及可接受状态表。状态转移表记录了从一个状态出发,通过输入能转移到其他状态,例如:
s1 | s2 | s3 | s4 | |
---|---|---|---|---|
s1 | a | |||
s2 | b | |||
s3 | c |
可接受状态表记录了所有的可接受状态, 例如:
status | type |
---|---|
s4 | abc |
这个状态转移表记录了一个可以匹配字符串 abc 的状态机,其中 s4 为可接受状态。
在拥有了状态转移表和可接受状态表后,就可以进行分词操作,其步骤为:
r
初始化状态机
循环,直到所有字符被读取完成:
读取字符 c,不移动字符指针
若当前状态 s 是可接受状态,记录其匹配的 Token 为 t // 1
若 s 可以通过 c 转移,则:
移动指针指向下一个字符
否则:
是否有已经匹配上的匹配的 Token,如果有:
输出 Token
初始化状态机状态
否则:
匹配异常
注意判断条件 1 的位置,此时状态机已经匹配到一个可接受状态,但不能立马输出 Token,因为需要满足上面提到的匹配最长的原则,直到遇到某个无法进行转移的字符,再尝试输出上次已经匹配到的 Token。
优化
对于一门编程语言来说,标识符的定义可能为[a-zA-Z_][a-zA-Z_0-9]*
。一个朴素的思想是通过或运算
来完成其中的 [a-z]
类型的表达式:
但是,对于字符串常量来说,其正则表达式定义为"[^"]*"
,其中[^"]
表示匹配除了 "
外的所有字符。这种情况下虽然也可以通过或运算来实现,但是作为一门现代的编程语言支持 unicode 字面量是非常的基本操作。如果要通过或运算来实现,那么我们大概需要或运算来连接 149,812 个基本正则表达式(149,812个正则表达式字符减去一个 ")。这样转换得到的 NFA 会非常巨大,明显不是一个可实现的方案。为了解决这个问题,可以将 NFA 状态接收一个[字符]输入转移到另一个状态
修改为 NFA 状态接收一个[范围内的字符]输入转移到另一个状态
,例如上面的 [a-z]
可以直接简化为:
当然,单个字符同样可以表示为:
同样,刚刚提到的[^"]
同样可以通过范围来表示,这样仅仅两个状态就可以表示整个 unicode 范围,非常高效。
这种改变对于从正则表达式转换到 NFA 来说没有任何影响。但从 NFA 转换到 DFA 需要一定的改变。例如,假设一个 DFA 状态可以对应两个 NFA 状态,这两个 NFA 状态分别支持的转移范围为 [a, f]
以及 [d, g]
。当 NFA 仅支持字符时,只需要遍历这两个 NFA 状态所有支持的字符即可。但此时是这两个 NFA 所支持的是两个范围的转换,应该如何处理?其解决方案也非常简单,首先将某个 DFA 包含的 NFA 状态所支持的转移范围划分为不相交的子集。然后遍历这些不相交的子集即可,例如:
可以分割为:
这样,只需要将互不相交的子集当成一个单独的字符进行处理即可。
使用这种表达式得到的状态转移表无法在 O(1) 时间复杂度内查表来直接判定某个状态是否能通过某个字符进行转移,因为状态对应的可接受字符都是一个范围。这一定程度上增加了分词的时间复杂度,但仍然有优化空间,将一个状态可进行转移的范围按起点的大小进行排序,然后通过二分查找的方式能够将时间复杂度缩短到 O(logn)。