Swift Practices & Perls (2)


下文部分内容成于2018年


本文继续上一篇Swift Practices & Perls (1), 记录标准库学习过程中的技巧.

标准库拾遗

Swift 标准库提供的 API 强大而优雅. 这里归纳总结了一些网上不常见的点.

String和其他类型之间的转换

一方面, 标准库中存在多个 protocol, 来关联自定义类型和String. 通常, 在需要按文本格式打印某个对象, 或涉及到用文本进行序列化/反序列化时, 可能会使用到这些 protocol.

  • ExpressibleByStringLiteral (旧名StringLiteralConvertible). 遵循该 protocol 的类型, 支持使用 string literal 初始化.
  • CustomStringConvertibleCustomDebugStringConvertible. 支持最基本的输出到一个String.
  • LosslessStringConvertible. 该 protocol 继承自 CustomStringConvertible. 与后者不同的是, 该 protocol 的遵循者必须提供和String之间双向的精确转化. 因此某种意义上说, 遵循本 protocol 的类型转化成的String可用范围更广, 不限于 debug/print. 因为不用担心2个不同的对象对应到同一个String.
  • TextOutputStreamable. 支持按流式增量添加到已有的 stream 的末尾. 因为支持增量添加, 所以遵循者实际实现时, 可能和其他输出String的 protocol 相比, 内存占用上可以有一些优化.

另一方面, String本身也存在好几种 initializer. 标准库建议我们使用String的各种初始化方法, 而不要直接调用上面各个 protocol 定义的方法.

  • init<T>(_ value: T, radix: Int = default, uppercase: Bool = default). 把Int按某个进制转换成String.
  • init<T>(_ value: T) where T : LosslessStringConvertible.
  • init<Subject>(describing: Subject). 优先使用TextOutputStreamable的实现, 最后使用CustomDebugStringConvertible的实现, 偏向精准性.
  • init<Subject>(reflecting: Subject). 优先使用CustomDebugStringConvertible, 最后使用TextOutputStreamable, 偏向debug.

Comparisons

在STL中, 并非所有可比的对象都直接遵循Comparable, 但一定要求最终的底层 element 对象是遵循的.
对于不直接遵循Comparable的类型来说, 他们比较各自对象的方式无外乎通过 operator 或 method.

  • StringProtocol遵循者中, String直接遵循Comparable; 但SubString没有, 可通过 <运算符比较.
  • 遵循Sequence的类型, 如果本身不遵循Comparable, 但Element实际遵循Comparable, 那么可以通过lexicographicallyPrecedes进行字典比较.
  • SE-0283被合入 Swift 前(不早于Swift 5.3), 由多个分量组成的 tuple, 本身是不遵循Comparable的. 但是标准库为含有2-6个分量的 tuple, 手动重载了<操作符, 允许我们直接使用<进行字典比较.

Lazy or not

当我们要给一个Sequence/Collection增加一个方法的时候, 要考虑是否要把这个方法设计成 lazy 的. 这里的 lazy 指的是, 当方法执行时, 不实际开辟内存创建一个新的集合, 或实际遍历整个序列, 而是把相关待执行的操作/规则先保留, 等到后面不得不执行的时候再执行.

当这个方法返回一个新的Sequence/Collection时, 这里要考虑的 lazy 的含义还是两重的. 一方面要考虑当前方法是否 lazy, 另一方面要考虑对返回的集合进一步执行非 lazy 方法时, 是否 lazy.

标准库给这个问题作出了很好的示范.
有一些方法本身一定是 lazy 的, 返回的新对象的后续操作是否 lazy 要看其他条件. 例如:

  • BidirectionalCollection.reversed(). 方法本身在O(1)O(1)内即完成.
  • Sequence.enumerated(). 方法本身在O(1)O(1)内即完成.
  • Sequence.lazy. 方法本身在O(1)O(1)内即完成. 并且返回一个遵循了LazySequenceProtocol的对象. 从而保证后续任意操作均为 lazy.
    另一些方法, 本身是否 lazy 要看Self, 在一般情况下是 imperative 的, 但当Self: LazySequenceProtocol时, 是 lazy 的. 例如:
  • Sequence.map()
  • Sequence.filter()

