概述
词法分析读取源代码中的字符,识别其中的子串为不同类型的词素(lexeme),生成其对应的 token 传递给语法解析器。
通常来讲,除了生成 token,词法分析器还会清除空白、注释,发现非法字符,生成符号表记录标识符,记录行号以便生成报错信息等。
Lexeme, Token & Token class
一个 token 通常包括两个部分:该 token 所属的 token 类(token class)以及该 token 代表的子串,也即词素(lexeme)。例如<Identifier, a>
,<keyword, if>
等。
一个 token 类表示了一个字符串集合,这些字符串通常具有相近的含义。例如标识符,某个关键字,字面常量等。词素 则表示了能被识别为某个 token 类的字符串。
为了识别与分类词素,需要一些方法来定义 token 类的识别模式(pattern)。常用的方法是使用正则语言(Regular Language)。
正则语言与正则表达式
数学上定义的“语言”(language)由两个集合所组成:字符集(alphabet)与定义在该字符集上的字符串(string)集。
其中字符集为一个包含若干符号的有限集,一般用希腊字母$\Sigma$表示,常见的例如 ASCII 字符集和 Unicode 字符集;字符串为一个由字符集$\Sigma$中的符号组成的有限序列;而语言中的字符串集可以是任意的由若干定义在$\Sigma$上的字符串组成的可数集。
从该定义中可以看出,每个 token 类都可以视作一个语言。
对于定义在相同字符集的两个语言$L$, $M$,有如下运算:
- 并(union):$L \cup M = \{s \mid s \in L \lor s \in M\}$
- 连接(concatenation):$LM = \{st \mid s \in L \land t \in M\}$
- 克林闭包(Kleene closure):$L* = \bigcup_{i=0}^{\infty} L^i$
其中$L^i$意为语言$L$自身连接$i$次,$i=0$时即为仅包含空串的语言,记为$\{\epsilon\}$;对应地,空字符串记为$\epsilon$。
定义在$\Sigma$上的正则语言可如下定义:
- 仅包含空串$\{\epsilon\}$和仅包含单个字符$\{c\}(c\in \Sigma)$的语言为正则语言;
- 若$L$为正则语言,则其克林闭包$L*$为正则语言;
- 若$L, M$均为正则语言,则其并$L \cup M$与连接$LM$为正则语言。
可以通过定义如下的记号代表一个正则语言,即正则表达式(REGular EXpression, abbr. regex):
- 空串$\epsilon$代表的语言$L(\epsilon) = \{\epsilon\}$;
- 字符$\mathbf{a}(a\in \Sigma)$代表的语言$L(\mathbf{a}) = \{a\}$;
- $(r)|(s)$代表并$L(r) \cup L(s)$;
- $(r)(s)$代表连接$L(r)L(s)$;
- $(r)\ast$代表克林闭包$(L(r))\ast$;
- $(r)$代表语言$L(r)$,该规则表示在表达式左右添加括号不改变其含义。
同时为了消除括号,通常各运算有如下优先级与结合性:
- 一元运算$\ast$优先级最高,左结合;
- 连接运算优先级次高,左结合;
- 并运算优先级最低,左结合。
通常来讲编程语言中的 token 类都是正则语言。例如,对于 C 语言中的标识符,可以用正则表达式表达为(不考虑保留字):
其中$\mathrm{letter}$表示英文字母,$\mathrm{digit}$表示数字。
token 识别
定义了正则语言与正则表达式后,token 的识别过程可以表述为如下的步骤:
- 写出各个 token 类的正则表达式$R_i$,同时记一个正则表达式$R$为所有$R_i$的并,即:
- 设输入为$x_1 \cdots x_n$,对所有$i \in [1, n]$,检查是否有:
- 若找到一个$i$满足上述条件则说明必有至少一个$j\in[1,m]$,将其记录为 token,对应$R_j$的 token 类;
- 将$x_1 \cdots x_i$移除输入,返回步骤2继续寻找,直至输入为空。
对于冲突,有如下策略:
- 对于不同长度的满足步骤 2 中条件的前缀,选择最长的前缀,例如将
==
视为一个 token 而不是两个=
; - 对于不同的,满足步骤 3 中条件的$R_j$,选择最小的$j$,也即将排列顺序视为优先级,例如将某些词素视为关键字而非标识符;
- 设置错误类以避免失配的情况,如非法字符,非法标识符等。
识别算法的实现通常使用有限状态机(Finite Automaton)。
有限状态机
有限状态机包含了以下部分:
- 一个输入字符集$\Sigma$;
- 一个状态有限集$S$;
- 一个初始状态$s_0 \in S$;
- 一个接受状态集$F \subseteq S$;
- 一个转移集合$S \to^a S$,其中$a \in \Sigma \cup \{\epsilon\}$
有限状态机可以表示为一个有向图,其中的结点即为状态,边即为转移,边上有标号$a$。
有限状态机可分为非确定性有限状态机(Nondeterministic ~, abbr. NFA)与确定性有限状态机(Deterministic ~, abbr. DFA)。其中 DFA 除了上述部分外,还有以下约束:
- 对于每个状态$s \in S$与每个字符$a \in \Sigma$,至多有一个转移;
- 空串$\epsilon$不允许出现在标号上。
每个有限状态机都对应一个正则语言或空语言(即字符串集合为空集),使用有限状态机可以识别某个字符串是否属于对应的正则语言,方法如下:
- 从初始状态开始进行转移;
- 除了空串外,若要进行标号为$a$的转移,从字符串中“消耗”一个字符$a$,并转移到另一个状态,标号为$\epsilon$的转移不“消耗”字符直接转移;
- 若字符串“消耗”完毕后可到达接受状态$f \in F$,则称状态机接受该字符串,该字符串属于对应的正则语言;
- 若字符串“消耗”完毕后无法到达接受状态或无法进行任何转移,则称状态机拒绝该字符串,该字符串不属于对应的正则语言。
通过将某些状态的集合看作一种状态,可以将 NFA 转化为 DFA 。至于如何通过正则表达式生成有限状态机以及如何将 NFA 转化为 DFA 前面的博文已经提过了,这里不再赘述。
Lab
作业二中需要使用 flex 生成 COOL 语言的 Lexer。首先需要定义各个 token 类的正则表达式:
1 | CLASS (class) |
按照作业要求各关键字是大小写不敏感的,新版的 flex 可以使用 (?:) 组来进行大小写不敏感匹配,但我这里用的是老版本的所以没有使用。
然后是生成 token。先写出关键字的 token 生成。
1 | {CLASS} { return (CLASS); } |
然后是单字符 token:
1 | {SGN} { |
需要注意的是这几项需要写在最后,.
是用来兜底代表非法字符的;并且换行符需要单独处理,因为需要保存行号。
接下来是字面量和标识符:
1 | {TRUE} { |
这几项需要写在关键字后面,因为关键字不应该被识别为标识符。
再然后是注释:
1 | {ILCMT} {} |
行内注释比较简单,关键是多行注释,由于多行注释内的语法与注释外不一样,所以使用到了状态 State。
状态需要先定义,在定义段中使用%x COMMENT
可以定义COMMENT
状态,使用BEGIN(COMMENT)
可以切换到该状态,使用BEGIN(INITIAL)
切换回初始状态,在正则表达式前加上<COMMENT>
可以指定在某状态内的匹配操作。
最后是字符串,和注释一样使用到了状态:
1 | \" { |
和注释一样需要在定义段中加上%x STRING
定义状态。同时这里使用了一个自定义的函数push_char_safe
:
1 | char *string_buf_ptr; |
该函数需定义在定义段的代码块中。