swift-parsing

CI Slack

一个用于将非结构化数据转换为结构化数据的库,专注于组合、性能、通用性和可逆性


了解更多

这个库是在 Point-Free 上的许多剧集中设计的,Point-Free 是一个探索函数式编程和 Swift 语言的视频系列,由 Brandon WilliamsStephen Celis 主持。 您可以在这里观看所有剧集。

video poster image

动机

解析是编程中一个非常普遍的问题。我们可以将解析定义为尝试将非结构化数据转换为结构化数据。Swift 标准库附带了许多我们每天都在使用的解析器。例如,IntDouble 甚至 Bool 上都有初始化器,它们尝试从字符串中解析数字和布尔值

Int("42")          // 42
Int("Hello")       // nil

Double("123.45")   // 123.45
Double("Goodbye")  // nil

Bool("true")       // true
Bool("0")          // nil

还有像 JSONDecoderPropertyListDecoder 这样的类型,它们尝试从数据中解析符合 Decodable 协议的类型

try JSONDecoder().decode(User.self, from: data)
try PropertyListDecoder().decode(Settings.self, from: data)

虽然解析器在 Swift 中无处不在,但 Swift 没有关于解析的整体方案。相反,我们通常以特别的方式使用许多不相关的初始化器、方法和其他方式来解析数据。这通常会导致代码的可维护性和可重用性降低。

这个库旨在为 Swift 中的解析编写这样一个方案。它引入了一个单一的解析单元,可以以有趣的方式组合,形成大型、复杂的解析器,以可维护的方式解决您需要解决的编程问题。

开始使用

这是库文档“入门指南”文章的节选版本。

假设您有一个字符串,其中包含一些您想要解析为 User 数组的用户数据

var input = """
1,Blob,true
2,Blob Jr.,false
3,Blob Sr.,true
"""

struct User {
  var id: Int
  var name: String
  var isAdmin: Bool
}

一种朴素的方法是嵌套使用 .split(separator:),然后再做一些额外的工作将字符串转换为整数和布尔值

let users = input
  .split(separator: "\n")
  .compactMap { row -> User? in
    let fields = row.split(separator: ",")
    guard
      fields.count == 3,
      let id = Int(fields[0]),
      let isAdmin = Bool(String(fields[2]))
    else { return nil }

    return User(id: id, name: String(fields[1]), isAdmin: isAdmin)
  }

这段代码不仅有点混乱,而且效率低下,因为我们为 .split 分配了数组,然后立即丢弃这些值。

更直接和高效的方法是描述如何从输入的开头消耗位,并将其转换为用户。这正是这个解析器库擅长的事情 😄。

我们可以从描述解析单行意味着什么开始,首先从字符串的前面解析一个整数,然后解析一个逗号。我们可以通过使用 Parse 类型来做到这一点,Parse 类型充当描述您想要依次运行以从输入中消耗的解析器列表的入口点

let user = Parse(input: Substring.self) {
  Int.parser()
  ","
}

请注意,这个解析库非常通用,允许将任何类型的输入解析为任何类型的输出。因此,我们有时需要指定解析器可以处理的确切输入类型,在本例中为子字符串。

这已经可以消耗输入的开头

try user.parse("1,")  // 1

接下来,我们想要获取用户姓名之前的所有内容,直到下一个逗号,然后消耗逗号

let user = Parse(input: Substring.self) {
  Int.parser()
  ","
  Prefix { $0 != "," }
  ","
}

然后我们想要获取行尾的布尔值,表示用户的管理员状态

let user = Parse(input: Substring.self) {
  Int.parser()
  ","
  Prefix { $0 != "," }
  ","
  Bool.parser()
}

目前,这将从输入中解析一个元组 (Int, Substring, Bool),我们可以对其进行 .map 以将其转换为 User

let user = Parse(input: Substring.self) {
  Int.parser()
  ","
  Prefix { $0 != "," }
  ","
  Bool.parser()
}
.map { User(id: $0, name: String($1), isAdmin: $2) }

为了使我们正在解析的数据更加突出,我们可以将转换闭包作为 Parse 的第一个参数传递

