[译] 经过 Rust 学习解析器组合器 — 第三部分

经过 Rust 学习解析器组合器 — 第三部分

若是你没看过本系列的其余几篇文章,建议你按照顺序进行阅读:前端

断定组合器

如今咱们有了构建的代码块,咱们须要经过它用 one_or_more 解析空格符,并用 zero_or_more 解析属性对。android

事实上,得等一下。咱们并不想先解析空格符而后解析属性。若是你考虑到,在没有属性的状况下,空格符也是可选的,而且咱们可能会当即遇到 >/>。但若是有一个属性时,在开头就必定会有空格符。幸运的是,每一个属性之间也必定会有空格符,若是有多个的话,那么咱们看看零个或者多个序列,该序列是在属性后跟随一个或者多个空格符。ios

首先,咱们须要一个针对单个空格的解析器。这里咱们能够从三种方式选择其中一种。git

第一,咱们能够最简单的使用 match_literal 解析器,它带有一个只包含一个空格的字符串。这看起来是否是很傻?由于空格符也至关因而换行符、制表符和许多奇怪的 Unicode 字符,它们都是以空白的形式呈现的。咱们将不得再也不次依赖 Rust 的标准库,固然,char 有一个 is_whitespace 方法,也是相似于它的 is_alphabeticis_alphanumeric 方法。程序员

第二,咱们能够编写一个解析器,它是经过 is_whitespace 来断定解析任意数量的空格,就像咱们前面写到的 identifier 同样。github

第三,咱们能够更明智一点,咱们确实喜欢更明智的作法。咱们能够编写一个解析器 any_char,它返回一个单独的 char,只要输入中还有空格符,接着编写一个 pred 组合器,它接受一个解析器和一个断定函数,并将它们像这样组合起来:pred(any_char, |c| c.is_whitespace())。这样作会有一个好处,它使咱们最终的解析器的编写变得更简单:属性值使用引用字符串。json

any_char 能够看作是一个很是简单的解析器,但咱们必须记住当心那些 UTF-8 陷阱。后端

fn any_char(input: &str) -> ParseResult<char> {
    match input.chars().next() {
        Some(next) => Ok((&input[next.len_utf8()..], next)),
        _ => Err(input),
    }
}
复制代码

对于如今咱们富有经验的眼睛来讲,pred 组合器没有给咱们带来惊喜。咱们调用解析器,而后在解析器执行成功时再对返回值调用断定函数,只有当该函数返回 true 时,咱们才真正返回成功,不然就会返回跟解析失败同样多的错误。bash

fn pred<'a, P, A, F>(parser: P, predicate: F) -> impl Parser<'a, A>
where
    P: Parser<'a, A>, F: Fn(&A) -> bool, { move |input| { if let Ok((next_input, value)) = parser.parse(input) { if predicate(&value) { return Ok((next_input, value)); } } Err(input) } } 复制代码

快速地写一个测试用例来确保一切是有序进行的:ide

#[test]
fn predicate_combinator() {
    let parser = pred(any_char, |c| *c == 'o');
    assert_eq!(Ok(("mg", 'o')), parser.parse("omg"));
    assert_eq!(Err("lol"), parser.parse("lol"));
}
复制代码

针对这两个地方,咱们能够用一个快速的一行代码来编写咱们的 whitespace_char 解析器:

fn whitespace_char<'a>() -> impl Parser<'a, char> {
    pred(any_char, |c| c.is_whitespace())
}
复制代码

如今,咱们有了 whitespace_char,咱们所作的离咱们的想法更近了,一个或多个空格,以及相似的想法,零个或者多个空格。咱们将其简化一下,分别将它们命名为 space1space0

fn space1<'a>() -> impl Parser<'a, Vec<char>> {
    one_or_more(whitespace_char())
}

fn space0<'a>() -> impl Parser<'a, Vec<char>> {
    zero_or_more(whitespace_char())
}
复制代码

字符串引用

完成这些工做后,终于咱们如今能够解析这些属性了吗?是的,咱们只须要确保为属性组件编写好了单独的解析器。咱们已经获得了属性名的 identifier(尽管很容易使用 any_charpred 加上 *_or_more 组合器重写它)。= 也即 match_literal("=")。不过,咱们只须要字符串解析器的引用,因此咱们要构建它。幸运的是,咱们已经实现了咱们所须要的组合器。

fn quoted_string<'a>() -> impl Parser<'a, String> {
    map(
        right(
            match_literal("\""),
            left(
                zero_or_more(pred(any_char, |c| *c != '"')),
                match_literal("\""),
            ),
        ),
        |chars| chars.into_iter().collect(),
    )
}
复制代码

在这里,组合器的嵌套有点烦人,但咱们暂时不打算重构它,而是将重点放在接下来要作的东西上。

最外层的组合器是一个 map,由于以前提到嵌套很烦人,从这里开始会变得糟糕而且咱们要忍受并理解这一点,咱们试着找到开始执行的地方:第一个引号字符。在 map 中,有一个 right,而 right 的第一部分是咱们要查找的:match_literal("\"")。以上就是咱们一开始要着手处理的东西。

