消费者 0.4.0

Consumer 0.4.0

Nick Lockwood 维护。



Consumer 0.4.0

  • 作者:
  • Nick Lockwood

Travis Coveralls Platforms Swift 5.0 License Twitter

简介

是什么?

Consumer是一个用于Mac和iOS的库,用于解析结构化文本,如配置文件或编程语言源文件。

主要接口是Consumer类型,用于以编程方式构建解析语法。

使用该语法,您可以将String输入解析为AST(抽象语法树),然后将其转换成应用特定的数据。

为什么?

有很多情况需要解析结构化数据。大多数流行的文件格式都有自己的解析器,通常是手工编写或使用代码生成。

编写解析器是一个费时且容易出错的流程。C语言世界中存在许多用于生成解析器的工具,但相对较少的工具适用于Swift。

Swift的强类型和高级枚举类型使其非常适合创建解析器,而Consumer就是利用了这些特性。

如何?

消费者使用一种称为 递归下降 的方法来解析输入。每个 Consumer 实例由一个子消费者树组成,树的叶子匹配输入中的单个字符串或字符。

您通过启动匹配您语言或文件格式中单个单词或值(称为“令牌”)的简单规则来构建消费者。然后,将这些规则组合成更复杂的规则,以匹配令牌序列,以此类推,直到您拥有一个能够描述您正在解析的语言中整个文档的单个消费者。

用法

安装

类型 Consumer 和其依赖关系封装在单个文件中,所有公共内容都被前缀或命名空间,因此只需将 Consumer.swift 拖到您的项目中即可使用它。

如果您愿意,有一个适用于 Mac 和 iOS 的框架,您可以导入其中包含 Consumer 类型。您可以手动安装或使用 CocoaPods、Carthage 或 Swift Package Manager 安装。

要使用 CocoaPods 安装 Consumer,请将以下内容添加到您的 Podfile 中

pod 'Consumer', '~> 0.3'

要使用 Carthage 安装,请将此添加到您的 Cartfile 中

github "nicklockwood/Consumer" ~> 0.3

要使用 Swift Package Manage 安装,请将此添加到您的 Package.swift 文件中的 dependencies: 部分中

.package(url: "https://github.com/nicklockwood/Consumer.git", .upToNextMinor(from: "0.3.0")),

解析

Consumer 类型是枚举,因此您可以通过将可能的值之一赋给变量来创建消费者。例如,以下是一个匹配字符串 "foo" 的消费者

let foo: Consumer<String> = .string("foo")

要使用此消费者解析字符串,请调用 match() 函数

do {
    let match = try foo.match("foo")
    print(match) // Prints the AST
} catch {
    print(error)
}

在上面的简单示例中,匹配将始终成功。如果对任意输入进行测试,匹配可能会失败,在这种情况下将抛出一个错误。错误将是类型 Consumer.Error,其中包含有关错误类型及其在输入字符串中发生位置的信息。

上述示例并不是很有用 —— 有许多更简单的方法可以检测字符串相等!让我们尝试一个稍微更高级的示例。以下消费者匹配无符号整数

let integer: Consumer<String> = .oneOrMore(.character(in: "0" ... "9"))

在这种情况下,顶级消费者是类型 oneOrMore,这意味着它可以匹配嵌套的 .character(in: "0" ... "9") 消费者中的一个或多个实例。换句话说,它可以匹配任何介于 "0" 和 "9" 范围内的字符序列。

尽管如此,这种实现存在稍微的问题:任意序列的数字可能包含前导零,例如 "01234",这在某些编程语言中可能会被误认为是八进制数,或者甚至被视为语法错误。我们如何修改 integer 消费者以拒绝前导零?

我们需要将第一个字符与后续的字符区分对待,这意味着我们需要应用两种不同的解析规则 按顺序。为此,我们使用 sequence 消费者

let integer: Consumer<String> = .sequence([
    .character(in: "1" ... "9"),
    .zeroOrMore(.character(in: "0" ... "9")),
])

因此,我们不再是寻找介于 0 到 9 范围内的一个或多个数字,而是寻找介于 1 到 9 范围内的单个数字,后面跟着一个或多个介于 0 到 9 范围内的数字。这意味着一个零在一个非零数字之前将不会匹配。

do {
    _ = try integer.match("0123")
} else {
    print(error) // Unexpected token "0123" at 0
}

