Rust 实现一个表达式 Parser(4) DFA 实现

2,161 阅读12分钟

本文将具体实现一个 DFA 进行词法分析, 在文章中将尽量避开理论, 尽量谈实践

源码 src/lexer/dfa.rs

DFA 介绍

全称 DFA(Deterministic Finite Automaton) 确定有限状态自动机, 与之相对的是 NFA(Non-Deterministic Finite Automaton) 非确定有限状态自动机, 关于 DFA 和 NFA 的区别放到文末展开, 此处只介绍 DFA

DFA 是一种能够根据不同的输入转换到不同状态的特殊的数学模型

以下假设笔者自己就是一个 DFA, 如下

dfa_me_dfa.jpg

从图可以看出, DFA 看起来很像是一个带权有向图

  • 每个顶点都表示我的一种状态, 如 "我起床", "我学习", "我吃饭" 等
  • 每条弧上的权重表示我发现了什么, 如 "我发现到饭点了", "我发现朋友叫我出去玩", "我发现没玩够" 等
  • 每条弧的方向指示我可以从某种状态达到某种状态, 如 "我可以从起床状态到吃饭状态", "我可以从吃饭状态到玩状态" 等

以下将上述解释套用到 DFA 中

  • 每个顶点表示一种状态
  • 每条弧表示可以从某个状态迁移到另一状态, 具体的, 下一状态称为当前状态的后继状态
  • 每条弧上的权重表示 DFA 接收到的某种输入
  • 同心圆指示的是终止状态, 如上图中是 "睡觉"
  • 未连通的箭头指出的是起始状态, 如上图中是 "起床"

那么此时假设我这个 DFA 接收到以下输入序列

["到饭点了", "朋友叫我出去玩", "没玩够", "没玩够", "没玩够", "玩累了"]

易得我接受完所有输入序列最终会停留在 "睡觉" 这一状态, 这一状态也是终止状态, 这意味着这一天结束了

回到起点, 假设当前有如下输入

["到饭点了", "朋友叫我出去玩", "没玩够" ]

易得我接受完所有输入序列最终会停留在 "玩" 这一状态, 这一状态并不是终止状态, 这意味着这一天还没有结束

将上面的描述套用到 DFA 中, 则是若接受完一个输入序列, DFA 停留在终止态, 则称该 DFA 接受该输入, 否则则称不接受

那么说了上面这么多, "确定" 和 "有限" 体现在哪里呢

  • 有限: DFA 的状态有限, 可以穷举
  • 确定: 对每一个不同的输入, DFA 总有唯一对应的后继状态, 具体来说就是我不会在接收到 "到饭点了" 这个输入后, 又可以选择吃饭, 又可以选择玩, 我一定会吃饭

DFA 模型

以下直接给出 tiny-expr-parser 的词法分析阶段使用的 DFA 的模型

dfa.jpg

简单说明一下

  • 该 DFA 不接受前缀 0, 因此为了简单起见, 将 0 单独作为一种输入接受
  • 上图省略了失败状态, 在具体实现时, 我们需要得知当前输入是否合法, 因此会添加一个失败状态, 具体来说也就是停留在 zero 状态时, 若是再接受 01-9 就会进入失败状态, 因为输入不合法, 终止解析进程
  • DFA 完整运转一次能够得到一个合法输入, 比如一串数字, 一个操作符, 而我们希望 DFA 在匹配出一个合法输入后继续进行匹配, 直到解析完所有的输入序列, 因此需要在实现的时候做点小手脚

DFA 如何实现

DFA 看起来像一个特殊的有向带权图, 而我们的需求其实非常简单, 即根据当前输入以及当前状态快速查找后继状态, 再判断此状态是否是终止态

众所周知, 有向带权图有两种常见的存储结构, 分别是 邻接矩阵十字链表, 这里我们应该选择何种存储结构呢, 以下针对我们的需求来分析一下两种存储结构的特点

邻接矩阵

  • 可以在 O(1) 时间复杂度内求得两顶点之间是否连通以及对应权重, 但需要根据权重判断能够到达的下一顶点需要遍历顶点对应的那一行数据, O(N) 时间复杂度
  • 整体空间复杂度是 O(顶点数量 * 顶点数量), 因此若是顶点数量大于弧的数量, 会存在大量空间浪费
  • 初始化可以直接硬编码

十字链表

  • 通常能够在 O(1) 的时间复杂度内求得某顶点的出度和入度, 但是根据权重判断能够到达的下一顶点需要遍历当前顶点的所有出边组成的链表, O(N) 时间复杂度
  • 虽然存在很多指针域, 但不会存在冗余数据, 空间复杂度 O(顶点数量 + 弧的数量)
  • 初始化需要动态创建每个顶点的邻接表