let user = Parse(input: Substring.self) {
  User(id: $0, name: String($1), isAdmin: $2)
} with: {
  Int.parser()
  ","
  Prefix { $0 != "," }
  ","
  Bool.parser()
}

或者,我们可以通过首先将 Prefix 解析器的输出从 Substring 转换为 String,以 point-free 风格将 User 初始化器传递给 Parse

let user = Parse(input: Substring.self, User.init(id:name:isAdmin:)) {
  Int.parser()
  ","
  Prefix { $0 != "," }.map(String.init)
  ","
  Bool.parser()
}

这足以从输入字符串中解析单个用户

try user.parse("1,Blob,true")
// User(id: 1, name: "Blob", isAdmin: true)

要从输入中解析多个用户,我们可以使用 Many 解析器多次运行用户解析器

let users = Many {
  user
} separator: {
  "\n"
}

try users.parse(input)
// [User(id: 1, name: "Blob", isAdmin: true), ...]

现在,此解析器可以处理整个用户文档,并且代码比使用 .split.compactMap 的版本更简单、更直接。

更棒的是,它性能更高。我们为这两种解析风格编写了基准测试.split 风格的解析速度慢两倍以上

name                             time        std        iterations
------------------------------------------------------------------
README Example.Parser: Substring 3426.000 ns ±  63.40 %     385395
README Example.Ad hoc            7631.000 ns ±  47.01 %     169332
Program ended with exit code: 0

此外,如果您愿意针对 UTF8View 而不是 Substring 编写解析器,则可以获得更高的性能,速度提高一倍以上

name                             time        std        iterations
------------------------------------------------------------------
README Example.Parser: Substring 3693.000 ns ±  81.76 %     349763
README Example.Parser: UTF8      1272.000 ns ± 128.16 %     999150
README Example.Ad hoc            8504.000 ns ±  59.59 %     151417

我们还可以将这些时间与 Apple Foundation 提供给我们的工具进行比较:Scanner。它是一种允许您从字符串开头消耗以生成值的类型,并提供比使用 .split 更友好的 API

var users: [User] = []
while scanner.currentIndex != input.endIndex {
  guard
    let id = scanner.scanInt(),
    let _ = scanner.scanString(","),
    let name = scanner.scanUpToString(","),
    let _ = scanner.scanString(","),
    let isAdmin = scanner.scanBool()
  else { break }

  users.append(User(id: id, name: name, isAdmin: isAdmin))
  _ = scanner.scanString("\n")
}

但是,Scanner 风格的解析速度比上面编写的子字符串解析器慢 5 倍以上,比 UTF-8 解析器慢 15 倍以上

name                             time         std        iterations
-------------------------------------------------------------------
README Example.Parser: Substring  3481.000 ns ±  65.04 %     376525
README Example.Parser: UTF8       1207.000 ns ± 110.96 %    1000000
README Example.Ad hoc             8029.000 ns ±  44.44 %     163719
README Example.Scanner           19786.000 ns ±  35.26 %      62125

我们可以更进一步。只需做一个小小的改动,我们就可以将解析器变成打印器

