86e93393d221ac8dcbeaeaacf35dc5fc
不同角度看问题 - 从 Codable 到 Swift 元编程

起源

前几天看到同事的一个 P-R,里面有将一个类型转换为字典的方法。在我们所使用的 API 中,某些方法需要接受 JSON 兼容的字典 (也就是说,字典中键值对的 value 只能是数字,字符串,布尔值,以及包含它们的嵌套字典或者数组等),因为项目开始是在好几年前了,所以一直都是在需要的时候使用下面这样手写生成字典的方法:

  • struct Cat {
  • let name: String
  • let age: Int
  • func toDictionary() -> [String: Any] {
  • return ["name": name, "age": age]
  • }
  • }
  • let kitten = Cat(name: "kitten", age: 2)
  • kitten.toDictionary()
  • // ["name": "kitten", "age": 2]

显然这是很蠢的做法:

  1. 对于每一个需要处理的类型,我们都需要 toDictionary() 这样的模板代码;
  2. 每次进行属性的更改或增删,都要维护该方法的内容;
  3. 字典的 key 只是普通字符串,很可能出现 typo 错误或者没有及时根据类型定义变化进行更新的情况。

对于一个有所追求的项目来说,解决这部分遗留问题具有相当的高优先级。

Codable

在 Swift 4 引入 Codable 之后,我们有更优秀的方式来做这件事:那就是将 Cat 声明为 Codable (或者至少声明为 Encodable - 记住 Codable 其实就是 Decodable & Encodable),然后使用相关的 encoder 来进行编码。不过 Swift 标准库中并没有直接将一个对象编码为字典的编码器,我们可以进行一些变通,先将需要处理的类型声明为 Codable,然后使用 JSONEncoder 将其转换为 JSON 数据,最后再从 JSON 数据中拿到对应的字典:

  • struct Cat: Codable {
  • let name: String
  • let age: Int
  • }
  • let kitten = Cat(name: "kitten", age: 2)
  • let encoder = JSONEncoder()
  • do {
  • let data = try encoder.encode(kitten)
  • let dictionary = try JSONSerialization.jsonObject(with: data, options: [])
  • // ["name": "kitten", "age": 2]
  • } catch {
  • print(error)
  • }

这种方式也是同事提交的 P-R 中所使用的方式。我个人认为这种方法已经足够优秀了,它没有添加任何难以理解的部分,我们只需要将 encoder 在全局进行统一的配置,然后用它来对任意 Codable 进行编码即可。唯一美中不足的是,JSONEncoder 本身其实在内部就是先编码为字典,然后再从字典转换为数据的。在这里我们又“多此一举”地将数据转换回字典,稍显浪费。但是在非瓶颈的代码路径上,这一点性能损失完全可以接受的。

如果想要追求完美,那么我们可能需要仿照 _JSONEncoder 重新实现 KeyedEncodingContainer 的部分,来将 Encodable 对象编码到容器中 (因为我们只需要编码为字典,所以可以忽略掉 unkeyedContainersingleValueContainer 的部分)。整个过程不会很复杂,但是代码显得有些“啰嗦”。如果你没有自己手动实现过一个 Codable encoder 的话,参照着 _JSONEncoder 的源码实现一个 DictionaryEncoder 对于你理解 Codable 系统的运作和细节,会是很好的练习。不过因为这篇文章的重点并不是 Codable 教学,所以这里就先跳过了。

标准库中要求 Codable 的编码器要满足 Encoder 协议,不过要注意,公开的 JSONEncoder 类型其实并不遵守 Encoder,它只提供了一套易用的 API 封装,并将具体的编码工作代理给一个内部类型 _JSONEncoder,后者实际实现了 Encoder,并负责具体的编码逻辑。

Mirror

Codable 的解决方案已经够好了,不过“好用的方式千篇一律,有趣的解法万万千千”,就这样解决问题也实在有些无聊,我们有没有一些更 hacky 更 cool 更 for fun 一点的做法呢?

