Travis Coveralls Platforms Swift 5.0 License Twitter

简介

是什么?

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

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

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

为什么?

在许多情况下,能够解析结构化数据非常有用。大多数流行的文件格式都有某种解析器,通常是手工编写或使用代码生成。

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

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

如何使用?

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

您可以通过从匹配语言或文件格式中单个单词或值(称为“标记”)的简单规则开始构建 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 类型是一个枚举,因此您可以通过将其中一个可能的值分配给变量来创建 Consumer。例如,这是一个匹配字符串 "foo" 的 Consumer

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

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

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

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

上面的示例不是很实用 - 有更简单的方法来检测字符串相等性!让我们尝试一个稍微高级一点的示例。以下 Consumer 匹配一个无符号整数

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

在这种情况下,顶层 Consumer 的类型为 oneOrMore,这意味着它匹配嵌套的 .character(in: "0" ... "9") Consumer 的一个或多个实例。换句话说,它将匹配 "0" - "9" 范围内的任何字符序列。

但是,此实现存在一个小问题:任意数字序列可能包含前导零,例如 "01234",在某些编程语言中可能会被误认为是八进制数,甚至只是被视为语法错误。我们如何修改 integer Consumer 以拒绝前导零?

我们需要区别对待第一个字符和后续字符,这意味着我们需要按顺序应用两个不同的解析规则。为此,我们使用 sequence Consumer

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

因此,现在我们寻找的是 1 - 9 范围内的单个数字,后跟 0 - 9 范围内的 zeroOrMore 数字,而不是 0 - 9 范围内的 oneOrMore 数字。这意味着零先于非零数字将不会被匹配。

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,
])

字符集

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

例如,要定义一个匹配数字 0 - 9 的 Consumer,您可以使用范围

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 字符的范围。

转换

在上一节中,我们编写了一个可以匹配整数的 Consumer。但是,当我们将其应用于某些输入时,会得到什么呢?这是匹配代码

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

这是输出

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

这...很奇怪。您可能希望得到一个包含 "1234" 的 String,或者至少是一些更易于使用的东西。

如果我们深入一点并查看返回的 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 Consumer 结合使用。这允许您为给定的 Consumer 规则分配名称,该名称可用于稍后引用它。由于您可以将 Consumer 存储在变量中并以这种方式引用它们,因此为什么这有用并不立即显而易见,但它有两个目的

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

第二个目的是在转换时使用,以标识要转换的节点类型。分配给 Consumer 规则的标签在解析后保留在 Match 节点中,从而可以识别哪个规则被匹配以创建特定类型的值。未标记的匹配值无法单独转换,它们将作为第一个标记的父节点的值传递。

因此,要转换整数结果,我们必须首先使用 label Consumer 类型为其指定标签

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 ?? "")

我们知道 integer Consumer 将始终返回字符串标记数组,因此在这种情况下,我们可以安全地使用 as!values 强制转换为 [String]。这不是很优雅,但这是在 Swift 中处理动态数据的本质。安全纯粹主义者可能更喜欢使用 as?,并在值不是 [String] 时抛出 Error,但这种情况只能在编程错误的情况下发生 - 我们上面定义的 integer Consumer 匹配的任何输入数据都不会返回其他任何内容。

添加此函数后,字符标记数组将转换为单个字符串值。现在打印的结果只是 '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,则 Int(_ string: String) 初始化程序会返回一个 Optional。由于我们已经预先确定字符串仅包含数字,您可能会认为我们可以安全地强制解包它,但是初始化程序仍然有可能失败 - 例如,匹配的整数可能包含太多位而无法放入 64 位。

我们可以直接返回 Int(string) 的结果,因为转换函数的返回类型是 Any?,但这将是一个错误,因为如果转换失败,这将默默地从输出中省略该数字,而我们实际上希望将其视为错误。

我们在这里使用了名为 MyError 的虚构错误类型,但您可以使用您喜欢的任何类型。Consumer 会将您抛出的错误包装在 Consumer.Error 中,然后再返回它,这将使用从解析过程保留的源输入偏移量和其他有用的元数据对其进行注释。