可以看到, 貌似没有特别合适的存储结构, 并且最主要的根据权重查找可达的下一顶点的需求都需要 O(N) 的时间复杂度来完成, 可是实际上 DFA 中有个很重要的条件没有使用上, 输入可穷举, 这意味着这个图的权重值是可以穷举的, 那么我们就可以基于邻接矩阵改变一下存储思路

opws01-9
START1023
OPERATOR
ZERO
NUM33

以下解释一下

  • 使用一个矩阵来存储所需, 记为 table
  • 横坐标是所有的输入 记为 input
  • 纵坐标是所有的状态, 记为 state
  • table[state][input] 存储的是当前 state 接收到 input 时的后继状态
    • table[0][1] == 1 表示 START 状态接收到一个 op 输入, 后继状态为 1 号状态, 也就是索引为 1 的状态 OPERATOR, 说再简单一点就是处在 START 状态接收到一个 op 输入, 就迁移到 OPERATOR 状态

这样存储有什么好处呢

  • 根据输入查找后继状态只需要 O(1) 时间复杂度
  • 整体空间复杂度为 O(状态数量 * 条件数量), 并且不可达的情况是应该判断出来并报错的, 也就是说上表例子中的空位都是有用的, 这意味着几乎没有冗余数据
  • 完全可以硬编码初始化

因此使用上述存储结构就可以完美满足我们的需求, 不过在具体使用的时候还需要再动点小手脚

动点小手脚

由前面的分析可知, 我们的状态共有 4 种

  • START: 起始状态
  • OPERATOR: 操作符, 终止状态
  • ZERO: 0, 终止状态
  • NUM: 数字, 终止状态

输入也有 4 种

  • op: 任意操作符, "+", "-", "*", "/", "(", ")"
  • ws: 空格
  • 0: 数字 0
  • 1-9: 数字 1~9

我们可以快速的根据上面的图建出如下状态表

opws01-9
START1023
OPERATOR
ZERO
NUM33

而我们希望能获知出错状态, 并且为了方便统一处理, 便将出错也单独视为一种状态, 建表如下

opws01-9
ERROR0000
START2134
OPERATOR
ZERO
NUM44

当出错时应该马上停止解析工作并报错, 因此其后继状态我们不需要关心, 这里就随便填个 0 了

此外还有一个问题, 标准的 DFA 一次匹配出一个合法或非法的输入序列, 而我们希望这个 DFA 能一直匹配出多个合法序列, 并在出错时直接停止, 因此再进行如下更改

opws01-9
ERROR0000
START2134
OPERATOR2134
ZERO21
NUM2144

每个终止状态 接收到 非当前状态可接受的, 但可以转移到另一终止状态, 且 合法 的输入, 就直接让其转移

比如 OPERATOR 状态下, 若是接收到数字 0, 就直接让其转移到 ZERO 状态, 这样我们就可以直接对比转移前后状态是否相等来判断是否解析出了一个完整的 Token

这样做只是为了简化实现逻辑, 并非必须

最后将空位填 0 表示出错, 具体来说就是若当前匹配到一个单独的 0, 后续匹配到任何数字都认为是非法输入

这样就得到了我们最终的状态转换表

opws01-9
ERROR0000
START2134
OPERATOR2134
ZERO2100
NUM2144

DFA 实现

上面大致理清了实现思路, 下面将正式实现 DFA, 再次放上模型和状态转换表

  • 模型 dfa.jpg
  • 状态转换表
    opws01-9
    ERROR0000
    START2134
    OPERATOR2134
    ZERO2100
    NUM2144

状态实现

至此已经非常简单了, 直接硬编码出所需状态, 此外再专门封装一个函数来判断当前状态是否是终止态

这里使用了闭包来避免引入过多的全局变量

pub const ERROR: usize = 0;
pub const START: usize = 1;
pub const OPERATOR: usize = 2;
pub const ZERO: usize = 3;
pub const NUM: usize = 4;

pub fn get_terminator_judgement() -> impl Fn(usize) -> bool {
    const END_STATE: [usize; 3] = [OPERATOR, ZERO, NUM];
    |state: usize| END_STATE.contains(&state)
}

使用方式如下

#[test]
fn zero_is_terminator() {
    let is_terminator = get_terminator_judgement();
    assert!(is_terminator(ZERO));
}

状态转换函数实现

状态转换表的实现只需要直接硬编码就可以了, 而条件的判断还需要单独封装几个简单的函数, 实现如下