这里有一个关键的 protocol LazySequenceProtocol. 这个 protocol 在体系中起到语义标记作用, 一个遵循了Sequence的类型是否进一步遵循LazySequenceProtocol, 会导致在该类型上调用方法时, 产生不同的结果.

现在可以回答一开始的问题. 我们自己写的方法, 如果操作是有消耗性的, 或者耗时很长, 可以考虑把他定义成 lazy 的. 实现时也要遵循下面这个约定:

  • 在默认情况下, 返回一个以Self作为模板参数的泛型结构. 这个结构只是一个 wrapper, 给Self作了一层浅的封装, 这个方法本身就是 lazy 即可.
  • Self: LazySequenceProtocol时, 返回一个遵循了LazySequenceProtocol的 wrapper, 保证后续操作可以继续用 lazy 操作串起来.
  • 在 Swift 支持 conditional conformance 后, 上述两点可以很方便地用同一个 wrapper 类型统一起来.

需要注意的是, 正如标准库开发者在 Swift Forums 上指出过: 对一个方法, 并不应该不加思考地就决定把他设置成 lazy 的. 因为 lazy 只是把当前的遍历行为延迟到后面. 如果后面反复执行时, 反而可能触发多次遍历, 还不如把这个遍历当前就消耗掉.
例如: 如果对于filter()方法, 我们无条件的返回一个 lazy 的 wrapper. 那么会产生令人惊讶的后果:

1
2
3
4
5
// bad case
let range = 1..<10000
let array = range.myFilter({ $0 % 2 == 0 }) // 假如这个 myFilter 在 O(1) 里返回一个 wrapper
print(array.count) // 触发一次 1..<10000 的遍历
print(array.count) // 又触发一次 1..<10000 的遍历

这显然是我们不想看到的. 对这样的方法, 我们应该默认把他按 imperative 的方式实现.
而只在Self:LazySequenceProtocol的情形下, 才进行lazy的行为. 注意到这个情况下, 因为调用链上游某处显式出现了一次.lazy, 所以这个责任已经转嫁到调用者, 调用者应该十分清楚 code 的行为, 并不会产生 surprise.

1
2
3
4
5
// good case
let range = 1..<10000
let array = range.lazy.filter({ $0 % 2 == 0 })
print(array.count) // 触发一次 1..<10000 的遍历
print(array.count) // 又触发一次 1..<10000 的遍历

Compiler-synthesized conformance

给我们的自定义类, 在某些情况下直接添加 protocol conformance, 不提供具体实现, compiler 会生成默认的实现. 涉及到的 protocol 有:

各个 protocol 支持自动遵循的自定义类型有不一样的要求:

  • ComparableCaseIterable只作用于 enum.
  • EquatableHashable只作用于 enum 和 struct.
  • Codable只作用于 struct 和 class.

这些自定义遵循都有一些共同的特点/要求:

  • 要求类型的成员, 按树形结构递归满足遵循条件. 例如:
    • 遵循Codable类型的各个属性的类型本身要遵循Codable.
    • 遵循Comparable类型的 enum, 如果某个 case 携带了 associated value, 那么该 value 的类型也要遵循Comparable.
  • 在以前: 必须是定义类型时立即 conform 某个 protocol 才可以触发编译器自动合成代码, 在 extension 中 conform 是无效的. 现在, 允许在和自定义类型的定义同一个 source file 下的 extension 中进行 conform. 这也是为了和 conditional conformance 进行配合.
  • 这里列出的各个 protocol 的命名规则是统一的: “xxx+able”, “a"前没有"e”. 但须注意, 这个规则只是针对上面列出的几个protocol, STL 里当然也存在按"+e+able"命名的 protocol (比如Strideable, 目前还没有改动命名).

依赖 compiler-synthesized 代码的逻辑注入技巧

需要明确, 这几个 protocol 开发者一行代码不写就 work 的背后原理, 并不是命中了一个 default 实现的的 protocol extension implementation, 而是由编译器实打实地合成了实现代码. 编译器合成的实现和我们手动输入的代码有相同地位, 也会参与重载决议等等编译阶段. 因此, 我们可以利用这一点, 给这个看似被编译器定死, 而无法扩展的功能, 注入一些自定义逻辑.