但是我们引入了另一个错误——虽然前导零被正确拒绝,但 "0" 单独也将被拒绝,因为它不以 1-9 开头。我们需要接受 单个 零,或者 我们刚刚定义的序列。为此,我们可以使用 any

let integer: Consumer<String> = .any([
    .character("0"),
    .sequence([
        .character(in: "1" ... "9"),
        .zeroOrMore(.character(in: "0" ... "9")),
    ]),
])

这会做我们想要的事情,但它的复杂度更高。为了使其更具可读性,我们可以将其分解成单独的变量

let zero: Consumer<String> = .character("0")
let oneToNine: Consumer<String> = .character(in: "1" ... "9")
let zeroToNine: Consumer<String> = .character(in: "0" ... "9")

let nonzeroInteger: Consumer<String> = .sequence([
    oneToNine, .zeroOrMore(zeroToNine),
])

let integer: Consumer<String> = .any([
    zero, nonzeroInteger,
])

然后,我们可以通过额外的规则进一步扩展它,例如

let sign = .any(["+", "-"])

let signedInteger: Consumer<String> = .sequence([
    .optional(sign), integer,
])

字符集

基本消费者类型是 charset(Charset),它匹配指定集合中的单个字符。类型 Charset 是不透明的,不能直接构造——相反,你应该使用 character(...) 系列的便利构造函数,这些构造函数接受 UnicodeScalar 的范围或 Foundation 的 CharacterSet

例如,为了定义一个匹配 0-9 的数字的消费者,你可以使用一个范围

let range: Consumer<String> = .character(in: "0" ... "9")

你也可以使用 Foundation 提供的预定义的 decimalDigits CharacterSet,但你应该注意,这包括来自其他语言(如阿拉伯语)的数字,因此在解析数据格式(如 JSON)或只期望 ASCII 数字的数据格式时的编程语言中,这可能不是你想要的行为。

let range: Consumer<String> = .character(in: .decimalDigits)

这两个函数实际上等同于以下内容,但由于类型推断和函数重载的神奇之处,你可以在更简洁的语法中这样做

let range: Consumer<String> = Consumer<String>.character(in: CharacterSet(charactersIn: "0" ... "9"))
let range: Consumer<String> = Consumer<String>.character(in: CharacterSet.decimalDigits)

你可以通过使用 anyCharacter(except: ...) 构造函数创建一个逆字符集。当你想要匹配除特定集合以外的任何字符时,这将很有用。在以下示例中,我们使用此功能通过匹配双引号后跟一个除双引号以外任何字符的序列来解析字符串文字,最后以一个关闭的双引号结束

let string: Consumer<String> = .sequence([
    .character("\""),
    .zeroOrMore(.anyCharacter(except: "\"")),
    .character("\""),
])

.anyCharacter(except: "\"") 构造函数在功能上等同于

 .character(in: CharacterSet(charactersIn: "\"").inverted)

但前者在匹配失败时显示了更有用的错误消息,因为它保留了“除 X 以外所有字符”的概念,而后者将被显示为包含所有 unicode 字符,除了指定的字符。

转换

在上一节中,我们编写了一个可以匹配整数数字的消费者。但是,将此应用于一些输入会得到什么结果呢?以下是匹配代码

let match = try integer.match("1234")
print(match)

这里是输出结果

(
    '1'
    '2'
    '3'
    '4'
)

这有点奇怪……你可能期望得到一个包含“1234”的字符串,或者至少是一些简单易用的内容。

如果我们深入一点,看看返回的 Match 值的结构,会发现类似这样(为了清晰起见,省略了命名空间和其他元数据)

Match.node(nil, [
    Match.token("1", 0 ..< 1),
    Match.token("2", 1 ..< 2),
    Match.token("3", 2 ..< 3),
    Match.token("4", 3 ..< 4),
])

由于数字中的每个数字都是单独匹配的,因此结果被返回为一个标记符数组,而不是表示整个数字的单个标记符。这种详细程度对于某些应用可能是有用的,但我们对它目前并不需要——我们只想获取值。要做到这一点,我们需要对其进行转换

Match类型有一个名为transform()的方法,正是为了执行这个操作。transform()方法接受一个类型为Transform的闭包参数,该闭包的签名为(_ name: Label, _ values: [Any]) throws -> Any?。闭包会被递归地应用于所有匹配值,以将它们转换为应用所需的形式。

