下文部分内容成于2018年
本文继续上一篇Swift Practices & Perls (1), 记录标准库学习过程中的技巧.
标准库拾遗
Swift 标准库提供的 API 强大而优雅. 这里归纳总结了一些网上不常见的点.
String
和其他类型之间的转换
一方面, 标准库中存在多个 protocol, 来关联自定义类型和String
. 通常, 在需要按文本格式打印某个对象, 或涉及到用文本进行序列化/反序列化时, 可能会使用到这些 protocol.
ExpressibleByStringLiteral
(旧名StringLiteralConvertible
). 遵循该 protocol 的类型, 支持使用 string literal 初始化.CustomStringConvertible
和CustomDebugStringConvertible
. 支持最基本的输出到一个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()
. 方法本身在内即完成.Sequence.enumerated()
. 方法本身在内即完成.Sequence.lazy
. 方法本身在内即完成. 并且返回一个遵循了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 | // bad case |
这显然是我们不想看到的. 对这样的方法, 我们应该默认把他按 imperative 的方式实现.
而只在Self:LazySequenceProtocol
的情形下, 才进行lazy的行为. 注意到这个情况下, 因为调用链上游某处显式出现了一次.lazy
, 所以这个责任已经转嫁到调用者, 调用者应该十分清楚 code 的行为, 并不会产生 surprise.
1 | // good case |
Compiler-synthesized conformance
给我们的自定义类, 在某些情况下直接添加 protocol conformance, 不提供具体实现, compiler 会生成默认的实现. 涉及到的 protocol 有:
各个 protocol 支持自动遵循的自定义类型有不一样的要求:
Comparable
和CaseIterable
只作用于 enum.Equatable
和Hashable
只作用于 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 | struct Model: Hashable { |
当然这里的例子比较简单, 对于比较复杂的编译器支持生成实现代码的 protocol, 例如Encodable/Decodable
, 可以有更花式的做法. 例如这边的例子, 就是通过增加优先级高的重载方法, 来增强现有Codable
的自定义能力.
Tricks & Patterns
Implementing “ghost operator”
问题背景
在 Swift Standard Library 中某些 Collection 实现了一种 subscript, 接受的实参是...
, 生成一个涵盖整个 collection 范围的SubSequence
.
1 | let array = [1, 2, 3, 4, 5] |
当然...
在现有的 Swift 标识符规则下, 只能是一个 operator, 不可能是一个常量/变量. 因此显然这个 subscript 的入参类型是一个 function type.
问题目标
我们考虑更一般的情况, 假设我们想要实现这样一个 subscript.
1 | struct Special { |
为了能方便的使用, 我们的实现需要保证以下几点:
[...]
这个 subscript 调用可以完美解析, 不会出现找不到 subscript 方法, 或者出现二义性.- 在实现过程中, 暴露出来的无关接口越少越好.
- 这个 operator 只能是用在我们这个 subscript 中, 任何其他方式都不能被调用, 如果用户用
...
时都意识不到这是一个自定义的 operator, 似乎只是一个 magical special identifier, 那就更好不过了.
实现
(1) 使用 case-less enum 建立一个局部作用域, 并添加一个无实现的 operator 方法
1 | enum _ThreeDot { |
(2) 使用上述 operator 方法的签名作为入参类型, 定义我们想要的 subscript
1 | typealias ThreeDot = (_ThreeDot) -> Void |
此时就可以按想要的方式调用Special()[...]
.
实现过程中的要点主要有:
- 我们按标准的 Swift custom operator 的定义方法, 将方法定义在一个宿主 enum 里, 并且使这个方法接受一个宿主类型的参数, 可以有效避免
...
operator 和相同作用域内其他同名 operator 的冲突. - 我们定义的 enum, 是 non-inhabitant type, 只起到命名空间的作用. 并且由于在代码中的任何地方都不可能建立一个这个 enum 类型的实例. 所以
...
永远不可能被真正调用到: 调用需要的实参永远不可能被实例化出来嘛. 这个 operator 只能"相当"于一个 identifier 被引用, 而不会被调用.
可以看到, 这个技巧不限于 Swift 已经定义的...
, 可以用在任何其他名字的自定义 operator; 引用地点也不一定非得是 subscript, 任何 function/method 中都是可以的.