例如: Codable默认需要遵循者实现func hash(into hasher: inout Hasher), 我们实现时需要不停调用Hasher.combine(). 如果这个实现由编译器代替我们完成, 编译器实际上也是做了相同的工作. 而编译器每次调用Hasher.combine()实际上和我们手动编写时的调用完全无异, 每一次调用都会参与重载决议. 因此, 虽然Hasher是标准库提供的类, 但我们如果给Hasher增加几个重载方法, 并在我们自己的 module 中设法使这几个重载方法被命中, 那就相当于绕过了标准库的实现.

下面的代码, 展示了这样一个功能: 我们想看看, 我们的 module 中所有Optional<Int>被 hash 的次数.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct Model: Hashable {
let a: Int?
}

extension Hasher {
// 给标准库类型增加的 extension, 这个方法比 Hasher 已有的2个方法更加限定, 因此命中时优先级更高
// 实际上, 编译器给 Model 生成的 Hashable 实现代码里, 就会命中这个 combine, 而非 Hasher 自带的 combine
mutating func combine(_ value: Int?) {
print("we are hashing an optional int here!!!")
combine(AnyHashable(value))
}
}

// 用2个 Model 类型的 key, 创建 Dictionary
let dict = [
Model(a: 3): 3,
Model(a: 4): 4
]
// console output:
// we are hashing an optional int here!!!
// we are hashing an optional int here!!!

当然这里的例子比较简单, 对于比较复杂的编译器支持生成实现代码的 protocol, 例如Encodable/Decodable, 可以有更花式的做法. 例如这边的例子, 就是通过增加优先级高的重载方法, 来增强现有Codable的自定义能力.

Tricks & Patterns

Implementing “ghost operator”

问题背景

在 Swift Standard Library 中某些 Collection 实现了一种 subscript, 接受的实参是..., 生成一个涵盖整个 collection 范围的SubSequence.

1
2
3
let array = [1, 2, 3, 4, 5]
// sub 的类型是 ArraySlice
let sub = array[...]

当然...在现有的 Swift 标识符规则下, 只能是一个 operator, 不可能是一个常量/变量. 因此显然这个 subscript 的入参类型是一个 function type.

问题目标

我们考虑更一般的情况, 假设我们想要实现这样一个 subscript.

1
2
3
4
5
6
7
8
9
struct Special {
// 类型暂时不确定的一个subscript
subscript(/*SpecialOpearator*/) -> Void {
print("SpecialOpearator called")
}
}

// 想要能被如此使用
Special()[...]

为了能方便的使用, 我们的实现需要保证以下几点:

  • [...]这个 subscript 调用可以完美解析, 不会出现找不到 subscript 方法, 或者出现二义性.
  • 在实现过程中, 暴露出来的无关接口越少越好.
  • 这个 operator 只能是用在我们这个 subscript 中, 任何其他方式都不能被调用, 如果用户用...时都意识不到这是一个自定义的 operator, 似乎只是一个 magical special identifier, 那就更好不过了.

实现

(1) 使用 case-less enum 建立一个局部作用域, 并添加一个无实现的 operator 方法

1
2
3
4
5
enum _ThreeDot {
static postfix func ... (rhs: _ThreeDot) {
fatalError()
}
}

(2) 使用上述 operator 方法的签名作为入参类型, 定义我们想要的 subscript

1
2
3
4
5
6
typealias ThreeDot = (_ThreeDot) -> Void
struct Special {
subscript(_ op: ThreeDot) -> Void {
print("SpecialOpearator called")
}
}

此时就可以按想要的方式调用Special()[...].

实现过程中的要点主要有:

  • 我们按标准的 Swift custom operator 的定义方法, 将方法定义在一个宿主 enum 里, 并且使这个方法接受一个宿主类型的参数, 可以有效避免... operator 和相同作用域内其他同名 operator 的冲突.
  • 我们定义的 enum, 是 non-inhabitant type, 只起到命名空间的作用. 并且由于在代码中的任何地方都不可能建立一个这个 enum 类型的实例. 所以...永远不可能被真正调用到: 调用需要的实参永远不可能被实例化出来嘛. 这个 operator 只能"相当"于一个 identifier 被引用, 而不会被调用.

可以看到, 这个技巧不限于 Swift 已经定义的..., 可以用在任何其他名字的自定义 operator; 引用地点也不一定非得是 subscript, 任何 function/method 中都是可以的.