常用转换

某些类型的转换非常常见。除了我们刚刚完成的 Array -> String 转换之外,其他示例还包括丢弃值(等效于从转换函数返回 nil)或将给定的字符串替换为不同的字符串(例如,将 "\n" 替换为换行符,或反之亦然)。

对于这些常见操作,您可以选择使用内置的 Consumer 转换,而不是将标签应用于 Consumer 并必须编写转换函数

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

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

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 试图在此处提供帮助,坚持我们处理所有可能的标签值,但我们知道 "integer" 是此代码中唯一可能的标签,因此 default: 是多余的。

幸运的是,Swift 的类型系统可以帮助解决这个问题。请记住,标签值实际上不是 String,而是泛型类型 Label。这允许我们为标签使用我们想要的任何类型(前提是它符合 Hashable),一个非常好的方法是为 Label 类型创建一个 enum

enum MyLabel: String {
    case integer
}

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

下面显示了整数 Consumer 的完整更新代码

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 Consumer 包含以逗号分隔的 json 值序列,而 json Consumer 可以匹配任何其他类型,包括 array 本身。

您看到了问题吗?array Consumer 在声明之前引用了 json Consumer。这称为前向引用。您可能会认为我们可以通过在分配其值之前预先声明 json 变量来解决此问题,但这行不通 - Consumer 是值类型,因此对它的每个引用实际上都是副本 - 它需要预先定义。

为了实现这一点,我们需要使用 labelreference 功能。首先,我们必须为 json Consumer 指定标签,以便可以在声明之前引用它

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

然后,我们将 array Consumer 内的 json 替换为 .reference("json")

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

注意: 在使用像这样的引用时,您必须小心,不仅要确保命名的 Consumer 确实存在,还要确保它以非引用形式包含在您的根 Consumer 中(您实际尝试与输入匹配的 Consumer)。

在这种情况下,json 根 Consumer,所以我们知道它存在。但是,如果我们以另一种方式定义引用怎么办?

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 Consumer 中没有任何实际 array Consumer 的副本。它仅按名称引用。

如果您确保引用仅从子节点指向其父节点,并且父 Consumer 直接引用其子节点,而不是通过名称引用,则可以避免此问题。

语法糖

Consumer 有意没有过度使用自定义运算符,因为它会使代码对其他 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 不对您正在解析的文本的性质做任何假设,因此它在有意义的内容和空白字符(标记之间的空格或换行符)之间没有任何内置的区别。

实际上,许多编程语言和结构化数据文件都有忽略(或大部分忽略)标记之间空白字符的策略,那么最好的方法是什么?

首先,定义您的语言的语法,不考虑任何空白字符。例如,这是一个匹配逗号分隔的整数列表的简单 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" 这样的数字序列,但如果我们在数字之间包含空格,它将失败。因此,接下来我们需要定义一个用于匹配空白字符的 Consumer

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

此 Consumer 将匹配(并丢弃)任何空格、制表符、回车符或换行符的序列。使用 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(",")))

这会产生一个在功能上等效于我们上面创建的手动间隔列表的 Consumer,但代码量少得多。

ignore() 构造函数非常强大,但由于它以递归方式应用于整个 Consumer 层次结构,因此您需要小心不要在您不想允许空白字符的地方忽略空白字符。例如,我们不希望允许空白字符出现在单个标记内部,例如我们示例中的整数文字。

语法中的单个标记通常通过使用扁平化转换作为单个字符串值返回。ignore() 构造函数不会修改 flatten 内的 Consumer,因此我们示例中的 integer 标记实际上不受影响。

对于更复杂的语法,您可能无法使用 ignore(),或者可能只能在整个 Consumer 的某些子树上使用它,而不是整个 Consumer。例如,在 Consumer 库附带的 JSON 示例 中,字符串文字可以包含必须使用转换函数转换的转义 Unicode 字符文字。这意味着 JSON 字符串文字不能被扁平化,这也意味着 JSON 语法不能使用 ignore() 来处理空白字符,否则语法将忽略字符串内部的空白字符,这会搞砸解析。