-let user = Parse(User.init(id:name:isAdmin:)) {
+let user = ParsePrint(.memberwise(User.init(id:name:isAdmin:))) {
   Int.parser()
   ","
   Prefix { $0 != "," }.map(String.init)
   ","
   Bool.parser()
 }

 let users = Many {
   user
 } separator: {
   "\n"
 }

通过这一处改动,我们现在可以将用户数组打印回字符串

users.print([
  User(id: 1, name: "Blob", isAdmin: true),
  User(id: 2, name: "Blob Jr.", isAdmin: false),
  User(id: 3, name: "Blob Sr.", isAdmin: true),
])
// 1,Blob,true
// 2,Blob Jr.,false
// 3,Blob Sr.,true

这就是解析和打印简单字符串格式的基础知识,但是还有更多的运算符和技巧需要学习,以便高效地解析更大的输入。阅读文档以更深入地了解解析器-打印器的概念,并查看基准测试以获取更多真实解析场景的示例。

基准测试

该库附带一个基准测试可执行文件,它不仅演示了库的性能,还提供了各种解析示例

这些是我们目前运行基准测试时获得的时间

MacBook Pro (16-inch, 2021)
Apple M1 Pro (10 cores, 8 performance and 2 efficiency)
32 GB (LPDDR5)

name                                         time            std        iterations
----------------------------------------------------------------------------------
Arithmetic.Parser                                6166.000 ns ±  10.73 %     228888
BinaryData.Parser                                 208.000 ns ±  39.64 %    1000000
Bool.Bool.init                                     41.000 ns ±  84.71 %    1000000
Bool.Bool.parser                                   42.000 ns ±  87.86 %    1000000
Bool.Scanner.scanBool                             916.000 ns ±  30.55 %    1000000
Color.Parser                                      208.000 ns ±  28.34 %    1000000
CSV.Parser                                    3675250.000 ns ±   1.16 %        380
CSV.Ad hoc mutating methods                    651333.000 ns ±   1.00 %       2143
Date.Parser                                      3500.000 ns ±   5.65 %     238924
Date.DateFormatter                              23542.000 ns ±   5.50 %      58766
Date.ISO8601DateFormatter                       29041.000 ns ±   3.31 %      48028
HTTP.HTTP                                       10250.000 ns ±   6.24 %     135657
JSON.Parser                                     38167.000 ns ±   3.26 %      36423
JSON.JSONSerialization                           1792.000 ns ±  54.14 %     753770
Numerics.Int.init                                   0.000 ns ±    inf %    1000000
Numerics.Int.parser                                83.000 ns ±  67.28 %    1000000
Numerics.Scanner.scanInt                          125.000 ns ±  38.65 %    1000000
Numerics.Digits                                    83.000 ns ±  65.03 %    1000000
Numerics.Comma separated: Int.parser         15364583.000 ns ±   0.63 %         91
Numerics.Comma separated: Scanner.scanInt    50654458.500 ns ±   0.30 %         28
Numerics.Comma separated: String.split       15452542.000 ns ±   1.30 %         90
Numerics.Double.init                               42.000 ns ± 152.57 %    1000000
Numerics.Double.parser                            166.000 ns ±  45.23 %    1000000
Numerics.Scanner.scanDouble                       167.000 ns ±  42.36 %    1000000
Numerics.Comma separated: Double.parser      18539833.000 ns ±   0.57 %         75
Numerics.Comma separated: Scanner.scanDouble 55239167.000 ns ±   0.46 %         25
Numerics.Comma separated: String.split       17636000.000 ns ±   1.34 %         78
PrefixUpTo.Parser: Substring                   182041.000 ns ±   1.78 %       7643
PrefixUpTo.Parser: UTF8                         40417.000 ns ±   2.71 %      34379
PrefixUpTo.String.range(of:)                    49792.000 ns ±   2.70 %      27891
PrefixUpTo.Scanner.scanUpToString               53959.000 ns ±   3.87 %      25745
Race.Parser                                     59583.000 ns ±   2.78 %      23333
README Example.Parser: Substring                 2834.000 ns ±  12.87 %     488264
README Example.Parser: UTF8                      1291.000 ns ±  22.65 %    1000000
README Example.Ad hoc                            2459.000 ns ±  20.61 %     561930
README Example.Scanner                          12084.000 ns ±   5.53 %     115388
String Abstractions.Substring                  472083.500 ns ±   1.38 %       2962
String Abstractions.UTF8                       196041.000 ns ±   3.38 %       7059
UUID.UUID.init                                    208.000 ns ±  43.60 %    1000000
UUID.UUID.parser                                  167.000 ns ±  42.00 %    1000000
Xcode Logs.Parser                             4511625.500 ns ±   0.58 %        226

文档

发行版和 main 分支的文档可在此处获得

其他版本

社区

如果您想讨论这个库,或者对如何使用它来解决特定问题有疑问,您可以在许多地方与 Point-Free 爱好者们进行讨论

其他库

Swift 社区中还有一些其他解析库,您可能也会感兴趣

此库中的打印功能灵感来自 Tillmann Rendel 和 Klaus Ostermann 的论文 “可逆语法描述:统一解析和美观打印”

许可证

本库在 MIT 许可证下发布。有关详细信息,请参阅 LICENSE