自定义 Dump

CI

一组用于调试、比较和测试应用程序数据结构的工具。

动机

Swift 自带一个很棒的工具,可以将任何值的内容转储为字符串,它被称为 dump。它将值的所有字段和子字段打印成树状描述

struct User {
  var favoriteNumbers: [Int]
  var id: Int
  var name: String
}

let user = User(
  favoriteNumbers: [42, 1729],
  id: 2,
  name: "Blob"
)

dump(user)
▿ User
  ▿ favoriteNumbers: 2 elements
    - 42
    - 1729
  - id: 2
  - name: "Blob"

这非常有用,并且非常适合构建调试工具,以可视化应用程序运行时值中保存的数据,但有时其输出并不理想。

例如,转储字典会导致冗长的输出,这可能难以阅读(另请注意,键是无序的)

dump([1: "one", 2: "two", 3: "three"])
▿ 3 key/value pairs
  ▿ (2 elements)
    - key: 2
    - value: "two"
  ▿ (2 elements)
    - key: 3
    - value: "three"
  ▿ (2 elements)
    - key: 1
    - value: "one"

同样,枚举也具有非常冗长的输出

dump(Result<Int, Error>.success(42))
▿ Swift.Result<Swift.Int, Swift.Error>.success
  - success: 42

当处理深度嵌套的结构时,它变得更难以阅读

dump([1: Result<User, Error>.success(user)])
▿ 1 key/value pair
  ▿ (2 elements)
    - key: 1
    ▿ value: Swift.Result<User, Swift.Error>.success
      ▿ success: User
        ▿ favoriteNumbers: 2 elements
          - 42
          - 1729
        - id: 2
        - name: "Blob"

有时 dump 根本不打印有用的信息,例如从 Objective-C 导入的枚举

import UserNotifications

dump(UNNotificationSetting.disabled)
- __C.UNNotificationSetting

因此,虽然 dump 函数可能很方便,但它通常是一个过于粗糙的工具。这就是 customDump 函数的动机。

customDump

customDump 函数模拟 dump 的行为,但提供了更精细的嵌套结构输出,优化了可读性。例如,结构体以更接近 Swift 中结构体语法的格式转储,数组以每个元素的索引转储

import CustomDump

customDump(user)
User(
  favoriteNumbers: [
    [0]: 42,
    [1]: 1729
  ],
  id: 2,
  name: "Blob"
)

字典以更紧凑的格式转储,模仿 Swift 的语法,并自动对键进行排序

customDump([1: "one", 2: "two", 3: "three"])
[
  1: "one",
  2: "two",
  3: "three"
]

同样,枚举也以更紧凑、更易读的格式转储

customDump(Result<Int, Error>.success(42))
Result.success(42)

深度嵌套的结构具有简化的树状结构

customDump([1: Result<User, Error>.success(user)])
[
  1: Result.success(
    User(
      favoriteNumbers: [
        [0]: 42,
        [1]: 1729
      ],
      id: 2,
      name: "Blob"
    )
  )
]

diff

使用 customDump 函数的输出,我们可以构建一种非常轻量级的方式,以文本方式比较 Swift 中的任意两个值

var other = user
other.favoriteNumbers[1] = 91

print(diff(user, other)!)
  User(
    favoriteNumbers: [
      [0]: 42,
-     [1]: 1729
+     [1]: 91
    ],
    id: 2,
    name: "Blob"
  )

此外,还完成了额外的工作,以最大限度地减少结构中未更改部分的大小差异,例如大型集合中单个元素的更改

let users = (1...5).map {
  User(
    favoriteNumbers: [$0],
    id: $0,
    name: "Blob \($0)"
  )
}

var other = users
other.append(
  .init(
    favoriteNumbers: [42, 1729],
    id: 100,
    name: "Blob Sr."
  )
)

print(diff(users, other)!)
  [
    … (4 unchanged),
+   [4]: User(
+     favoriteNumbers: [
+       [0]: 42,
+       [1]: 1729
+     ],
+     id: 100,
+     name: "Blob Sr."
+   )
  ]

对于真实的用例,我们修改了 Apple 的 Landmarks 教程应用程序,以打印收藏地标前后的状态

  [
    [0]: Landmark(
      id: 1001,
      name: "Turtle Rock",
      park: "Joshua Tree National Park",
      state: "California",
      description: "This very large formation lies south of the large Real Hidden Valley parking lot and immediately adjacent to (south of) the picnic areas.",
-     isFavorite: true,
+     isFavorite: false,
      isFeatured: true,
      category: Category.rivers,
      imageName: "turtlerock",
      coordinates: Coordinates(…)
    ),
    … (11 unchanged)
  ]

expectNoDifference

XCTest 的 XCTAssertEqual 和 Swift Testing 的 #expect(_ == _) 都允许您断言两个值相等,如果它们不相等,测试套件将失败并显示消息

var other = user
other.name += "!"

XCTAssertEqual(user, other)
#expect(user == other)
XCTAssertEqual failed: ("User(favoriteNumbers: [42, 1729], id: 2, name: "Blob")") is not equal to ("User(favoriteNumbers: [42, 1729], id: 2, name: "Blob!")")
Expectation failed: (user → User(favoriteNumbers: [42, 1729], id: 2, name: "Blob")) == (other → User(favoriteNumbers: [42, 1729], id: 2, name: "Blob!"))