与自顶向下的解析不同,转换是从底向上进行的。这意味着每个Match的子节点将在它们的父节点之前被转换,因此,传递给转换闭包的所有值都应该已转换为预期的类型。

因此,转换函数接受一个值数组,并将其折叠为一个单个值(或nil)——相当直观——但你可能想知道关于Label参数的问题。如果你查看Consumer类型的定义,你会发现它也接受一个类型为Label的泛型参数。在到目前为止的示例中,我们一直在传递String作为标签类型,但我们实际上还没有使用它。

Label类型与label消费者一起使用。这允许你给一个特定的消费者规则起一个名字,可以在以后引用它。由于你可以将消费者存储在变量中并通过这种方式引用它们,所以这可能并不立即明显为何这样有用,但它有两个目的。

第一个目的是允许前向引用,下面将进行解释。

第二个目的是用于转换时,用于识别要转换的节点类型。分发给消费者规则的标签会在解析后在Match节点中保留,这使得能够识别哪种规则被用于创建特定类型的值。未标记的匹配值无法单独转换,它们将作为第一个标记的父节点的值传递。

因此,为了转换整数结果,我们首先必须使用label消费者类型给它一个标签

let integer: Consumer<String> = .label("integer", .any([
    .character("0"),
    .sequence([
        .character(in: "1" ... "9"),
        .zeroOrMore(.character(in: "0" ... "9")),
    ]),
]))

然后我们可以用以下代码转换匹配对象:

let result = try integer.match("1234").transform { label, values in
    switch label {
    case "integer":
        return (values as! [String]).joined()
    default:
        preconditionFailure("unhandled rule: \(name)")
    }
}
print(result ?? "")

我们知道整数消费者总是返回字符串标记数组,因此在这种情况下安全地使用as!values转换为[String]。这并不特别优雅,但这正是Swift中处理动态数据的本质。安全主义者可能会更喜欢使用as?并且在值不是[String]时抛出Error,但这种情况下只能是由编程错误引发——上面定义的整数消费者根本不会返回其他任何内容。

通过添加此函数,字符标记数组被转换成单个字符串值。打印结果现在是简单的'1234'。这要好多了,但它仍然是一个String,如果我们打算使用这个值,我们可能确实希望它是一个实际的Int。由于transform函数返回Any?,我们可以返回任何我们想要的类型,所以让我们修改它以返回一个Int

switch label {
case "integer":
    let string = (values as! [String]).joined()
    guard let int = Int(string) else {
        throw MyError(message: "Invalid integer literal '\(string)'")
    }
    return int
default:
    preconditionFailure("unhandled rule: \(name)")
}

Int(_ string: String)初始化器在参数无法转换为Int时返回一个Optional。由于我们已预先确定该字符串仅包含数字,您可能会认为我们可以安全地进行强制解析,但初始化器仍可能失败——例如,匹配的整数可能比64位能容纳的更多位数。

我们可以直接返回Int(string)的结果,因为转换函数的返回类型是Any?,但这将是一个错误,因为这会在转换失败时默默地省略数字,而我们实际上想要将其视为错误。

在这里,我们使用了一个假设的错误类型MyError,但您可以使用任何您喜欢的类型。消费者在返回之前会将您抛出的错误包裹在Consumer.Error中,这将为它附加源输入偏移和其他从解析过程中保留的有用元数据。

常见转换

某些类型的转换非常常见。除了我们刚才所做的数组到字符串的转换外,其他例子包括丢弃一个值(等价于从转换函数中返回nil),或将给定的字符串替换为另一个字符串(例如将"\n"替换为新行字符,反之亦然)。

对于这些常见操作,您不需要向消费者添加标签并编写一个转换函数,而是可以使用内置的消费者转换之一。

  • flatten - 将节点树展平为单个字符串标记
  • discard - 从结果中删除匹配的字符串标记或节点树
  • replace - 用不同的字符串标记替换匹配的节点树或字符串标记

请注意,这些转换是在解析阶段应用的,在返回Match或应用常规transform()函数之前。

使用flatten消费者,我们可以稍微简化我们的整数转换

let integer: Consumer<String> = .label("integer", .flatten(.any([
    .character("0"),
    .sequence([
        .character(in: "1" ... "9"),
        .zeroOrMore(.character(in: "0" ... "9")),
    ]),
])))

