Rust 实现一个表达式 Parser(8) Parser 实现

3,141 阅读7分钟

最困难的部分已经结束了, 剩下的部分都非常简单

本文将实现 Parser 的核心逻辑

也就是使用上一篇文章封装好的 Parser Combinator 描述文法

源码: src/parser/grammar.rs

回忆一下

在这里给出我们上上篇文章定义好的文法

Expr   -> Add ;

Add    -> Mul (("+" | "-") Mul)* ;

Mul    -> Factor (("*" | "/") Factor)* ;

Factor -> "(" Expr ")"
        | Number
        ;

Number -> NUM ;

这里为了少写冗余代码, 我们再最后最后化简一步, 然后统一一下命名

Expr   -> Term (("+" | "-") Term)* ;

Term   -> Factor (("*" | "/") Factor)* ;

Factor -> "(" Expr ")"
        | Literal
        ;

Literal -> NUM ;

定义一下

在正式开始写之前, 应该先将 AST 上的节点类型定义一下, 这里就直接用 enum 穷举, 源码位置在 src/parser/node.rs , 实现如下

#[derive(Debug, Clone, PartialOrd, PartialEq)]
pub enum Node {
    Literal {
        kind: SyntaxKind,
        value: i32,
        raw: String,
    },

    Expr {
        kind: SyntaxKind,
        left: Box<Node>,
        op: SyntaxKind,
        right: Box<Node>,
    },
}

需要注意的是, Expr 表示表达式节点, leftright 可以是 Expr 或者 Literal, 是一个递归类型, 因此需要使用 Box 包裹一下将实际内容存放到堆上, 否则编译器无法计算需要分配多少内存, 编译无法通过

写一下

直接开始写, 现在只需要根据现有的 Parser Combinator 来描述文法就可以了

literal

Literal 文法如下

Literal -> NUM ;

代码如下, 使用 single_token 匹配出一个 NUM 类型的 Token, 再使用 map 将他映射为 Literal 节点类型即可

pub fn literal() -> impl Parser<'static, Node> {
    single_token(NUM).map(|(_, value)| Literal {
        kind: NUM,
        // 这里直接调 unwrap 即可, 因为前面的 Lexer 可以保证一定可以被转为整数
        // 不考虑溢出的情况的话...
        value: value.parse().unwrap(),
        raw: value,
    })
}

factor

下一个是 Factor, 文法如下

Factor -> "(" Expr ")"
        | Literal
        ;

对于 "(" Expr ")" 我们可以直接用一连串 and_then 来连续匹配, 如下

fn factor() -> impl Parser<'static, Node> {
    either(
        literal(),
        single_token(token!["("])
            // 这里假设已经事先定义出 expr 函数
            .and_then(|_| expr())
            .and_then(|node| {
                single_token(token![")"])
                    .map(move |_| node.to_owned())
            }),
    )
}

term

Term 文法如下

Term -> Factor (("*" | "/") Factor)* ;

这里我们直接使用 zero_or_more 来将 ("*" | "/") Factor) 匹配 0 次或更多次, 我们先不考虑节点的构建, 搭出框架如下

fn term() -> impl Parser<'static, Node> {
    factor().and_then(|left| {
        // 将传入 Parser 重复匹配 0 次或更多次
        zero_or_more(
            either(
                single_token(token!["*"]),
                single_token(token!["/"])
            ).and_then(|(op, _)| {
                // 这里将整个结果映射为一个二元组 (操作符, 操作数)
                factor().map(move |right| (op, right))
            }),
        )
        // 使用 map 将匹配到的内容映射为 Node 类型
        .map(move |node_list| {
            // TODO 如何构建 Expr 节点
        })
    })
}

这里简单的解释一下

  • zero_or_more 会将给定的 Parser 重复匹配 0 次或更多次, 返回值是一个装有匹配结果的 Vector
  • 在上面的实现中, zero_or_more 内部的 Parser 将输出映射为一个二元组, 包括操作符以及操作数, 具体类型为 (SyntaxKind, Node)
  • 整个 zero_or_more 的输出就是一个装有这个二元组的 Vector, 具体来说, 输出类型为 Vec<(SyntaxKind, Node)>

我们先暂时忽略这里的节点构建, 先完成最后一个 Expr 的文法描述

expr

这里其实是 Term 的镜像版本, 因此就不多做解释了, 文法如下

Expr -> Term (("+" | "-") Term)* ;

实现如下

pub fn expr() -> impl Parser<'static, Node> {
    term().and_then(|left| {
        zero_or_more(
            either(
                single_token(token!["+"]),
                single_token(token!["-"])
            ).and_then(|(op, _)| {
                term().map(move |right| (op, right))
            }),
        )
        .map(move |node_list| {
            // TODO 如何构建 Expr 节点
        })
    })
}

build_expr_node

经过上面的实现, 可以发现, 目前可以抽离一个用来构建表达式节点的公用函数, 因为 exprterm 都会需要, 并且他们的结构几乎一样, 下面就来实现

先来看看 Expr 节点的结构

pub enum Node {
    Expr {
        kind: SyntaxKind,
        left: Box<Node>,
        op: SyntaxKind,
        right: Box<Node>,
    },
}

Exprleftright 都是一个递归类型, 那么我们应该怎么来构建呢, 具体来说就是该怎么来构建这棵 AST

看这个例子 "1 + 2 + 3"

