Rust 实现一个表达式 Parser(7) Parser Combinator

3,195 阅读9分钟

本文将实现一套简单的 Parser Combinator 作为 Parser 工具

可能会有一点点麻烦, 不过一切都是很值得的, 因为用起来真的很省心

在这里放一下目前高 Star 的 Rust Parser Combinator 库 nom

对应源码 src/parser/parser_combinator

本文实现的 Parser Combinator 是参考了一位外国大佬的博客并根据需求进行了修改, 不过实在找不到原文链接了, 在此提前道歉

Parser Trait

在开始之前, 我们先将相关的类型定义好, 如下

// 单次解析的结果
pub type ParserResult<Output> = Result<(TokenStream, Output), TokenStream>;

// 实现了这个 Trait 就认为是一个 Parser
pub trait Parser<'input, Output> {
    fn parse(&self, input: TokenStream) -> ParserResult<Output>;
}

这里顺手实现出普通的 Parser

impl<'input, Output, F> Parser<'input, Output> for F
where
    F: Fn(TokenStream) -> ParserResult<Output>,
{
    fn parse(&self, input: TokenStream) -> ParserResult<Output> {
        self(input)
    }
}

解释一下, 这里为所有满足 Fn(TokenStream) -> ParserResult<Output> 的函数实现了 Parser Trait, 这个函数上会存在一个 parse 方法, 直接看一下例子

fn get_a_parser_like_function() -> impl Parser<i32> {
    |input: TokenStream| {
        Ok((input, 666))
    }
}

上面这个函数 get_a_parser_like_functon 返回一个闭包函数, 这个闭包函数满足 Parser Trait 的约束, 也就意味着, 这个被返回出去的闭包函数就是一个 Parser, 具体使用方式如下

let parser = get_a_parser_like_function();
let input = vec![];
let parse_result = parser.parse(input);

BoxedParser

为了便于支持后续的链式调用, 再单独封装一个特殊的结构体, 如下

pub struct BoxedParser<'input, Output> {
    // 这里的 parser 是一个 Trait Bound 对象
    // 意为这个 Box 里面塞的东西必须实现了 Parser Trait
    pub parser: Box<dyn Parser<'input, Output> + 'input>,
}

impl<'input, Output> BoxedParser<'input, Output> {
    pub fn new<P>(parser: P) -> Self
    where
        P: Parser<'input, Output> + 'input,
    {
        BoxedParser {
            parser: Box::new(parser),
        }
    }
}

BoxedParser 顺手实现 Parser Trait

impl<'input, Output> Parser<'input, Output> for BoxedParser<'input, Output> {
    fn parse(&self, input: TokenStream) -> ParserResult<Output> {
        // 调用 parser 上的 parse 方法来进行解析
        self.parser.parse(input)
    }
}

这样我们就完成准备工作了

API 实现

由于解析的内容比较简单, 并没有用到一些复杂的 Combinator, 以下的实现都非常简单, 因此就直接贴出代码加个测试用例

atom

这个 Parser 可以获取 Token 流中的下一个 Token

pub fn atom<'input>() -> impl Parser<'input, Token> {
    move |input: TokenStream| {
        // 获取迭代器
        let mut it = input.iter();
        match it.next() {
            Some(next) => Ok((
                // 下次输入需要跳过一项
                input.iter().skip(1).map(|t| t.to_owned()).collect(),
                // 本次输入为当前这一项
                next.to_owned(),
            )),
            // 若输入流为空, 则直接原样返回
            None => Err(input),
        }
    }
}

使用举例如下

#[test]
fn test_atom() {
    let input = vec![(NUM, "1".to_string()), (NUM, "2".to_string())];
    assert_eq!(
        Ok((vec![(NUM, "2".to_string())], (NUM, "1".to_string()))),
        atom().parse(input)
    );
}

map

这个 Combinator 可以对一个 Parser 的解析结果进行映射, 效果类似 Iterator::map