pub fn get_transition() -> impl Fn(char, usize) -> usize {
    /// 状态转换表
    ///
    /// |              | op  | ws  | 0   | 1-9 |
    /// |--------------|-----|-----|-----|-----|
    /// | ERROR        | E   | E   | E   | E   |
    /// | START        | 2   | 1   | 3   | 4   |
    /// | OPERATOR     | 2   | 1   | 3   | 4   |
    /// | ZERO         | 2   | 1   | E   | E   |
    /// | NUM          | 2   | 1   | 4   | 4   |
    ///
    const STATE_TABLE: [(usize, usize, usize, usize); 5] = [
        (0, 0, 0, 0), // ERROR
        (2, 1, 3, 4), // START
        (2, 1, 3, 4), // OPERATOR
        (2, 1, 0, 0), // ZERO
        (2, 1, 4, 4), // NUM
    ];
    let is_op = |c: char| match c {
        '-' | '+' | '*' | '/' | '(' | ')' => true,
        _ => false,
    };
    let is_whitespace = |c: char| match c {
        ' ' => true,
        _ => false,
    };
    let is_zero = |c: char| match c {
        '0' => true,
        _ => false,
    };
    let is_one_to_nine = |c: char| match c {
        '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' => true,
        _ => false,
    };
    // 返回一个闭包避免引入过多的全局变量
    move |c: char, state: usize| {
        if is_op(c) {
            // 这里是公式
            //    后继状态 = table[当前状态][满足条件的输入]
            STATE_TABLE[state].0
        } else if is_whitespace(c) {
            STATE_TABLE[state].1
        } else if is_zero(c) {
            STATE_TABLE[state].2
        } else if is_one_to_nine(c) {
            STATE_TABLE[state].3
        } else {
            ERROR
        }
    }
}

需要注意的是这里必须使用 move 关键字来指定 移动所有权 的语义, 因为闭包的生命周期会比这个函数更长, 不获取所有权的话会导致这里面的临时变量 drop

该函数返回的闭包函数接受两个参数, 分别是当前输入的字符以及当前的状态, 接着直接使用封装好的函数来进行状态的改变即可, 该函数的使用方式如下

#[test]
fn start_transfer_to_operator_while_received_an_operator() {
    let transition = get_transition();
    assert_eq!(OPERATOR, transition('+', START));
}

至此 DFA 的核心部分已经完成, 剩余部分需要基于词法分析的逻辑, 因此放到下一篇

DFA & NFA

下面来简单介绍一下 DFA 和 NFA 的区别, 首先回忆前文提到的笔者自身作为 DFA, 如下图

dfa_me_dfa.jpg

这里直接给出 NFA 版本, 需要注意的是, 下面这个 NFA 和上面这个 DFA 并非等价

dfa_nfa.jpg

不考虑图中含义, NFA 版本和 DFA 版本有以下两点不同

  • 起床 状态时, 我不需要任何条件就可以直接达到 吃饭 状态
  • 吃饭 状态时, 我接收 "朋友叫我出去玩" 的输入可以达到 状态也可以达到 睡觉 状态

这就是 NFA 和 DFA 的主要不同之处

  • NFA 中可以存在空输入, 即不需要任何输入也可以迁移状态, 通常记为 ϵ\epsilon
  • NFA 中一个状态接收到某一输入后, 后继状态可以不唯一

以上两点其实可以合并成, NFA 中某一状态接收到某一输入的后继状态可以不唯一, 这也是 NFA 中 N(Nondeterministic) 非确定 的原因

目前有相关理论证明了 NFA 和 DFA 的等价性, 也存在现成的将 NFA 转化为 DFA 的方法, 实际上转化为 DFA 再实现是 NFA 通常的实现思路, 不过将 NFA 转化为 DFA 通常会导致状态数量的指数级别爆炸, 因此还需要进行化简等操作, 简单来说就是实现 NFA 有点麻烦

具体的转化方法和化简方式这里就不展开说了, 因为都是很机械性的工作, 百度一下看看就能懂

Q&A

Q: DFA 还有什么应用?

A: 挺多的, 目前个人感觉自动机是这种匹配问题的杀手级别解决方案, 应用方面一下子还说不上来很多, 但是感觉匹配相关的问题大部分都可以用自动机解决, 只是合不合适的问题, 以下简单列举一下

  • DFA/NFA 实现正则表达式(单/多模式匹配)
  • AC 自动机进行敏感词匹配(多模式匹配)

Q: 除了用 DFA 还有什么方法可以实现?

A: 上正则! 但是正则本质上也是自动机, 可能是 DFA 或者 NFA

Q: 可以用 NFA 实现么?

A: 实际上一开始我是希望能直接在这一步匹配出正负数并支持浮点数运算的, 那么很自然的就画出了一个 NFA, 然后经过转化和化简发现很麻烦很冗余, 我认为没有必要在这里增加理解的复杂度, 因为核心逻辑应该越精简越好, 因此就直接写出最简单的一个 DFA, 然后砍掉了对浮点数运算的支持, 但是实际上做起来并不难, 就是麻烦, 感兴趣的可以自己尝试一下