right 的第二部分是字符串剩余部分的处理。它位于 left 的内部,咱们会很快的注意到右侧left 参数,是咱们要忽略的,也就是另外一个 match_literal("\"") —— 结束的引号。因此左侧参数是咱们引用的字符串。

咱们利用新的 predany_char 在这里获得一个解析器,它接收任何字符除了另外一个引号,咱们把它放进 zero_or_more,因此咱们讲的也是如下这些:

  • 一个引号
  • 随后是零个或多个除了结束引号之外的字符
  • 随后是结束引号

而且,在 rightleft 之间,咱们会在结果值中丢弃引号,而且获得引号之间的字符串。

等等,那不是字符串。还记得 zero_or_more 返回的是什么吗?一个类型为 Vec<A> 的值,其中类型为 A 的值是由内部解析器返回的。对于 any_char,返回的是 char 类型。那么咱们获得的不是一个字符串,而是一个类型为 Vec<char> 的值。这是 map 所处的位置:咱们使用它把 Vec<char> 转换为 String,基于这样一个状况,你能够构建一个产生 String 的迭代器 Iterator<Item = char>,咱们称之为 vec_of_chars.into_iter().collect(),多亏了类型推导的力量,咱们才有了 String

在咱们继续以前,咱们先写一个快速的测试用例来确保它是正确的,由于若是咱们须要这么多词来解释它,那么它可能不是咱们做为程序员应该相信的东西。

#[test]
fn quoted_string_parser() {
    assert_eq!(
        Ok(("", "Hello Joe!".to_string())),
        quoted_string().parse("\"Hello Joe!\"")
    );
}
复制代码

如今,我发誓,真的是要解析这些属性了。

最后,解析属性

咱们如今能够解析空格符、标识符,= 符号和带引号的字符串。最后,这就是解析属性所需的所有内容。

首先,咱们为属性对写解析器。咱们将会把属性做为 Vec<(String, String)> 存储,你可能还记得这个类型,因此感受可能须要一个针对 (String, String) 的解析器,将其提供给咱们可靠的 zero_or_more 组合器。咱们看看可否造一个。

fn attribute_pair<'a>() -> impl Parser<'a, (String, String)> {
    pair(identifier, right(match_literal("="), quoted_string()))
}
复制代码

过轻松了,汗都没出一滴!总结一下:咱们已经有一个便利的组合器用于解析元组的值,也就是 pair,咱们能够将其做为 identifier 解析器,迭代出一个 String,以及一个带有 =right 解析器,它的返回值咱们不想保存,而且咱们刚写出来的 quoted_string 解析器会返回给咱们 String 类型的值。

如今,咱们结合一下 zero_or_more,去构建一个 vector —— 但不要忘了它们之间的空格符。

fn attributes<'a>() -> impl Parser<'a, Vec<(String, String)>> {
    zero_or_more(right(space1(), attribute_pair()))
}
复制代码

如下状况会出现零次或者屡次:一个或者多个空白符,其后是一个属性对。咱们经过 right 丢弃空白符并保留属性对。

咱们测试一下它。

#[test]
fn attribute_parser() {
    assert_eq!(
        Ok((
            "",
            vec![
                ("one".to_string(), "1".to_string()),
                ("two".to_string(), "2".to_string())
            ]
        )),
        attributes().parse(" one=\"1\" two=\"2\"")
    );
}
复制代码

测试是经过的!先别高兴太早!

实际上,有些问题,在这个状况中,个人 rustc 编译器已经给出提示信息表示个人类型过于复杂,我须要增长可容许的类型范围才能让编译继续。鉴于咱们在同一点上遇到了相似的错误,这是有利的,若是你是这种状况,你须要知道如何处理它。幸运的是,在这些状况下,rustc 一般会给出好的建议,因此当它告诉你在文件顶部添加 #![type_length_limit = "…some big number…"] 注解时,照作就好了。在实际状况中,就是添加 #![type_length_limit = "16777216"],这将使咱们更进一步深刻到复杂类型的平流层。全速前进,咱们就要上天了。

如今离答案很近了

在这一点上,这些东西看起来即将要组合到一块儿了,有些解脱了,由于咱们的类型正快速接近于 NP 彻底性理论。咱们只须要处理两种元素标签:单个元素以及带有子元素的父元素,但咱们很是有信心,一旦咱们有了这些,解析子元素就只须要使用 zero_or_more,是吗?

那么接下来咱们先处理单元素的状况,把子元素的问题放一放。或者,更进一步,咱们先基于这两种元素的共性写一个解析器:开头的 <,元素名称,而后是属性。让咱们看看可否从几个组合器中获取到 (String, Vec<(String, String)>) 类型的结果。

fn element_start<'a>() -> impl Parser<'a, (String, Vec<(String, String)>)> {
    right(match_literal("<"), pair(identifier, attributes()))
}
复制代码

有了这些,咱们就能够快速的写出代码,从而为单元素建立一个解析器。

fn single_element<'a>() -> impl Parser<'a, Element> {
    map(
        left(element_start(), match_literal("/>")),
        |(name, attributes)| Element {
            name,
            attributes,
            children: vec![],
        },
    )
}
复制代码