pub fn map<'input, P, Output, MapFn, NewOutput>(
    parser: P,
    map_fn: MapFn,
) -> impl Parser<'input, NewOutput>
where
    P: Parser<'input, Output>,
    MapFn: Fn(Output) -> NewOutput,
{
    move |input| {
        parser
            .parse(input)
            // parse 的返回值是一个 Result
            // 这里直接调用 Result::map 来将该 Parser 的结果映射为 map_fn 的返回值
            .map(|(next_input, output)| (next_input, map_fn(output)))
    }
}

使用如下, 这里使用 mapatom 的解析结果映射为 (PLUS, "+")

 #[test]
fn test_map() {
    let input = vec![(NUM, "1".to_string()), (NUM, "2".to_string())];
    assert_eq!(
        Ok((vec![(NUM, "2".to_string())], (PLUS, "+".to_string()))),
        map(atom(), |_| (PLUS, "+".to_string())).parse(input)
    );
}

and_then

这个 Combinator 可以连续执行两个 Parser, 只有两个 Parser 都成功了才会成功

pub fn and_then<'input, CurParser, CurOutput, NextFn, NextParser, NextOutput>(
    cur_parser: CurParser,
    next_fn: NextFn,
) -> impl Parser<'input, NextOutput>
where
    CurParser: Parser<'input, CurOutput>,
    NextFn: Fn(CurOutput) -> NextParser,
    NextParser: Parser<'input, NextOutput>,
{
    move |input| {
        // 尝试第一个 Parser 是否成功
        match cur_parser.parse(input) {
            // 若成功则尝试第二个 Parser
            // 注意这里第二个 Parser 的输入是第一个 Parser 的输出
            Ok((next_input, cur_output)) => {
                match next_fn(cur_output).parse(next_input) {
                    // 若两个 Parser 都成功, 最终返回第二个 Parser 的输出
                    Ok((final_input, next_output)) => Ok((final_input, next_output)),
                    Err(err) => Err(err),
                }
            },
            // 否则原样返回当前输入
            Err(err) => Err(err),
        }
    }
}

使用如下

#[test]
fn test_and_then() {
    let input = vec![(NUM, "1".to_string()), (NUM, "2".to_string())];
    assert_eq!(
        Ok((vec![], (NUM, "2".to_string()))),
        and_then(atom(), |_| { atom() }).parse(input)
    )
}

这里可能比较模糊, 解释一下:

  • 这里第一个 Parser 是一个 atom, 会匹配到 (NUM, "1")
  • 第二个 Parser 也是一个 atom, 会匹配到 (NUM, "2")
  • input 中只有这两项
  • 解析完成后, input 变成了一个空的 Vector, 输出则是第二个解析的结果 (NUM, "2")

judge

这个 Combinator 可以使用一个判断函数来控制一个 Parser 是否返回结果

pub fn judge<'input, P, Output, JudgeFn>(
    parser: P,
    judge_fn: JudgeFn,
) -> impl Parser<'input, Output>
where
    P: Parser<'input, Output>,
    JudgeFn: Fn(&Output) -> bool,
{
    move |input: TokenStream| {
        match parser.parse(input.clone()) {
            // 若解析成功, 且 judge_fn 返回 true
            // 则返回解析结果
            Ok((next_input, output)) if judge_fn(&output) => Ok((next_input, output)),
            _ => Err(input),
        }
    }
}

使用如下

#[test]
fn test_judge() {
    let input = vec![(PLUS, "+".to_string())];
    assert_eq!(
        Ok((vec![], (PLUS, "+".to_string()))),
        judge(atom(), |(kind, _)| *kind == PLUS).parse(input)
    )
}

either

这个 Combinator 可以从两个 Parser 挑选一个成功的 Parser 执行, 优先第一个

pub fn either<'input, P1, P2, Output>(parser1: P1, parser2: P2) -> impl Parser<'input, Output>
where
    P1: Parser<'input, Output>,
    P2: Parser<'input, Output>,
{
    move |input: TokenStream| {
        // 若 parser1 解析成功则返回 parser1 的解析结果
        match parser1.parse(input.clone()) {
            Ok((next_input, output)) => Ok((next_input, output)),
            // 若 parser1 解析失败则返回 parser2 的解析结果
            Err(_) => parser2.parse(input),
        }
    }
}

使用如下

