以下是「第2章 词法分析器」的读书笔记。
关键词:有限自动机、正则表达式。
识别器
这一节用「状态转移图」来描述识别器。
识别单词
假设要识别 new 关键字,那么识别器中可能存在这样的代码:
c <- NextChar()
if c = 'n'
c <- NextChar()
if c = 'e'
c <- NextChar()
if c = 'w'
return 'success'
else
throw 'error'
end
else
throw 'error'
end
else
throw 'error'
end
我们可以用 转移图 来表示这一逻辑:
图中的 S0、S1、S2 和 S3 表示计算过程中的一个抽象状态,S0 一般用于表示起始状态,有两层圆圈的 S3 表示接受状态。
那么识别 while 的转移图就是这样的:
识别 not 的转移图是这样:
因此,识别 new not 和 while 的转移图就是这样的:
识别器的形式化
我们可以把识别 new not 和 while 的有限自动机形式化为:
其中
- 表示所有状态,其中 是错误状态,转移图一般不画 。
- 是识别器使用的字母表
- 是识别器的所有转移函数
- 是初始状态
- 是接受状态的集合
任何形式上满足这 5 个条件(、、、、)的数学对象都叫做「有限状态机」,英文缩写为 FA。
识别数字
这个图有两个问题:
- 它无法终结,这违反了 是有限集的定义。
- 从 开始,所有的状态都是等价的,因为他们期待同样的数字切均为接受状态。
因此我们可以允许 FA 有环,这可以大大简化 FA:
识别标识符
不唯一
一个识别器对应的状态转移图很可能不唯一。
正则表达式
识别器除了用「状态转移图」来描述,还可以用「正则表达式」来描述。
但什么是正则表达式呢?
形式化定义
一个正则表达式由三个基本操作构建而成:
-
选择,语法为 ,定义为
-
连接,语法为 ,定义为
-
闭包,语法为 ,定义为
- 正闭包,语法为 ,其本质是
举例,用上面定义的正则表达式来表示一个识别标识符的识别器是这样的:
[a-zA-Z]([a-zA-Z0-9])*
可以用正则表达式定义的语言被称为「正则语言」。
从正则表达式到确定性有限状态机
这一小节一开始就给出了一个结论:从有限状态机可以自动推导出正则表达式,反之亦然。其中的构造法如下:
为了理解这些构造法,我们需要将 FA 区分为 DFA(确定性有限状态机)和 NFA(非确定性有限状态机)。
- NFA 允许在 (空字符串)上进行转移。
- DFA 不允许。
我个人感觉这部分内容过于学术,没有必要理解。直接记住下面结论即可:
- RE => NFA:Thompson 构造法可以把正则表达式变成 NFA
- NFA => DFA:子集构造法可以把 NFA 变成 DFA
- DFA => min DFA:Hopcroft 算法或 Brzozowski 算法可以把 DFA 简化成最小 DFA(最小是指状态数最小)
- 推论:通过上面三个算法,我们可以把正则表达式变成最小 DFA
- DFA 变成识别器代码是很容易的事情(下节讲)
- 推论:只要用正则描述一门语言,就可以很容易的得到识别器的代码
- DFA => RE:Kleene 构造法可以把 DFA 变成正则表达式
- 推论:DFA 和正则表达式是等价的
从 DFA 到代码
将 DFA 转换为可执行的代码有三种策略:
- 表驱动
- 直接编码
- 手工编码
三种策略都是在模拟状态机的运转方式:不停地读取输入字符串中的下一个字符,并模拟状态转移。它们的不同之处在于对 DFA 转移结构的建模方式和模拟 DFA 操作的方式。
表驱动的词法分析器
假设某编程语言的标识符的正则表达式为 $[0-9]+,那么对应的 DFA 可能为:
我们需要把字符分类,列成一个表,我们将此表命名为「字符分类表」:
| $ | 0-9 | EOF | 其他 |
|---|---|---|---|
| prefix | number | other | other |
然后我们可以把所有的状态转移列成一个表,我们将此表命名为「转移表」:
| prefix | number | other | |
|---|---|---|---|
其中只有 是接受状态。
我们还可以将每种状态是否属于接受状态进行列表,此表名为「结果类型表」
| 无效 | 无效 | 标识符 | 无效 |
那么代码就很好写了:
function nextWord(){
let state = 's_0'
let lexeme = ''
let stack = []
stack.push('bad')
while (state !== 's_error') {
nextChar(char) // 此函数以后介绍
lexeme += char
if(['s_2'].include(state)){
stack = []
}
stack.push(state)
categoty = 字符分类表[char]
state = 转移表[state][category]
}
// 如果最终 state 不是接受状态,则回滚到之前的接受状态(如果有)
while (!['s_2'].include(state) && state !== 'bad'){
state = stack.pop()
truncate(lexeme)
rollback() // 此函数以后介绍
}
if(['s_2'].include(state)){
return 结果类型表[state]
} else {
return '无效'
}
}
表驱动法存在的一个明显问题是,它的回滚逻辑遇到比较长的失败匹配时,可能会非常低效 O(n^2),此时可以使用「最长适配」算法来解决这个问题。
直接编码的词法分析器
表驱动法有这样一段代码:
while (state !== 's_error') {
nextChar(char) // 此函数以后介绍
...
categoty = 字符分类表[char]
state = 转移表[state][category]
}
这里有两次查表操作,虽然查表是常数次操作,但直接编码法可以进一步降低操作数,其原理是使用 goto 和标签来代替 while 和查表。
依然以 $[0-9]+ 为例,代码如下:
s_init:
lexeme = ''
stack = []
stack.push('bad')
goto s_0
s_0:
nextChar(char)
lexeme += char
if(['s_2'].include(state)){
stack = []
}
stack.push(state)
if(char === '$'){
goto s_1
}else{
goto s_out
}
s_1:
nextChar(char)
lexeme += char
if(['s_2'].include(state)){
stack = []
}
stack.push(state)
if('0123456789'.include(char)){
goto s_2
}else{
goto s_out
}
s_2:
nextChar(char)
lexeme += char
if(['s_2'].include(state)){
stack = []
}
stack.push(state)
if('0123456789'.include(char)){
goto s_2
}else{
goto s_out
}
s_out:
white(!['s_2'].include(state) && state !== 'bad'){
state = stack.pop()
truncate lexeme
rollback()
}
if(['s_2'].include(state)){
return 结果类型表[state]
} else {
return '无效'
}
可以看到,while 变成了多个 goto,查表操作变成了不同的 label。
但这种方案有两个缺点:
- 违反了结构化编程原则,因为使用了 goto
- 不适合人类阅读
还在,这种方案的代码很多时候都是自动生成的,所以问题不大。
手工编码的词法分析器
前面两种方案中,每个字符需要被逐个分析,I/O开销还是过大,为什么不一次性读取多个字符呢?也就是引入 buffer。
buffer 越大,词法分析器读取输入字符串的次数就越少。当然 buffer 也不能无限大。