注意: ignore() 可用于忽略任何类型的输入,而不仅仅是空白字符。此外,虽然从语法的角度来看输入被忽略,但它不一定必须在输出中丢弃。如果您正在编写代码 linter 或格式化程序,您可能希望保留原始来源中的空白字符。为此,您将从空白字符规则内部删除 discard() 子句

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

在某些语言(如 Swift 或 JavaScript)中,空格在很大程度上被忽略,但换行符具有语义意义。对于这种情况,您可以仅忽略空格但不忽略换行符,或者您可以同时忽略两者,但仅丢弃空格,这样您就可以在转换函数中手动处理换行符

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,可以直接显示给用户(尽管消息未本地化),但这消息的有用程度部分取决于您编写 Consumer 实现的方式。

当 Consumer 遇到意外标记时,错误消息将包含对实际预期内容的描述。内置 Consumer 类型(如 stringcharset)会自动分配有意义的描述。标记的 Consumer 将使用标签描述显示

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)'"
        }
    }
}

性能

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

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

回溯

从您的 Consumer 语法中获得良好解析性能的最佳方法是尽量避免回溯

当解析器必须丢弃部分匹配的结果并再次解析它们时,就会发生回溯。当给定 any 组中的多个 Consumer 以相同的标记或标记序列开头时,就会发生回溯。

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

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"])
])

此 Consumer 匹配与前一个 Consumer 完全相同的输入,但在成功匹配 "foo" 后,如果它未能匹配 "bar",它将立即尝试 "baz",而不是返回并再次匹配 "foo"。我们消除了回溯。

字符序列

以下 Consumer 示例匹配一个带引号的字符串文字,其中包含转义引号。它匹配零个或多个转义引号 \" 实例,或除了 "\ 之外的任何其他字符。

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

上述实现按预期工作,但效率不如它可能达到的效率。对于遇到的每个字符,它必须首先检查转义引号,然后检查它是否是任何其他字符。执行此检查非常昂贵,并且(目前)无法通过 Consumer 框架进行优化。

Consumer 具有针对匹配 .zeroOrMore(.character(...)).oneOrMore(.character(...)) 规则的优化代码路径,我们可以重写字符串 Consumer 以利用此优化,如下所示

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

由于典型字符串中的大多数字符都不是 \ 或 ",因此这将运行得更快,因为它可以在每个转义序列之间有效地消耗大量非转义字符。

扁平化和丢弃

我们在上面的常用转换部分中提到了 flattendiscard 转换,作为在应用自定义转换之前从解析结果中省略冗余信息的便捷方法。

但是使用 "flatten" 和 "discard" 还可以通过简化解析过程并避免收集和传播不必要的信息(如源偏移量)来提高性能。

如果您打算最终展平匹配结果的给定节点,则最好在 Consumer 本身中使用 flatten 规则来执行此操作,而不是在您的转换函数中使用 Array.joined()。唯一不能这样做的情况是,如果某些子 Consumer 需要应用自定义转换,因为通过展平节点树,您会删除转换中引用节点所需的标签。

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

注意: 转换规则是分层应用的,因此如果父 Consumer 已经应用了 flatten,则单独应用于该 Consumer 的子 Consumer 不会获得进一步的性能提升。

示例项目

Consumer 包含许多示例项目,以演示该框架

JSON

JSON 示例项目实现了 JSON 解析器,以及将其转换为 Swift 数据的转换函数。

REPL

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

您在 REPL 中键入的每一行都将独立评估,结果将打印在控制台中。要在表达式之间共享值,您可以使用标识符名称后跟 = 和表达式来定义变量,例如

foo = (5 + 6) + 7

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

此示例演示了许多高级技术,例如互递归 Consumer 规则、运算符优先级和使用 not() 的负向先行断言。