let result = try integer.match("1234").transform { label, values in
    switch label {
    case "integer":
        let string = values[0] as! String // matched value is now always a string
        guard let int = Int(string) else {
            throw MyError(message: "Invalid integer literal '\(string)'")
        }
        return int
    default:
        preconditionFailure("unhandled rule: \(name)")
    }
}

类型标签

除了需要强制展开的需要外,我们变换函数的另一个不优雅之处在于在 switch 语句中需要 default: 子句。Swift 在这里试图提供帮助,坚持要求我们处理所有可能的标签值,但我们 清楚 “整数” 是这段代码中唯一的可能标签,所以 default: 是多余的。

幸运的是,Swift 的类型系统在这里可以帮助我们。记住标签值实际上不是一个 String,而是一个通用类型 Label。这允许我们为标签使用任何我们希望的类型(只要它符合 Hashable),而一个非常好的方法是为 Label 类型创建一个 enum

enum MyLabel: String {
    case integer
}

如果我们现在将代码更改为使用这个 MyLabel 枚举而不是 String,我们就避免了容易出错字符串字面量的复制粘贴,并且在变换函数中消除了需要 default: 子句,因为 Swift 现在可以静态地确定 integer 是唯一可能的值。另一个很好的好处是,如果我们未来添加其他标签类型,编译器会警告我们如果忘记为它们实现变换。

下面是整数消费者的完整更新后的代码

enum MyLabel: String {
    case integer
}

let integer: Consumer<MyLabel> = .label(.integer, .flatten(.any([
    .character("0"),
    .sequence([
        .character(in: "1" ... "9"),
        .zeroOrMore(.character(in: "0" ... "9")),
    ]),
])))

enum MyError: Error {
    let message: String
}

let result = try integer.match("1234").transform { label, values in
    switch label {
    case .integer:
        let string = values[0] as! String
        guard let int = Int(string) else {
            throw MyError(message: "Invalid integer literal '\(string)'")
        }
        return int
    }
}
print(result ?? "")

转发引用

更复杂的解析语法(例如用于编程语言或结构化数据文件)可能需要在规则之间需要循环引用。例如,这里有解析 JSON 语法的压缩版

let null: Consumer<String> = .string("null")
let bool: Consumer<String> = ...
let number: Consumer<String> = ...
let string: Consumer<String> = ...
let object: Consumer<String> = ...

let array: Consumer<String> = .sequence([
    .string("["),
    .optional(.interleaved(json, ","))
    .string("]"),
])

let json: Consumer<String> = .any([null, bool, number, string, object, array])

array 消费者包含一系列用逗号分隔的 json 值,而 json 消费者可以匹配任何其他类型,包括 array 自己。

看到了问题吗?array 消费者在声明之前就引用了 json 消费者。这被称为 转发引用。您可能认为我们可以在赋值其值之前预先声明 json 变量来解决此问题,但这不会起作用 - Consumer 是一个值类型,所以每个引用实际上都是复制 - 它需要事先定义。

为了实现这一点,我们需要利用 labelreference 功能。首先,我们必须给 json 消费者一个名称,以便在声明之前就可以引用它

let json: Consumer<String> = .label("json", .any([null, bool, number, string, object, array]))

然后我们将 array 消费者中的 json 替换为 .reference("json")

let array: Consumer<String> = .sequence([
    .string("["),
    .optional(.interleaved(.reference("json"), ","))
    .string("]"),
])

注意:当使用这种引用时,我们必须小心,不仅要确保命名的消费者实际上存在,还要确保它在您的根消费者(您实际尝试与输入相匹配的那个)中以非引用形式存在。

在这种情况下,json 实际上是 根消费者,所以我们知道它存在。但是如果我们按照相反的方式定义引用会怎样呢?

let json: Consumer<String> = .any([null, bool, number, string, object, .reference("array")])

let array: Consumer<String> = .label("array", .sequence([
    .string("["),
    .optional(.interleaved(json, ","))
    .string("]"),
]))

所以我们现在改变了这些设置,使得 json 首先定义,然后有一个指向 array 的转发引用。看起来这似乎应该可行,但它不会。问题是,当我们要将 json 与一个输入字符串匹配时,json 消费者中没有实际的 array 消费者副本。它只通过名称引用。