当然有,在 review P-R 的时候第一想到的就是 Mirror。使用 Mirror 类型,可以让我们在运行时一窥某个类型的实例的内容,它也是 Swift 中为数不多的与运行时特性相关的手段。Mirror 的最基本的用法如下,你也可以在官方文档中查看它的一些其他定义:

  • struct Cat {
  • let name: String
  • let age: Int
  • }
  • let kitten = Cat(name: "kitten", age: 2)
  • let mirror = Mirror(reflecting: kitten)
  • for child in mirror.children {
  • print("\(child.label!) - \(child.value)")
  • }
  • // 输出:
  • // name - kitten
  • // age - 2

通过访问实例中 mirror.children 的每一个 child,我们就可以得到所有的存储属性的 labelvalue。以 label 为字典键,value 为字典值,我们就能从任意类型构建出对应的字典了。

字典中值的类型

不过注意,这个 child 中的值是以 Any 为类型的,也就是说,任意类型都可以在 child.value 中表达。而我们的需求是构建一个 JSON 兼容的字典,它不能包含我们自定义的 Swift 类型 (对于自定义类型,我们需要先转换为字典的形式)。所以还需要做一些额外的类型保证的工作,这里可以添加一个 DictionaryValue 协议,来表示目标字典能接受的类型:

  • protocol DictionaryValue {
  • var value: Any { get }
  • }

对于 JSON 兼容的字典来说,数字,字符串和布尔值都是可以接受的,它们不需要进行转换,在字典中就是它们自身:

  • extension Int: DictionaryValue { var value: Any { return self } }
  • extension Float: DictionaryValue { var value: Any { return self } }
  • extension String: DictionaryValue { var value: Any { return self } }
  • extension Bool: DictionaryValue { var value: Any { return self } }

严格来说,我们还需要对像是 Int16Double 之类的其他数字类型进行 DictionaryValue 适配。不过对于一个「概念验证」的 demo 来说,上面的定义就足够了。

有了这些,我们就可以进一步对 DictionaryValue 进行协议扩展,让满足它的其他类型通过 Mirror 的方式来构建字典:

  • extension DictionaryValue {
  • var value: Any {
  • let mirror = Mirror(reflecting: self)
  • var result = [String: Any]()
  • for child in mirror.children {
  • // 如果无法获得正确的 key,报错
  • guard let key = child.label else {
  • fatalError("Invalid key in child: \(child)")
  • }
  • // 如果 value 无法转换为 DictionaryValue,报错
  • if let value = child.value as? DictionaryValue {
  • result[key] = value.value
  • } else {
  • fatalError("Invalid value in child: \(child)")
  • }
  • }
  • return result
  • }
  • }

现在,我们就可以将想要转换的类型声明为 DictionaryValue,然后调用 value 属性来获取字典了:

  • struct Cat: DictionaryValue {
  • let name: String
  • let age: Int
  • }
  • let kitten = Cat(name: "kitten", age: 2)
  • print(kitten.value)
  • // ["name": "kitten", "age": 2]

对于嵌套自定义 DictionaryValue 值的其他类型,字典转换也可以正常工作:

  • struct Wizard: DictionaryValue {
  • let name: String
  • let cat: Cat
  • }
  • let wizard = Wizard(name: "Hermione", cat: kitten)
  • print(wizard.value)
  • // ["name": "Hermione", "cat": ["name": "kitten", "age": 2]]

字典中的嵌套数组和字典

上面处理了类型中属性是一般值 (JSON 原始值以及嵌套其他 DictionaryValue 类型) 的情况,不过对于 JSON 中的数组和字典的情况还无法处理 (因为我们还没有让 ArrayDictionary 遵守 DictionaryValue)。对于数组或字典这样的容器中的值,如果这些值满足 DictionaryValue 的话,那么容器本身显然也是 DictionaryValue 的。用代码表示的话类似这样:

  • extension Array: DictionaryValue where Element: DictionaryValue {
  • var value: Any { return map { $0.value } }
  • }
  • extension Dictionary: DictionaryValue where Value: DictionaryValue {
  • var value: Any { return mapValues { $0.value } }
  • }

