词法分析器的作用
词法分析器(scanner), 在第〇章我们已经说过它的作用: 识别所有标识符并对其标记属性信息. 这篇内容讲解这个过程是如何实现的.
首先, 先引入三个概念: 串, 单词与模式. 串就是我们通常说的字符串, 是一个一维的逻辑结构, 按照某个特定顺序存储了一些符号; 单词就是符合某一模式的串; 那么什么是模式呢? 简单的说就是字符的形成规则. 比如在 C 中, 我们知道变量的名字由字母, 数字, 以及下划线组成, 且数字不可以出现在首位. 除此之外, 变量名不可以与保留字相同(比如不可以"int for = 3;"). 这就是一条规则. 根据这个模式(规则)我们可以识别出给定字符串的若干字串, 这些字串便是满足这一模式的单词.
有了上面的概念, 我们便可以进一步描述词法分析器的任务: 根据约定的模式, 识别出串中的单词, 并标记其属性信息(比如识别出一个数字常量, 它的属性可能是它的存储类型(可能涉及类型转换)以及数字的值, 识别出一个操作符; 它的属性可能是它代表的操作含义;).
举个例子, 我们有串"float aaa = bb 12.23". 我们有这样的几个规则(并非C标准), "float 是类型名, 指的是浮点数", "变量名由字母组成", " 是操作符, 指的是乘法", "数字常量由数字(+ "." + 数字)". 经过词法分析器, 我们应该可以得到这样的记号流: "".
通常在识别单词的时候, 可能会发现有些串有二义性. 也就是说同一个串可能符合多个模式. 比如, "while"是 C 的保留字, 但是我的变量名可以定义为"while_count_ge_0". 顺便说一句, 词法分析器在分析串的时候是一个字符一个字符读入的. 那么它就可能在读取到"while"后马上标记它为"保留字,循环语句标志"; 亦或是在识别串中的">="时, 可能在识别">"后马上判定这个是"操作符, 大于"而不是"大于或等于". 这个时候就需要超前搜索, 即满足某一模式后, 仍继续读入字符, 直到不满足任何模式才做标记, 而不是在第一次满足模式后就马上标记.
读到这, 你可能觉得词法分析器很强大. 不知你有没有"debug 弹出的 warning 和 error 都是词法分析器给出的" 这样的错觉. 事实上词法分析器只能区分很少的错误. 比如在识别某一模式时, 不能准确地落在某一标记上. 比如在 C 中写了这样的东西 "a = 12.ab3". 在词法分析器读取小数点"."后, 它期待下一个字符要么是数字, 要么是空格分号之类的断句符号. 但接下来读入的字母令它始料未及, 它没法继续做标记, 这个时候就会抛出错误. 词法分析器也只能给出这样的错误信息. 至于函数未声明, 变量未定义等其他语法文法的错误, 词法分析器是不会明白的.
词法分析器的实现
逻辑上,我们要实现的词法分析器实际上是对我们对于语言模式要求的实现. 这句话看着有点绕, 简单的解释就是, 我们想要根据某些模式对串中的单词做出区别与标记, 词法分析器就是要帮我们完成这项工作. 为了形式化地描述模式, 我们需要了解正规式(正则表达式).
正规式
坊间有个段子: "没有人能学会正则表达式". 我们在这里介绍的也只是正规式最基本的语法成分, 不过用来实现词法分析器是足够的.
首先我们有一张字母表, 所谓字母表, 就是我们需要匹配的符号的集合, 用 Σ 表示. 下面介绍一些必要的正规式知识.
ε 表示空串, 匹配的结果什么都没有的空串.
a (假设 a ∈ Σ, a 在字母表中) 表示匹配 a 这个字母
a? 表示匹配 a 或不匹配 a
a* 表示匹配 a 0次或若干次, 等价于{ε, a, aa, aaa, aaaa, ...}
a+ 表示匹配 a 1次或多次, 和 a* 唯一的区别在于它不接受空串 ε
a|b 表示这一位匹配的字符是 a 或 b
ab 表示按顺序匹配两个字符 a 和 b, 有时候也不省略中间的点乘符号 a·b
有了这些知识, 我们就可以用正规式构造逻辑上的词法分析器了. 比如, 我们要描述 C 语言中对数字的表示模式.
这个表达式定义了 C 中对于十进制数字常量的模式要求(假定不会有空串). digit 是数字符号集合, dot 是小数点. e 就是字母 e. 先看表达式前半部分的含义: (有或没有(正号或者负号))(0个或多个数字)(有或没有(小数点及(0个或多个数字))). 用人话来说, 就是可以匹配多位数字, 也可以匹配带小数点的浮点数. 可以没符号, 也可以有正负号. 由于 C 中允许将 "0.123" 写成 ".123", 所以前面的 digit 用的是 "*" 而不是 "+". 同理, C 中同样允许 "123." 表示 "123", 故小数点后面也可以没有数字.
后半部分就是科学计数法的匹配, 要匹配一个字母 e, 剩下的和前面一样. 同样是因为数字常量可以不使用科学计数法, 因此这部分也可有可无.
如果要匹配变量名:
如果我们不考虑保留字的情况, 这就是变量名的模式. underscore 是下划线, letter 是英文字母集合, unl 就是带有下划线的字母集. digit 同样表示数字集. 这样的话, 变量名的模式就是, 首字母一定不能是数字(至少匹配一个 unl 中的字母), 后面的字母既可以来自 unl, 又可以来自 digit. 且数目可以为 0, 1, 也可以很多(不考虑变量名长度的限制).
有限状态自动机
前文说过, 词法分析器每次仅处理串中的一个字母. 这不禁让我们联想到状态自动机模型: 由初态出发, 每次根据读入的一个字符判断下一个状态位置, 以此类推, 直到停机, 落在的那个状态表示什么就意味着这个串序列表示的是什么. 如果无法正常停在某个终态, 则发生错误. (比如前面的 "12.ab3", 读到字母 a, 自动机会不知道下一个状态是什么, 造成非正常停机.)
不确定状态自动机 NFA
为了进一步用更具体的手段实现词法分析器, 我们需要将正规式表示成自动机. 所幸正规式可以机械化地翻译成不确定状态自动机(NFA). 具体实现方法叫做 "Thopmson 方法". 翻译的规则如下:
我们用单圈表示状态, 双圈表示终态. 初始状态只能有一个, 一般标记为 S, 而终态可能有很多.
对于第一个, 空串的模式表示为"什么都没有就可以从初态到终态", 因为初态即终态. 第二个表示识别一个字母 a, 在初态接受这个字母就可以到达终态, 完成识别.
a|b 就意味着无论是 a 还是 b 都能到达终态; a* 就是可以很多 a 或者没有. 同理 a+ 指的是至少有一个 a. (没有写 a?, 因为 a? 等价于 a|ε)
举个例子, 比如对于正规式 0(0|1)1+, 意思是, 首位必须是 0, 第二位必须是 0 或 1, 后面至少有一个 1 的单词. 画成 NFA 是这样:
不知道你对 NFA 的名字是否感觉很困惑, 为什么叫"不确定状态"呢? 下面举这样的例子.
正规式 0+(0|1)1 的 NFA :
通过观察这个 NFA, 你会发现一个非常诡异的现象: 假设你在状态 1, 接下来读进一个数字符号'0', 你是继续停留在状态 1, 还是转移到状态 2 ? 如果再下一个符号是 1, 你可能知道需要转移到状态 2. 但别忘了, 我们的词法分析器每次仅读进一个字符, 如果这个例子中的(0+)匹配特别长的0串, 我们是不是需要使用缓冲区, 预先读取特别多的字符, 仅仅用来判断第二步跳转! 这显然是非常不现实的. 还有就是NFA 中可能涉及空串匹配, 也就是说一个状态可能无缘无故就跳到其他状态. 当然你可以做出约定, 要求大家不要写这么奇怪的正规式. 但我们有更好的解决方案: 确定状态自动机(DFA).
(注: 真正的 NFA 比这个复杂, 因为他要自行添加很多空串匹配, 为了不影响阅读, 这里的 NFA 做了适当的简化.)
确定状态自动机 DFA
知道了 NFA 的缺陷, 我们希望我们设计的 DFA, 在每读取一个字符后就可以找到自己的下一个状态. 因此, 我们需要将 NFA 翻译成 DFA. 这里用的方法叫做 "子集构造法".
简单来讲, 我们先将初态以及它用空串匹配就可以到达的一切状态, 作为初始状态集. 将这个状态集看做一个独立的状态, 它根据自己集合中元素的所有状态的匹配规则找到规则和目标状态, 得到的目标状态再根据"可由空串匹配到达的一切状态", 找到这样的状态集合. 以此类推, 做出新的状态迁移图. 新的状态图中, 若某状态表示的老状态集合中包含终态, 那么这个状态也是终态(显然可以有很多).
上面的文字可能不是很好理解, 我们举个例子.
现在我们有这样的 NFA :
我们可以看出, 这张图表示的正规式是 ((a(a|b)b)?a)+
先构造初态集合, 从初态 S 我们可以用空串匹配走到状态 1, 4, 所以初态集合是{S, 1, 4}.由初态集合的每个顶的转移规则, 我们先看初态集合根据匹配 a, 可以到达状态 {1, 5}, 再用空串匹配, 找到初态集合匹配 a 后到达的状态集合 {1, 5, F}. 以此类推, {S, 1, 4} 根据匹配 b 可以得到空集. 于是我们再从 {1, 5, F} 出发, 使用 a 匹配得到 {2}, 使用 b 匹配得到空集. 再从 {2} 出发, 通过 a 或 b 得到 {3}; 再从 {3} 出发通过 b 得到 {4}; 再从 {4} 通过 a 得到 {1, 5, F}.
画成 DFA :
当然, 你可以将状态集合改名, 改成新的状态, 而不是一个个集合.
到这里, 我们由正规式拿到了确定状态自动机 DFA. DFA 用编程语言实现起来就简单多了.
(后续工作还有 DFA 的简化, 将 DFA 用类似于子集构造法查找可以合并的状态, 减少复杂度)
2017.9.13Osinovsky