Rust 实现一个表达式 Parser(2) 整体设计

1,583 阅读5分钟

即将正式进入 tiny-expr-parser 的实现, 本文将大致介绍项目整体结构

最终目标

  • 解析疑似表达式的字符串, 构建 AST
  • 根据 AST 对表达式进行求值
  • 根据 AST 对表达式进行格式化

用例如下

#[test]
fn smoke() {
    let expr = "1*2+(3/(4+(-5)))";
    let ast = build_ast(expr).unwrap();

    assert_eq!(-1, eval(&ast));
    assert_eq!("1 * 2 + 3 / (4 + (-5))", format(&ast));
}

步骤

以下三个步骤分别对应源码中的三个模块

词法分析: "src/lexer" 语法分析: "src/parser" 遍历: "src/traversal"

词法分析

该阶段需要将传入的表达式字符串处理为 Token 流, 如

step_string_to_token.jpg

注意, 该过程会忽略空格, 当解析到 001 这样的非法输入时应直接报错

这里在项目中是实现了一个 DFA(Deterministic Finite Automaton)确定有限状态自动机, 具体说明及实现可以看下篇文章

语法分析

该阶段接收传入的 Token 流, 并构建 AST, 如

step_token_to_ast.jpg

该阶段应正确处理不同表达式的优先级, 并体现在 AST 的结构中

值得一提的是, 这里在实际实现时会使用 Parser Combinator 来描述文法, 以下简单介绍一下

Parser 技术

编译器前端的实现存在大量机械性的重复逻辑, 因此很理所当然的出现了一些相关的自动生成工具, 目前有以下两种

  • Parser Generator
    • 根据定义的文法生成一个可以直接运行的编译器前端代码文件
    • yacc,antlr
  • Parser Combinator
    • 使用不同的 Parser 组合子来直接描述文法
    • 通常作为一个库来实现
    • ts,rust, haskell

以下是使用 Parser Combinator 匹配一个数字节点的代码

pub fn literal() -> impl Parser<Node> {
    single_token(NUM).map(|(_, value)| Literal {
        kind: NUM,
        value: value.parse().unwrap(),
        raw: value,
    })
}

tiny-expr-parser 实现初衷是为了简单了解一下编译前端的具体细节, 因此就还是选择用 Parser Combinator 来实现, 更具体的原因见 PS

两者基本都可以完成需求, 不过通常情况下, Parser Generator 的性能会比 Parser Combinator 高, 但是貌似有一些技术能够优化 Parser Combinator, 让其效率提高到与 Parser Generator 几乎无异

遍历

关于树形结构的遍历思路大致上有两种, 广度优先(DFS)和深度优先(BFS), 这里该如何选择遍历思路呢

如上文提到的表达式 "1 * (2 + 3)" 的 AST

step_ast.jpg

我们期望的计算顺序是先执行 (2 + 3), 结果为 5, 再执行 1 * 5, 得出最终结果 5

我们可以优先访问左子节点, 再访问右子节点, 得出两个子节点的值之后再执行根节点指定的运算, 那么我们就可以借助 DFS 的回溯过程自然的将表达式的值返回到上一层, 若是使用 BFS 则会在实现上带来一定的麻烦

而 DFS 若是在寻常二叉树上进行的话会非常简单, 一个递归函数就可以实现, 不过对 AST 来说, 遍历是需要特殊处理一下的, 剧透一下看看该项目中的数字节点 Literal 和表达式节点 Expr 的类型定义

/// 枚举所有 AST 的节点类型
pub enum Node {
    Literal {
        kind: SyntaxKind, // 节点类型
        value: i32,       // 节点实际值
        raw: String,      // 节点原本在字符串中的样子
    },

    Expr {
        kind: SyntaxKind, // 节点类型
        left: Box<Node>,  // 表达式的左操作数
        op: SyntaxKind,   // 表达式的操作符
        right: Box<Node>, // 表达式的右操作数
    },
}

可以看到, 在 Literal 节点中, 我们希望访问的应该是其 value 字段, 而在 Expr 节点中, 我们希望访问的是 left, op, right 字段, 简单来说就是根据不同的节点类型有不同的访问逻辑, 因此不能用寻常的实现方式

针对这个需求, 需要使用访问者模式(Visitor Mode)来进行实现 AST 的 DFS, 将不同的访问逻辑封装在不同的 visit_xxx 方法中来实现, 具体会在对应文章中展开

PS

关于为什么使用 Parser Combinator 解释一下自己的思考

我尝试过实现 Parser Generator

实现起来非常繁琐, 并且从我写的测试 Demo 来看, 我生成的的前端整体性能和可读性都根本完全完全没法和 Parser Combinator 的版本比, 主要是因为并没有很好的进行优化, 还是研究的不够多, 但是主要还是太麻烦太复杂而且生成出来太丑

我觉得 Parser Combinator 很灵活

使用 Parser Combinator 是在通过组合不同的 Parser 来描述文法, 操作空间很大, 并且能很好的掌握其中的流程细节

我觉得 Parser Combinator 开发体验极好

Parser Combinator 是一种函数式风味很重的 Parser 实现思路, 由于个人对 FP 还是一知半解, 就不引入范畴论的相关术语, 只谈开发过程中实际体验到的, 每个 Parser 都是一个纯函数, 组合过后的 Parser 也还是一个 Parser, 接受一个输入, 返回一个输出, 这个过程没有任何的副作用, 这意味着接受的输入相同, 返回的值也一定相同, 这是纯函数带来的极大好处, 测试时也是极为方便

我觉得 FP 很有意思

依照个人的看法, FP 中的函数是对过程的抽象, 抽象的是不同的行为, 是动词, 而以 OOP 为例, 其他编程范式通常是对物体进行抽象, 是名词, 从某个角度来看, FP 就像把整个数据视为一条流水线, 而每个不同的函数都是流水线上的熟练工人, 每个工人都可以稳定的完成一定程度的简单任务, 最终流水线的尽头就是我所期待的产物, 在开发过程中, 不需要关心外部状态的问题, 只需要关注当前函数的输入和输出, 最终组装起来就能够完成整体需求, 这样的开发体验很奇妙