如果我们希望按照正常计算顺序来计算的话, 结合之前提到的 AST 的遍历顺序, 这棵树应该长下面这样

parser_ast1.jpg

那么 "1 + 2 + 3 + 4" 呢, 长这样

parser_ast2.jpg

我们会发现, 这棵树总是应该往左下角生长

其实这里是存在问题的, 因为 AST 往左下角生长意味着左递归, 而我们使用的解析算法无法处理左递归的文法, 因此在前面的文章中就对左递归问题进行过消除, 实际上是改写成了右递归

这里就产生了一个冲突, 我们希望通过非左递归的文法, 解析出一棵左递归的树, 因此这里不可能从文法层面去实现这个需求

只能把 向左递归生长 的逻辑实现在这个函数里面, 那么每个 Exprleft, right 字段, 对应关系又如何呢, 以下简单展开一下

举例如 "1 + 2 + 3", 他会匹配 Expr 的文法

Expr -> Term (("+" | "-") Term)* ;

Expr 进行简单的整理并推导展开

parser_grammar1.jpg

我们希望优先计算 1 + 2, 这意味着 "1 + 2" 应该单独形成一个 Expr 并作为子节点, 则有

parser_grammar2.jpg

而带上右边的 "+ 3" 时, 1 + 2 形成的 Expr 就应该作为这个表达式的左子节点, 如下

parser_grammar3.jpg

这样我们就得到了我们期望中的往左递归生长的树

下面再将无关的标识去掉, 只关注各个部分与 left, right 字段的对应关系, 如下

parser_grammar4.jpg

经过分析, 具体实现还是比较简单的, 结合上文提到的 node_list 的类型, 实现如下

fn build_expr_node(expr: Node, mut node_list: Vec<(SyntaxKind, Node)>) -> Node {
    match node_list.len() {
        // 若当前 node_list 为空, 回溯
        0 => expr,
        // 当前 node_list 不为空, 递归构建
        _ => {
            // 这里已经对 node_list 长度进行过判断, 可以放心 unwrap
            let (op, right) = node_list.pop().unwrap();
            // 构建表达式节点
            Expr {
                kind: match op {
                    token!["+"] => ADD_EXPR,
                    token!["-"] => SUB_EXPR,
                    token!["*"] => MUL_EXPR,
                    token!["/"] => DIV_EXPR,
                    
                    // 这里应该直接 panic
                    _ => UNKNOW,
                },
                // 向左递归构建子树
                left: Box::new(build_expr_node(expr, node_list)),
                op,
                right: Box::new(right),
            }
        }
    }
}

如上就可以完成节点的递归构建, 最主要的是需要理清文法中各部分在 Expr 中对应的角色, 具体一点说就是需要理清文法中各部分在 Expr 中要赋值给哪个字段

完善一下

如上完成了 build_expr_node 函数, 现在已经可以来完善前面的部分了

/// Expr -> Term (("+" | "-") Term)*
pub fn expr() -> impl Parser<'static, Node> {
    term().and_then(|left| {
        zero_or_more(
            either(
                single_token(token!["+"]),
                single_token(token!["-"])
            ).and_then(|(op, _)| {
                term().map(move |right| (op, right))
            }),
        )
        .map(move |node_list| build_expr_node(left.to_owned(), node_list))
    })
}

/// Term -> Factor (("*" | "/") Factor)*
fn term() -> impl Parser<'static, Node> {
    factor().and_then(|left| {
        zero_or_more(
            either(
                single_token(token!["*"]),
                single_token(token!["/"])
            ).and_then(|(op, _)| {
                factor().map(move |right| (op, right))
            }),
        )
        .map(move |node_list| build_expr_node(left.to_owned(), node_list))
    })
}

跑一下

别跑了, 我在源码里面写了很多测试用例, 太长了, 而且很丑, 就不 copy 过来了, 代码是没问题的

模块导出

目前已经完成了 parser 的大部分逻辑, 简单封装一个函数作为模块导出

源码: src/parser/mod.rs

pub fn syntax(tokens: TokenStream) -> Result<Node, String> {
    match expr().parse(tokens) {
        Ok((_, n)) => Ok(n),
        Err(output) => Err(format!(
            "panic at parsing `{}`",
            output.iter().fold(String::new(), |mut res, (_, cur)| {
                res.push_str(cur);
                res
            })
        )),
    }
}

Q&A

Q: 为什么变量名一直改来改去的?

A: 最开始书写文法的时候, 为了看起来更清晰, 就选择 AddMul 为文法命名, Expr -> Add 这种文法其实是没有意义的, 因为只有一个产生式, 相当于是整了个别名, 实际实现的时候就没必要存在, 因此就干脆一点把这条冗余的规则去掉了, 然后对应的修改了一下文法命名, 其实应该是不影响阅读和理解的

Q: 上篇文章说 Parser Combinator 优雅, 优雅在哪?

A: 很神奇的一点就在于传说可读性差的函数式编程范式, 在这里可读性意外的高, 只需要把变量名连在一起读就能读懂大概的意思, 此外笔者在实现的时候发现 Parser Combinator 的设计还意外的契合软件工程中 高复用, 细粒度, 高可控, 易测试 的理念, 简单来说就是每个 ParserCombinator 都是 小类短方法, 这里的开发体验真的不错, 当然也因为代码量很小, 但是好歹看起来挺清晰挺舒服的