如果您确保引用只从子节点指向父节点,并且父消费者直接引用其子节点,而不是通过名称,您就可以避免这个问题。

语法糖

用户故意不会过度使用自定义运算符,因为这会使得其他 Swift 开发者难以读懂代码,然而有一些语法扩展可以帮助使解析代码更易读。

Consumer 类型遵循 ExpressibleByStringLiteral 协议,作为 .string() 的情况的缩写,这意味着您可以直接写出以下内容,而无需编写:

let foo: Consumer<String> = .string("foo")
let foobar: Consumer<String> = .sequence([.string("foo"), .string("bar")])

您实际上可以直接写:

let foo: Consumer<String> = "foo"
let foobar: Consumer<String> = .sequence(["foo", "bar"])

此外,Consumer 还遵循 ExpressibleByArrayLiteral 协议,作为 .sequence() 的情况的缩写,所以直接写成以下内容:

let foobar: Consumer<String> = .sequence(["foo", "bar"])

您只需直接写成:

let foobar: Consumer<String> = ["foo", "bar"]

OR 运算符 | 也对 Consumer 进行了重载,作为使用 .any() 的替代方法,所以直接写:

let fooOrbar: Consumer<String> = .any(["foo", "bar"])

您只需写:

let fooOrbar: Consumer<String> = "foo" | "bar"

但是,请注意,当在极其复杂的表达式中使用 | 运算符时,由于类型推断的复杂性,它可能会导致 Swift 的编译时间呈指数级增长。最好只用于少量的情况。如果它不上于 4 或 5 个,或者如果它深深地嵌套在一个复杂的表达式中,您可能需要使用 any()

空白格

Consumer 不会对您正在解析的文本的性质做出任何假设,因此它没有关于有效内容与空白格(单词或标记之间)的内置区分。

在实践中,许多编程语言和结构化数据文件会有一个政策,就是忽略(或大部分忽略)标记之间的空白格,那么最好的方式是什么呢?

首先,为您的语言定义语法,排除对空白格的任何考虑。例如,这是一个简单的匹配以逗号分隔的整数列表的消费者:

let integer: Consumer<MyLabel> = .flatten("0" | [.character(in: "1" ... "9"), .zeroOrMore(.character(in: "0" ... "9"))])
let list: Consumer<MyLabel> = .interleaved(integer, .discard(","))

目前,这会匹配一个数字序列,例如 "12,0,5,78",但是如果数字之间有空格,则匹配失败。因此,我们接下来需要定义一个匹配空白格的消费者:

let space: Consumer<MyLabel> = .discard(.zeroOrMore(.character(in: " \t\r\n")))

此消费者将匹配并丢弃任何由空格、制表符、回车或换行符字符序列组成。使用 space 规则,我们可以手动修改我们的 list 模式,以忽略空格,如下所示:

let list: Consumer<MyLabel> = [space, .interleaved(integer, .discard([space, ",", space])), space]

这应该可以工作,但是像这样在语法规则之间手动插入空间相当繁琐。这也会使语法更难跟踪,并且很容易遗漏空间。

为了简化处理空白格,Consumer提供了一个方便的构造函数ignore(),允许您在匹配时自动忽略给定的模式。我们可以使用ignore()将原始的list规则与space规则组合,如下所示:

let list: Consumer<MyLabel> = .ignore(space, in: .interleaved(integer, .discard(",")))

这最终得到的消费者与手动添加空格的列表在功能上等效,但代码要少得多。

ignore() 构造函数功能强大,但它会递归地应用于整个消费者层次结构,因此您需要小心不要在您不希望允许空白处忽略空白。例如,我们不想在我们示例中的整数字面量内允许空白。

语法中的单个标记通常通过使用 flatten 转换 返回单个字符串值。 ignore() 构造函数不会修改 flatten 内部的消费者,因此我们示例中的 integer 标记实际上没有受到影响。

对于更复杂的语法,您可能无法使用 ignore(),或者只能用于整个整体消费者的某些子树,而不是整个树。例如,在 Consumer 库中包含的 JSON 示例 中,字符串字面量可以包含必须使用转换函数转换的转义 Unicode 字符字面量。这意味着 JSON 字符串字面量不能平坦化,这也意味着 JSON 语法不能使用 ignore() 处理空白,否则语法会忽略字符串内的空白,这将破坏解析。