#[test]
fn test_either() {
    let input = vec![(NUM, "1".to_string())];
    // 解析一个 NUM 类型的 Token
    let number_parser = judge(atom(), |(kind, _)| *kind == NUM);
    // 解析一个 PLUS 类型的 Token
    let plus_parser = judge(atom(), |(kind, _)| *kind == PLUS);
    assert_eq!(
        Ok((vec![], (NUM, "1".to_string()))),
        either(number_parser, plus_parser).parse(input)
    )
}

zero_or_more

这个 Combinator 可以将一个 Parser 重复匹配 0 次或更多次

pub fn zero_or_more<'input, P, Output>(parser: P) -> impl Parser<'input, Vec<Output>>
where
    P: Parser<'input, Output>,
{
    move |mut input: TokenStream| {
        // 保存解析结果
        let mut result = Vec::new();
        // 本次的输出应该作为下一次循环中 parser 的输入
        while let Ok((next_input, item)) = parser.parse(input.clone()) {
            input = next_input;
            result.push(item)
        }
        Ok((input, result))
    }
}

使用如下

#[test]
fn test_zero_or_more() {
    let num_one = (NUM, "1".to_string());

    // 匹配一个 (NUM, "1")
    let num_parser = judge(atom(), |(kind, text)| *kind == NUM && text == "1");
    let input = vec![];
    // 将 (NUM, "1") 匹配了 0 次
    assert_eq!(Ok((vec![], vec![])), zero_or_more(num_parser).parse(input));

    // 匹配一个 (NUM, "1")
    let num_parser = judge(atom(), |(kind, text)| *kind == NUM && text == "1");
    let input = vec![num_one.clone(), num_one.clone(), num_one.clone()];
    // 将 (NUM, "1") 匹配了 3 次
    assert_eq!(
        Ok((
            vec![],
            vec![num_one.clone(), num_one.clone(), num_one.clone()]
        )),
        zero_or_more(num_parser).parse(input)
    );
}

single_token

这个 Parser 可以尝试匹配一个期望的 SyntaxKind 类型

pub fn single_token(expect: SyntaxKind) -> impl Parser<'static, Token> {
    // 结合 atom 和 judge 可以很轻松的实现这个需求
    judge(atom(), move |(kind, _)| *kind == expect)
}

使用如下

#[test]
fn test_single_token() {
    let input = vec![(PLUS, "+".to_string())];
    assert_eq!(
        Ok((vec![], (PLUS, "+".to_string()))),
        single_token(PLUS).parse(input)
    )
}

实现链式调用

目前我们已经实现了要用到的所有工具函数, 但使用起来仍然感觉不够灵活不够舒服, 因此来借助 BoxedParser 实现链式调用

我们将上面封装好的一些函数直接添加到 Parser Trait 中并给予默认实现, 这样任何一个 Parser 都可以链式调用这些方法

实现如下, 添加了 map, and_then, or 三个方法, 这里的 or 就是 either, 只不过以中缀形式存在的话, 叫 or 会更加贴切一点

pub trait Parser<'input, Output> {
    fn parse(&self, input: TokenStream) -> ParserResult<Output>;

    fn map<MapFn, NewOutput>(self, map_fn: MapFn) -> BoxedParser<'input, NewOutput>
    where
        Self: Sized + 'input,
        Output: 'input,
        NewOutput: 'input,
        MapFn: Fn(Output) -> NewOutput + 'input,
    {
        BoxedParser::new(map(self, map_fn))
    }

    fn and_then<NextFn, NextParser, NextOutput>(
        self,
        next_fn: NextFn,
    ) -> BoxedParser<'input, NextOutput>
    where
        Self: Sized + 'input,
        Output: 'input,
        NextParser: Parser<'input, NextOutput> + 'input,
        NextFn: Fn(Output) -> NextParser + 'input,
        NextOutput: 'input,
    {
        BoxedParser::new(and_then(self, next_fn))
    }

    fn or<OtherParser>(self, other_parser: OtherParser) -> BoxedParser<'input, Output>
    where
        Self: Sized + 'input,
        Output: 'input,
        OtherParser: Parser<'input, Output> + 'input,
    {
        BoxedParser::new(either(self, other_parser))
    }
}