不幸的是,这些失败消息很难在视觉上解析和理解。需要花一些时间在消息中查找才能看到唯一的区别是名称末尾的感叹号。如果类型更复杂,由嵌套结构和大型集合组成,则问题会变得更糟。

这个库还附带一个 expectNoDifference 函数来缓解这些问题。它的工作方式类似于 XCTAssertEqual#expect(_ == _),除了失败消息使用格式良好的差异来准确显示两个值之间的差异

expectNoDifference(user, other)
expectNoDifference failed: …

  User(
    favoriteNumbers: […],
    id: 2,
-   name: "Blob"
+   name: "Blob!"
  )

(First: −, Second: +)

expectDifference

此函数提供 expectNoDifference 的逆函数:它通过评估给定操作之前和之后的给定表达式,然后比较结果,来断言值具有一组更改。

例如,给定一个非常简单的计数器结构,我们可以针对其递增功能编写测试

struct Counter {
  var count = 0
  var isOdd = false
  mutating func increment() {
    self.count += 1
    self.isOdd.toggle()
  }
}

var counter = Counter()
expectDifference(counter) {
  counter.increment()
} changes: {
  $0.count = 1
  $0.isOdd = true
}

如果 changes 没有详尽地描述所有更改的字段,则断言将失败。

通过省略操作,您可以通过仅描述要在 changes 闭包中断言的字段,来编写针对值的“非详尽”断言

counter.increment()
expectDifference(counter) {
  $0.count = 1
  // Don't need to further describe how `isOdd` has changed
}

自定义

Custom Dump 提供了几种重要的方法来自定义数据类型的转储方式:CustomDumpStringConvertibleCustomDumpReflectableCustomDumpRepresentable

CustomDumpStringConvertible

CustomDumpStringConvertible 协议提供了一种将类型转换为原始字符串的简单方法,用于转储。它最适合具有简单、非嵌套内部表示的类型,并且通常其输出适合单行,例如日期、UUID、URL 等

extension URL: CustomDumpStringConvertible {
  public var customDumpDescription: String {
    "URL(\(self.absoluteString))"
  }
}

customDump(URL(string: "https://www.pointfree.co/")!)
URL(https://www.pointfree.co/)

Custom Dump 也在内部使用此协议,为从 Objective-C 导入的枚举提供更有用的输出

import UserNotifications

print("dump:")
dump(UNNotificationSetting.disabled)
print("customDump:")
customDump(UNNotificationSetting.disabled)
dump:
- __C.UNNotificationSetting
customDump:
UNNotificationSettings.disabled

遇到 Objective-C 枚举打印不美观?请参阅本 README 的 贡献 部分,以帮助提交修复。

CustomDumpReflectable

CustomDumpReflectable 协议提供了一种更全面的方式,将类型转储为更结构化的输出。它允许您构造一个自定义镜像,描述应转储的结构。您可以省略、添加和替换字段,甚至更改结构转储方式的“显示样式”。

例如,假设您有一个结构体表示状态,该状态在内存中保存一个安全令牌,该令牌永远不应写入您的日志。您可以通过提供省略此字段的镜像,从 customDump 中省略令牌

struct LoginState: CustomDumpReflectable {
  var username: String
  var token: String

  var customDumpMirror: Mirror {
    .init(
      self,
      children: [
        "username": self.username,
        // omit token from logs
      ],
      displayStyle: .struct
    )
  }
}

customDump(
  LoginState(
    username: "blob",
    token: "secret"
  )
)
LoginState(username: "blob")

就像那样,没有令牌数据将被写入转储。

CustomDumpRepresentable

CustomDumpRepresentable 协议允许您返回任何值以进行转储。这对于展平包装器类型的转储表示可能很有用。例如,类型安全的标识符可能希望直接转储其原始值

struct ID: RawRepresentable {
  var rawValue: String
}

extension ID: CustomDumpRepresentable {
  var customDumpValue: Any {
    self.rawValue
  }
}

customDump(ID(rawValue: "deadbeef")
"deadbeef"

贡献

Apple 生态系统中有很多类型的字符串格式不佳。特别是,所有从 Objective-C 导入的枚举都转储为不是很有用的字符串

import UserNotifications

dump(UNNotificationSetting.disabled)
- __C.UNNotificationSetting

因此,我们已使 许多 Apple 的类型符合 CustomDumpStringConvertible 协议,以便它们打印出更合理的描述。如果您遇到打印信息无用的类型,我们很乐意接受 PR,使这些类型符合 CustomDumpStringConvertible

安装

您可以通过将 Custom Dump 添加为软件包依赖项,将其添加到 Xcode 项目。

https://github.com/pointfreeco/swift-custom-dump

如果您想在 SwiftPM 项目中使用 Custom Dump,只需将其添加到 Package.swift 中的 dependencies 子句中即可

dependencies: [
  .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0")
]

文档

Custom Dump API 的最新文档可在此处 获取

其他库

许可证

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