注意: ignore() 可以用于忽略任何类型的输入,而不仅仅是空白。此外,尽管从语法的角度来看,这些输入被忽略,但它们不必在输出中丢弃。如果您正在编写代码检查器或格式化程序,您可能希望保留原始源中的空白。为此,您将需要从您的空白规则中删除 discard() 子句。

let space: Consumer<MyLabel> = .zeroOrMore(.character(in: " \t\r\n"))

在某些语言中,例如 Swift 或 JavaScript,空白通常被忽略,但换行符具有语义意义。在这种情况下,您可能只想忽略空白但不忽略换行符,或者您可能都忽略但只 discard 空白,这样您就可以在您的转换函数中手动处理换行符。

let space: Consumer<MyLabel> = .zeroOrMore(.discard(.character(in: " \t")) | .character(in: "\r\n"))

在编程语言中通常允许 注释,这些注释通常没有语义意义,可以在允许空白的地方出现。您可以以忽略空格相同的方式忽略注释

let space: Consumer<MyLabel> = .character(in: " \t\r\n")
let comment: Consumer<MyLabel> = ["/*", .zeroOrMore([.not("*/"), .anyCharacter()]), "*/"]
let spaceOrComment: Consumer<MyLabel> = .discard(.zeroOrMore(space | comment))

let program: Consumer<MyLabel> = .ignore(spaceOrComment, in: ...)

错误处理

Consumer 中可能发生两种类型的错误:解析错误和转换错误。

当 Consumer 框架遇到不匹配指定语法的输入时,会自动生成解析错误。在这种情况下,Consumer 会生成一个包含发生错误类型的 Consumer.Error 值,以及错误在原始源输入中的位置。

源位置指定为 Consumer.Location 值,它包含错误字符范围,可以惰性地计算该范围所在的行号和列号。

转换错误在初始解析传递后通过在 Consumer.Transform 函数内部抛出错误生成。任何抛出的错误都将包装在 Consumer.Error 中,以便可以使用源位置进行注释。

Consumer 的错误符合 CustomStringConvertible 协议,可以直接显示给用户(尽管消息没有本地化),但这种消息有多有用部分取决于您如何编写消费者实现。

当消费者遇到意外的令牌时,错误信息将包括预期内容的描述。自带的消费者类型如stringcharset会自动分配有意义的描述。使用标签的消费者将通过标签描述显示

let integer: Consumer<String> = .label("integer", "0" | [
    .character(in: "1" ... "9"),
    .zeroOrMore(.character(in: "0" ... "9")),
])

_ = try integer.match("foo") // will throw 'Unexpected token 'foo' at 1:1 (expected integer)'

如果您使用String作为您的Label类型,则描述将是实际的字面字符串值。如果您使用枚举(建议这样做),则默认情况下将显示标签枚举的rawValue

您的枚举情况命名可能不适合用户显示。为了解决这个问题,您可以修改标签字符串,如下所示

enum JSONLabel: String {
    case string = "a string"
    case array = "an array"
    case json = "a json value"
}

这将改进错误信息,但它不是可本地化的,并且在将JSONLabel值绑定到用户可读字符串的情况下可能不理想,尤其是在我们想要序列化它们或在将来进行破坏性更改时。更好的选择是让您的Label类型符合CustomStringConvertible,然后实现自定义的description

enum JSONLabel: String, CustomStringConvertible {
    case string
    case array
    case json
    
    var description: String {
        switch self {
        case .string: return "a string"
        case .array: return "an array"
        case .json: return "a json value"
        }
    }
}

现在用户友好的标签描述与实际值无关。这种方法也使本地化更容易,因为您可以使用rawValue来索引字符串文件,而不是使用硬编码的switch语句。

var description: String {
    return NSLocalizedString(self.rawValue, comment: "")
}

类似地,当在转换阶段抛出自定义错误时,实现CustomStringConvertible对您的自定义错误类型是个好主意

enum JSONError: Error, CustomStringConvertible {
    case invalidNumber(String)
    case invalidCodePoint(String)
    
    var description: String {
        switch self {
        case let .invalidNumber(string):
            return "invalid numeric literal '\(string)'"
        case let .invalidCodePoint(string):
            return "invalid unicode code point '\(string)'"
        }
    }
}

性能

消费者解析器的性能可能会受到您规则结构方式的影响。本节包含了一些获取最佳解析速度的技巧。