在这里我们遇到一个非常“经典”的 Swift 的语言限制,那就是在 Swift 4.1 之前还不能写出上面这样的带有条件语句 (也就是 where 从句,ElementValue 满足 DictionaryValue) 的 extension。这个限制在 Swift 4.1 中得到了解决,不过再此之前,我们只能强制做一些变化:

  • extension Array: DictionaryValue {
  • var value: Any { return map { ($0 as! DictionaryValue).value } }
  • }
  • extension Dictionary: DictionaryValue {
  • var value: Any { return mapValues { ($0 as! DictionaryValue).value } }
  • }

这么做我们失去了编译器的保证:对于任意的 ArrayDictionary,我们都将可以调用 value,不过,如果它们中的值不满足 DictionaryValue 的话,程序将会崩溃。当然,实际如果使用的时候可以考虑返回 NSNull(),来表示无法完成字典转换 (因为 null 也是有效的 JSON 值)。

有了数组和字典的支持,我们现在就可以使用 Mirror 的方法来对任意满足条件的类型进行转换了:

  • struct Cat: DictionaryValue {
  • let name: String
  • let age: Int
  • }
  • struct Wizard: DictionaryValue {
  • let name: String
  • let cat: Cat
  • }
  • struct Gryffindor: DictionaryValue {
  • let wizards: [Wizard]
  • }
  • let crooks = Cat(name: "Crookshanks", age: 2)
  • let hermione = Wizard(name: "Hermione", cat: crooks)
  • let hedwig = Cat(name: "hedwig", age: 3)
  • let harry = Wizard(name: "Harry", cat: hedwig)
  • let gryffindor = Gryffindor(wizards: [harry, hermione])
  • print(gryffindor.value)
  • // ["wizards":
  • // [
  • // ["name": "Harry", "cat": ["name": "hedwig", "age": 3]],
  • // ["name": "Hermione", "cat": ["name": "Crookshanks", "age": 2]]
  • // ]
  • // ]

Mirror 很 cool,它让我们可以在运行时探索和列举实例的特性。除了上面用到的存储属性之外,对于集合类型,多元组以及枚举类型,Mirror 都可以对其进行探索。强大的运行时特性,也意味着额外的开销。Mirror 的文档明确告诉我们,这个类型更多是用来在 Playground 和调试器中进行输出和观察用的。如果我们想要以高效的方式来处理字典转换问题,也许应该试试看其他思路。

代码生成

最高效的方式应该还是像一开始我们提到的纯手写了。但是显然这种重复劳动并不符合程序员的美学,对于这种“机械化”和“模板化”的工作,定义模板自动生成代码会是不错的选择。

Sourcery

Sourcery 是一个 Swift 代码生成的开源命令行工具,它 (通过 SourceKitten) 使用 Apple 的 SourceKit 框架,来分析你的源码中的各种声明和标注,然后套用你预先定义的 Stencil 模板 (一种语法和 Mustache 很相似的 Swift 模板语言) 进行代码生成。我们下面会先看一个使用 Sourcery 最简单的例子,来说明如何使用这个工具。然后再针对我们的字典转换问题进行实现。

安装 Sourcery 非常简单,brew install sourcery 即可。不过,如果你想要在实际项目中使用这个工具的话,我建议直接从发布页面下载二进制文件,放到 Xcode 项目目录中,然后添加 Run Script 的 Build Phase 来在每次编译的时候自动生成。

EnumSet

来看一个简单的例子,假设我们在文件夹中有以下源码:

  • // source.swift
  • enum HogwartsHouse {
  • case gryffindor
  • case hufflepuff
  • case ravenclaw
  • case slytherin
  • }

很多时候我们会有想要得到 enum 中所有 case 的集合,以及确定一共有多少个 case 成员的需求。如果纯手写的话,大概是这样的:

  • enum HogwartsHouse {
  • // ...
  • static let all: [HogwartsHouse] = [
  • .gryffindor,
  • .hufflepuff,
  • .ravenclaw,
  • .slytherin
  • ]
  • static let count = 4
  • }

显然这么做对于维护很不友好,没有人能确保时刻记住在添加新的 case 后一定会去更新 allcount。对其他有同样需求的 enum,我们也需要重复劳动。Sourcery 就是为了解决这样的需求而生的,相对于手写 allcount,我们可以定义一个空协议 EnumSet,然后让 HogwartsHouse 遵守它:

top Created with Sketch.