可以看到, 这里几乎没有写任何逻辑, 只是调用了事先封装好的函数, 再将返回的 ParserBoxedParser 包裹并返回, 这样相当于又返回了一个实现了 Parser Trait 的对象, 因此就可以实现链式调用

#[test]
fn test_chained_call() {
    let input = vec![(NUM, "1".to_string())];
    assert_eq!(
        Ok((vec![], (PLUS, "+".to_string()))),
        atom().map(|_| (PLUS, "+".to_string())).parse(input)
    );
}

How it works

这里特意将这个内容放到最后来说, 是希望读者能在大致过了一遍前面的源码实现后再来看这一部分, 我们先来看一段简单的代码

#[test]
fn data_stream() {
    let input = vec![
        (NUM, "1".to_string()),
        (PLUS, "+".to_string()),
        (NUM, "2".to_string()),
    ];
    assert_eq!(
        Ok((
            vec![(PLUS, "+".to_string()), (NUM, "2".to_string())],
            (PLUS, "1".to_string())
        )),
        judge(atom(), |(kind, _)| *kind == NUM)
            .map(|(_, text)| (PLUS, text))
            .parse(input)
    );
}

回想笔者在前面的文章中提到的, "FP 的工作方式类似一条流水线", 我们现在将 TokenStream 视为一条流水线, 将 ParserCombinator 想象为流水线上面的某台机器或者闸门, 如下

注意, 这里示意图为了清晰简洁, 和实际解析顺序不同, 但是大致的工作方式是没问题的

pc_data_stream.jpg

这条流水线上面有很多的 Token 流过, 经过一层层阀门的加工和筛选, 直到最后被完全筛选处理完毕, 解析结束

我们单独来看 atomany parser 这段

  • 一开始 [NUM, PLUS, NUM] 作为输入流入 atom
  • atom 解析成功后, 将 NUM 作为输出流入岔道, 剩余部分 [NUM, PLUS] 作为下一次输入继续在流水线上流动, 作为 any parser 的输入

我们再来看岔道上这段

  • atom 解析成功, 输出 NUM 作为输入流入到 judge
  • judge 期望一个 NUM, 解析成功, 原样输出到 map
  • map 接收到 NUM, 将 NUM 映射为 PLUS, 输出

以上就是大致的工作方式

我们组合不同的 Parser 的过程就是在搭建这条流水线, 通过控制输入输出来在流水线分出岔道并设置相应的条件, 而调用 Parser Trait 上面的 parse 方法相当于是把某个地方的阀门打开, 让数据流能够流入, 而最外层的 parse 方法的调用就是控制整条数据流的流入时机

Q&A

Q: 这么长的调用链不会爆栈么?

A: 好问题, 我一开始也有过类似的担心, 现在这个 tiny-expr-parser 比较简单, 如果不是拿家里座机跑的话应该不至于爆栈, 我在另一个类似性质的项目里 debug parser 部分逻辑的时候看到调用栈心里一紧, 其实 Parser Combinator 是函数式风味很重的一种解决方案, 最开始貌似是 haskell 那边搞出来的, 纯函数式语言的内存管理策略一般与其他类型的语言不同, 我记得是需要专门针对 gc 做优化的, 因为在纯函数式语言里会存在大量的高高高高阶函数, 可能一调用瞬间就会产生大量的临时变量, 又会在一瞬间全部变成垃圾, 这点与其他语言区别极大, 像 Rust, Java, Js 这样的语言的内存应该是缓步上升的(我猜的), 我之前读到一篇文章, 据说在纯函数式语言里, 能把内存分配优化到比原地修改还要快, 因此这个在纯函数式语言里应该不是问题, 在其他语言中呢? 反正我目前的实践中, 除非遇到左递归, 不然还没爆过栈, 所以放心写吧

Q: 为什么写的这么丑?

A: 一方面我觉得 Rust 的生命周期标注和 Trait Bound 确实有点影响可读性, 另一方面我觉得不能以阅读命令式编程的方法来阅读函数式编程的代码, 将每个函数视为一个黑盒, 只关注函数名, 输入, 输出, 这样还是蛮好读的, 而且到下一篇正式实现 Parser 的时候会发现其实 Parser Combinator 挺优雅的