万岁,感受咱们已经接近咱们的目标了 —— 实际上咱们正在构建一个 Element

让咱们测试一下现代科技的奇迹。

#[test]
fn single_element_parser() {
    assert_eq!(
        Ok((
            "",
            Element {
                name: "div".to_string(),
                attributes: vec![("class".to_string(), "float".to_string())],
                children: vec![]
            }
        )),
        single_element().parse("<div class=\"float\"/>")
    );
}
复制代码

…… 我想咱们已经逃离出平流层了。

single_element 返回的类型是如此的复杂,以致于编译器不能顺利的完成编译,除非咱们提早给出足够大内存空间的类型,甚至要求更大的类型。很明显,咱们不能再忽略这个问题了,由于它是一个很是简单的解析器,却须要数分钟的编译时间 —— 这会致使最终的产品可能须要数小时来编译 —— 这彷佛有些不合理。

在继续以前,你最好将这两个函数和测试用例注释掉,便于咱们进行修复……

处理无限大的问题

若是你曾经尝试过在 Rust 中编写递归类型的东西,那么你可能已经知道这个问题的解决方案。

关于递归类型的一个简单例子就是单链表。原则上,你能够把它写成相似于这样的枚举形式:

enum List<A> {
    Cons(A, List<A>),
    Nil,
}
复制代码

很明显,rustc 编译器会对递归类型 List<A> 给出报错信息,提示它具备无限的大小,由于在每一个 List::<A>::Cons 内部均可能有另外一个 List<A>,这意味着 List<A> 能够一直直到无穷大。就 rustc 编译器而言,咱们须要一个无限列表,而且要求它能分配一个无限列表。

在许多语言中,对于类型系统来讲,一个无限列表原则上不是问题,并且对 Rust 来讲也不是什么问题。问题是,前面提到的,在 Rust 中,咱们须要可以分配它,或者,更确切的说,咱们须要可以在构造类型时先肯定类型的大小,当类型是无限的时候,这意味着大小也必须是无限的。

解决办法是采用间接的方法。咱们不是将 List::Cons 改成 A 的一个元素和另外一个 A列表,反而是使用一个 A 元素和一个指向 A 列表的指针。咱们已知指针的大小,无论它指向什么,它都是相同的大小,因此咱们的 List::Cons 如今是一个固定大小的而且可预测的,无论列表的大小如何。把一个已有的数据变成将数据存储于堆上,而且用指针指向该堆内存的方法,在 Rust 中,就是使用 Box 处理它。

enum List<A> {
    Cons(A, Box<List<A>>),
    Nil,
}
复制代码

Box 的另外一个有趣特性是,其中的类型是能够抽象的。这意味着,咱们可让类型检查器处理一个很是简洁的 Box<dyn Parser<'a, A>>,而不是处理当前的很是复杂的解析器函数类型。

听起来很不错。有什么缺陷吗?好吧,咱们可能会由于使用指针的方式而损失一两次循环,也可能会让编译器失去一些优化解析器的机会。可是想起 Knuth 的关于过早优化的提醒:一切都会好起来的。损失这些循环是值得的。你在这里是学习关于解析器组合器,而不是学习手工编写专业的 SIMD 解析器(尽管它们自己会使人兴奋)

所以,抛开目前咱们使用的简单函数,让咱们继续基于即将要完成的解析器函数来实现 Parser

struct BoxedParser<'a, Output> { parser: Box<dyn Parser<'a, Output> + 'a>, } impl<'a, Output> BoxedParser<'a, Output> { fn new<P>(parser: P) -> Self where P: Parser<'a, Output> + 'a, { BoxedParser { parser: Box::new(parser), } } } impl<'a, Output> Parser<'a, Output> for BoxedParser<'a, Output> {
    fn parse(&self, input: &'a str) -> ParseResult<'a, Output> {
        self.parser.parse(input)
    }
}
复制代码

为了更好地实现,咱们建立了一个新的类型 BoxedParser 用于保存 Box 相关的数据。咱们利用其它的解析器(包括另外一个 BoxedParser,虽然这没太大做用)来建立新的 BoxedParser,咱们提供一个新的函数 BoxedParser::new(parser),它只是将解析器放在新类型的 Box 中。最后,咱们为它实现 Parser,这样,它就能够做为解析器交换着使用。

这使咱们具有将解析器放入一个 Box 中的能力,而 BoxedParser 将会以函数的角色为 Parser 执行一些逻辑。正如前面提到的,这意味着将 Box 包装的解析器移到堆中,而且必须删除指向该堆区域的指针,这可能会多花费几纳秒的时间,因此实际上咱们可能想先不用 Box 包装全部数据。只是把一些更活跃的组合器数据经过 Box 包装就够了。

许可证

本做品版权归 Bodil Stokke 全部,在知识共享署名-非商业性-相同方式共享 4.0 协议之条款下提供受权许可。要查看此许可证,请访问 creativecommons.org/licenses/by…

脚注

1: 他不是你真正的叔叔 2: 请不要成为聚会上的那我的。

若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索