注意:与其他性能调整一样,在更改之前,您需要测量解析器的性能,否则您可能会浪费时间优化已经足够快的东西,甚至可能无意中使它变慢。

回溯

从消费者语法获得良好解析性能的最好方法是尽量减少回溯

回溯是解析器必须丢弃部分匹配结果并再次解析这些内容。它发生在给定any组中的多个消费者以相同的令牌或令牌序列开始时。

例如,以下是一个低效模式的示例

let foobarOrFoobaz: Consumer<String> = .any([
    .sequence(["foo", "bar"]),
    .sequence(["foo", "baz"]),
])

当解析器遇到输入“foobaz”时,它将首先匹配“foo”,然后尝试匹配“bar”。当失败时,它将回退到开始并尝试第二序列“foo”后跟“baz”。这将使解析比所需的速度慢。

我们可以将其重写为

let foobarOrFoobaz: Consumer<String> = .sequence([
    "foo", .any(["bar", "baz"])
])

这个消费者匹配与前面的一个完全相同的输入,但在成功匹配“foo”后,如果它无法匹配“bar”,它将立即尝试“baz”,而不是回退并再次匹配“foo”。我们已经消除了回溯。

字符序列

以下消费者示例可以匹配包含转义引号的字符串字面量。它可以匹配零个或多个转义引号 \" 或除 "\ 之外的其他任何字符。

let string: Consumer<String> = .flatten(.sequence([
    .discard("\""),
    .zeroOrMore(.any([
        .replace("\\\"", "\""), // Escaped "
        .anyCharacter(except: "\"", "\\"),
    ])),
    .discard("\""),
]))

上述实现按预期工作,但它的效率并不如它本可以做到的那样高。对于遇到的每个字符,它必须首先检查是否为转义引号,然后检查是否为除 "\ 之外的其他任何字符。这是一个相当昂贵的检查,而且目前不可能通过消费者框架来进行优化。

消费者已针对匹配 .zeroOrMore(.character(...)).oneOrMore(.character(...)) 规则进行了优化,并且我们可以重新编写字符串消费者以利用此优化,如下所示

let string: Consumer<String> = .flatten(.sequence([
    .discard("\""),
    .zeroOrMore(.any([
        .replace("\\\"", "\""), // Escaped "
        .oneOrMore(.anyCharacter(except: "\"", "\\")),
    ])),
    .discard("\""),
]))

由于典型的字符串中大多数字符都不是 \ 或 ",这将大大加快速度,因为它可以在每个转义序列之间有效地消耗非转义字符的长系列。

展开和丢弃

我们在上游的 通用转换 部分提到了 flattendiscard 转换,作为在应用自定义转换之前从解析结果中删除冗余信息的一种便捷方式。

但是使用 "flatten" 和 "discard" 也可以提高性能,因为它简化了解析过程,并避免了收集和传播不必要的像源偏移量这样的信息的需求。

如果您打算最终展开某个匹配结果的节点,那么最好在消费者内部使用 flatten 规则来做到这一点,而不是在转换函数中使用 Array.joined()。唯一不能这样做的情况是,如果某些子消费者需要应用自定义转换,因为通过展开节点树,您移除了在转换中引用节点所需的标签。

类似地,对于不需要的匹配结果(例如,逗号、括号和其他在解析后不需要的标点符号),您应该在应用转换之前始终使用 discard 来从匹配结果中删除节点或令牌。

注意:转换规则是按层次结构应用的,所以如果父消费者已经应用了 flatten,那么单独对其子应用它将无法获得进一步的性能提升。

示例项目

消费者包括一些示例项目,以展示该框架

JSON

JSON 示例项目实现了一个 JSON 解析器,并附带一个转换函数将数据转换为 Swift 格式。

REPL

REPL(读取评估打印循环)示例是一个用于评估表达式的 Mac 命令行工具。REPL 可以处理数字、布尔值和字符串值,但目前仅支持基本数学运算。

您输入到 REPL 中的每一行将被独立评估,并在控制台输出结果。要共享表达式之间的值,您可以定义变量,使用标识符名称后跟 = 符号,然后是一个表达式,例如

foo = (5 + 6) + 7

命名变量(本例中的 "foo")随后可以在后续的表达式中使用。

该示例演示了多种高级技术,如相互递归消费者规则、操作符优先级和使用 not() 进行负向前瞻。