原文成于2018年
本文积累学习 Swift 过程中获取到的好技巧, 以及 Swift Standard Library 使用的优秀范式 (tricks).
普通 Practices
Availability checks
使用 availability checks 将超出指定的"Base SDK"版本的代码块隔离起来. 在运行时, 系统会根据运行环境动态 check 该代码是否执行.
@available(iOS 9.0, *)
, method, type 定义时.if #available(iOS 9.0, *)
, 使用 method, type 时.- 支持 guard 作为 early exit.
可以替代相对应的早期的以下调用:
UIDevice.current.systemVersion.compare("9.0", options: .numeric) == .orderedAscending
活用 “String-backed” enum
背景知识
在众多基于 raw value 的 enum 中, raw value 是String
的最为特殊, 因为这样的 enum 在定义各个 case 时, 可以省去显式写出用来初始化该 enum 的 raw value. 这种情况下, 编译器默认追加代码, 使用各个 case identifier 的同名字符串作为 raw value.
1 | // 各行 case 可以省略 // 之后的部分 |
另一方面, 这样的 enum 默认遵循RawRepresentable
, 且关联类型RawValue == String
. 这方便我们对于一系列有相同特征的 enum 依靠 protocol 提供统一操作.
案例
下述技巧最早出现于WWDC 2015, session 403.
该 video 已经被 apple 从公开列表中移除, 此处提供的是 web.archive 上的缓存.
也可以尝试直接获取 video 和 pdf.
在使用 segue 的 app 中每一个UIViewController
都含有一组 segue identifier, 通过调用func performSegue(withIdentifier identifier: String, sender: Any?)
来执行页面跳转.
但是直接调用该方法有风险: 传入的identifier
有可能有拼写错误. 因此容易想到, 定义一个使用String
作为 raw value 的 enum, 并同时给UIViewController
增加一个接受该 enum 的重载方法.
1 | enum SegueIdentifier: String{ |
但仅仅如此, 有多个副作用
- 不同的 view controller 理所当然有不同的 segue, 因此应该给不同的 view controller 定义不同的
SegueIdentifier
enum, 使他们彼此独立 - 既然必须存在多个
SegueIdentifier
彼此独立, 那么就不得不针对每种SegueIdentifier
单独提供一个performSegue
的重载方法
可以看到这样虽然提供了一种 type safe 的调用方法, 但是显著增加了工作量. 为了解决这个问题, 可以抽象出一个 protocol, 表示包含SegueIdentifier
的 view controller, 进而针对该 protocol 统一增加一个方法即可.
1 | // 这里使用的 swift 语法, 可以直接给 protocol 的遵循者增加约束 |
此时, 只要任意UIViewController
, 通过提供自己 local 的SegueIdentifier
, 就可以遵循SegueNavigation
, 进而可以调用 type safe 的func performSegue(identifier: SegueIdentifier)
方法.
Tricks & Patterns
由于 Swift 语言在不断变化之中, 很多将来加入的语法特性在当下版本没有直接对应的表达方式. 但凭借 Swift 已有的丰富的 generics 和 protocol 表达技巧, 可以实现一些等价的功能 (虽然可能并不优雅).
给 protocol 增加 recursive constraints (before Swift 4.1)
定义 Swift 的 protocol 时, 可以要求遵循者同时提供associatedtype
, 该associatedtype
可以附带一些额外约束, 例如: SomeType
, where ...
. 当前版本下, 这样的约束内允许递归引用到 protocol 本身[1].
1 | protocol A { |
在允许 associated type 递归引用前, 为了实现该功能, 使用了以下技巧.
(1) 先定义一个和A
有相同接口或大部分关键接口的"隐形 protocol".
1 | protocol __PrivateA { |
(2) 然后使A
本身继承这个 protocol, 并要求 associatedtype 也遵循这个 protocol.
1 | protocol A: __PrivateA { |
(3) 此时所有引用A
和A.T
的地方, 都可以调用到 __PrivateA
中的那些方法了
这种避免显式出现 recursion 的方式, 虽然可以解决语法问题, 但带来的后果就是多了不得不暴露给用户的中间层. 在 Swift 4.1 之前的标准库中, Collection
就通过该技巧定义了SubSequence
和Indices
这2个 associated type. 诚然这也给当时的学习者带来了一些困难:
- 太多 protocol, 不容易理清类库关联性.
- 这种引入的中间层 protocol 语法上来讲是可见的, 但是标准库在导出 interface 时有一个 hack 步骤: 把所有"__"开头的声明 strip 掉了, 此时这个方法可以被调用, 但不会出现在 interface 和 documentation 里. 这反而导致这个 protocol 中的某些关键方法无法被使用者注意到. 例如
Collection
中有一个关键方法func index(after i: Index) -> Index
, 在当时正因为定义在实现此 trick 对应的_IndexableBase
中, 导致无法在文档中查询.
泛型对象排除特例调用
问题
一般而言, 经常需要在泛型类型的模板参数满足某个条件时, 增加一些特例调用. 这时候我们可以通过带限制条件的 extension 给这个类型扩展方法.
1 | struct Container<T> { |
这边提出的是一个反向问题: 对于某个泛型类型, 要求模板参数在某个条件下, 不能调用某个方法. 这个乍看 Swift 不可能支持的需求, 在某种程度上可以实现.
场景
这个需求貌似不符合逻辑, 但 Swift Standard Library 中就有曾经有个这样的例子. 在之前版本的 Swift 中, CountableRange
用来表示可以离散取值的闭区间. 例如0..<10
就是一个CountableRange
. 很自然地, CountableRange
可以遵循Collection
. 既然可以遵循Collection
, 那么接下来就需要确定CountableRange
的Index
.
在 Swift 中, 一个Collection
的Index
, 不一定为Int
; 即使是Int
, 也不一定从0开始. 并且, Collection
和其SubSequence
之间, 应该存在Index
的复用关系. 例如:
Array
的SubSequence
:ArraySlice
, 复用Array
的 index, 即["a", "b", "c"].dropFirst()
的startIndex
是1, 而不是0.String
的SubSequence
:SubString
, 复用String
的 index, 即"hello word".prefix(6)
的endIndex
, 在原来 string 中可以索引出"w".
CountableRange
也应如此, 那么(-10..<10)[0]
应该是多少呢, 如果把CountableRange
的firstIndex
一律定位0, 那么上述表达式会返回 range 中的第一个值"-10". 但这是不符合Collection
约定的, 因为(-10..<10).dropFirst()
的结果-9..<10
, 仍然是一个CountableRange
. 但它的firstIndex
显然不应该是0, 因为(-9..<10)[0]
会返回9, 这就违反Collection
和SubSequence
中, 同一个 index 应该取得同样的 element 这个约定.
所以 Swift Standard Library 决定, 针对一个CountableRange
: a..<b
, 使用a
作为其 firstIndex
, 从而(a..<b)[a] == a
. 这一个理性但也许出人意料的表现, 确实可能导致一部分人的困惑. 于是 Swift 标准库采取了这样的办法: 当CountableRange
的泛型参数就是Int
时, 禁止调用subscript
, 当泛型参数不是Int
时, 没这个问题, 因为本身下标就不会是0, 因此不会出现理解问题.
实现
这个 trick 在 Swift 标准库源码的实现中称为: “Statically Unavailable & Dynamically Available”. 换言之, 这个方法动态是存在的, 但是你的静态代码无法调到.
考虑一般的情况
1 | struct Container<T: Equatable> { |
具体实现原理是: 在特定情况下, 针对不允许调用的方法定义一个特殊的重载, 使用户调用该方法时, 会出现二义性, 从而无法通过编译.
(1) 首先给Container
的泛型参数的条件中, 插入一个特殊的标记, 这个标记默认指向一个人畜无害的类型
1 | enum __UnavailableFlag {} |
(2) 接着针对想特殊处理的泛型类型, 改写这个标记, 使之引用自身
1 | extension Int: SpecialEquatable { |
(3) 给我们的泛型类型, 增加一个重载, 使仅针对我们要规避的参数类型, 产生二义性
1 | struct Container<T: SpecialEquatable> { |
此时, 当我们调用Container.check
时, 当且仅当T == Int
时, 因为Int._Unavailable == Int
, 因此同时命中2个check
方法, 且毫无优先级区别, 导致编译失败; 而当T
是其他类型时, 此时T._Unavailable == __UnavailableFlag
, 而__UnavailableFlag
是一个 non-inhabitant type, 又称 “caseless-enum”, 因此调用者不可能产生一个这样类型的实例, 因此永远不可能产生二义性.
需要说明的是, 在上面的实现中我们把Container<T: Equatable>
改写成了Container<T: SpecialEquatable>
, 这会导致我们不得不把所有类型都重新遵循SpecialEquatable
才能使之用于Container
.
而在标准库设计CountableRange
时, 是不需要的, 因为CountableRange
本身就要求T: Strideable
, 而Strideable
的源码 Swift 开发人员是可控的, 因此他们只要偷偷给Strideable
塞入一个有默认实现的 associated type, 就可以使所有原先就遵循Strideable
的类型不改动任何代码就 work 了.
可以看到这个 trick 也有一些恼人之处:
- 这个实现实际上巧妙地借用编译阶段的"二义性"错误避免了调用, 并非是真的规避调用, 我们这么实现后 error message 也并没有传达给用户我们不希望他们如此调用的出发点.
- 这个实现需要泛型类型本身对泛型参数有 protocol 约束, 并且我们想规避的方法也得至少有1个参数, 否则这个 trick 是无法进行的.
- 这个实现会至少在 ABI 层面暴露给用户多余的 associated type 定义和一个用不到的类型, 即上面的
associatedtype _Unavailable
和enum __UnavailableFlag {}
. 当然作为 Swift Standard Library, 在输出给我们用时, API interface 里是看不到的, 但也只有 Swift 开发者能有这个优势.
也许是这样的 trick 太过惊艳, 在最新的 Swift 中, 借重构Range
族的机会, Swift 标准库把这个 trick 从曾经的CountableRange
(注: 现在CountableRange
只是Range
的一个带约束同义语) 中去掉了[2]. 当然, 这样的代价就是需要我们开发者确保自己清楚Range
的Index
是如何生效的.
这个 trick 是很有启发性的. 基于 Swift 较为成熟的类型系统, 我们可以通过泛型参数、protocol 的 associated type 等特性, 给自定义类型增加编译时可以确定的类型标记. 起到这种标记作用的类型, 仅仅辅助实现静态决议, 而在代码运行过程中并不会出现这种类型的实例对象. 因此, 这种起到标记作用的类型被称为"Phantom Type". Phantom Type 是常见的代码技巧, 尤其在泛型编程中, 会在各种 practices 中经常见到.
泛型类型按条件遵循 protocol (before Swift 4.1)
当前的 Swift 含有一个强大功能: Conditional Conformance[3]. 允许我们针对泛型类型在其泛型参数满足特定条件时, 使泛型类型本身遵循 protocol. 当泛型参数有一系列变化时, 泛型类型本身遵循的 protocol 还可以产生层级. 这个功能从 Swift 4.1 部分加入 (只支持编译期决议), 并在 Swift 4.2 得到彻底实现 (支持动态判断).
在 Swift 4.1 之前, 由于语言不支持此特性, 我们只能手动另辟蹊径. 例如, 期望实现下面等价的效果.
1 | struct Container<T> { let item: T } |
当我们使用不同的模板参数实例化Container
时, 形成的不同的类型本来彼此就是异构 (heterogeneous) 的, 无法再使用单个类型指示他们, 因此我们只需考虑静态情况下的问题. 并且, 某个Container
实例是否遵循 protocol 在实例化瞬间就已经确定了, 因此可以尽早对不同的Container
进行区分.
(1) 首先针对不同的约束情况建立一组不同的实例类型
1 | struct Container<T> { let item: T } |
(2) 然后编写一个"静态"的工厂方法即可, 这个方法其实针对不同的输入参数类型, 进行重载决议, 在编译时确定遵循到哪一层 protocol.
1 | func makeContainer<T>(_ item: T) -> Container<T> { |
这种方案的缺陷在于: 当同时存在多组彼此正交的 protocol 体系时, 我们要定义的泛型类型族会很庞大, 需要定义的类型个数是各个 protocol 体系笛卡尔积的尺寸.
在 Swift 4.1 前, Swift Standard Library 对Slice<Base: Collection>
就是这么处理的, 由于 Collection
丰富的继承体系, 要定义的Slice
变体也非常多. 实际上:
- 在 index 构建方面,
Collection
派生出BidirectionalCollection
和RandomAccessCollection
- 在 in-place mutation 方面,
Collection
派生出MutableCollection
- 在可变长方面,
Collection
派生出RangeReplaceableCollection
因此只能建立总计个 concrete 的Slice
变体.