Swift Practices & Perls (1)


原文成于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
2
3
4
5
// 各行 case 可以省略 // 之后的部分
enum ImageIdentifier: String {
case centerButton // = "centerButton"
case myFavorites // = "myFavorites"
}

另一方面, 这样的 enum 默认遵循RawRepresentable, 且关联类型RawValue == String. 这方便我们对于一系列有相同特征的 enum 依靠 protocol 提供统一操作.

案例

下述技巧最早出现于WWDC 2015, session 403.
该 video 已经被 apple 从公开列表中移除, 此处提供的是 web.archive 上的缓存.
也可以尝试直接获取 videopdf.

在使用 segue 的 app 中每一个UIViewController都含有一组 segue identifier, 通过调用func performSegue(withIdentifier identifier: String, sender: Any?)来执行页面跳转.

但是直接调用该方法有风险: 传入的identifier有可能有拼写错误. 因此容易想到, 定义一个使用String作为 raw value 的 enum, 并同时给UIViewController增加一个接受该 enum 的重载方法.

1
2
3
4
5
6
7
8
9
10
11
12
13
enum SegueIdentifier: String{
case jumpToHome
case jumpToMyFavorites
}

extension HomeViewController {
func performSegue(identifer: SegueIdentifier) {
self.performSegue(withIdentifier: identifer.rawValue, sender: nil)
}
}

// 调用时
performSegue(identifer: .jumpToHome)

但仅仅如此, 有多个副作用

  • 不同的 view controller 理所当然有不同的 segue, 因此应该给不同的 view controller 定义不同的SegueIdentifier enum, 使他们彼此独立
  • 既然必须存在多个SegueIdentifier 彼此独立, 那么就不得不针对每种SegueIdentifier单独提供一个performSegue的重载方法

可以看到这样虽然提供了一种 type safe 的调用方法, 但是显著增加了工作量. 为了解决这个问题, 可以抽象出一个 protocol, 表示包含SegueIdentifier的 view controller, 进而针对该 protocol 统一增加一个方法即可.

1
2
3
4
5
6
7
8
9
10
11
12
13
// 这里使用的 swift 语法, 可以直接给 protocol 的遵循者增加约束
// 在最初的 WWDC session 中还不支持这么做
protocol SegueNavigation where Self: UIViewController {
associatedtype SegueIdentifier: RawRepresentable where SegueIdentifier.RawValue == String

func performSegue(identifier: SegueIdentifier)
}

extension SegueNavigation {
func performSegue(identifier: SegueIdentifier) {
performSegue(withIdentifier: identifier.rawValue, sender: nil)
}
}

此时, 只要任意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
2
3
protocol A {
associatedtype T: A
}

在允许 associated type 递归引用前, 为了实现该功能, 使用了以下技巧.

(1) 先定义一个和A有相同接口或大部分关键接口的"隐形 protocol".

1
2
3
protocol __PrivateA {
// 这里包含了 A 本来的方法
}

(2) 然后使A本身继承这个 protocol, 并要求 associatedtype 也遵循这个 protocol.

1
2
3
protocol A: __PrivateA {
associatedtype T: __PrivateA
}

(3) 此时所有引用AA.T的地方, 都可以调用到 __PrivateA 中的那些方法了

这种避免显式出现 recursion 的方式, 虽然可以解决语法问题, 但带来的后果就是多了不得不暴露给用户的中间层. 在 Swift 4.1 之前的标准库中, Collection 就通过该技巧定义了SubSequenceIndices这2个 associated type. 诚然这也给当时的学习者带来了一些困难:

  • 太多 protocol, 不容易理清类库关联性.
  • 这种引入的中间层 protocol 语法上来讲是可见的, 但是标准库在导出 interface 时有一个 hack 步骤: 把所有"__"开头的声明 strip 掉了, 此时这个方法可以被调用, 但不会出现在 interface 和 documentation 里. 这反而导致这个 protocol 中的某些关键方法无法被使用者注意到. 例如Collection中有一个关键方法func index(after i: Index) -> Index, 在当时正因为定义在实现此 trick 对应的_IndexableBase中, 导致无法在文档中查询.

泛型对象排除特例调用

问题

一般而言, 经常需要在泛型类型的模板参数满足某个条件时, 增加一些特例调用. 这时候我们可以通过带限制条件的 extension 给这个类型扩展方法.

1
2
3
4
5
6
7
8
9
10
11
struct Container<T> {
//...
}

extension Container where T == Int {
func special() {}
}

Container<Int>().special() // OK
Container<Double>().special() // Compiler Error!
Container<String>().special() // Compiler Error!

这边提出的是一个反向问题: 对于某个泛型类型, 要求模板参数在某个条件下, 不能调用某个方法. 这个乍看 Swift 不可能支持的需求, 在某种程度上可以实现.

场景

这个需求貌似不符合逻辑, 但 Swift Standard Library 中就有曾经有个这样的例子. 在之前版本的 Swift 中, CountableRange用来表示可以离散取值的闭区间. 例如0..<10就是一个CountableRange. 很自然地, CountableRange可以遵循Collection. 既然可以遵循Collection, 那么接下来就需要确定CountableRangeIndex.

在 Swift 中, 一个CollectionIndex, 不一定为Int; 即使是Int, 也不一定从0开始. 并且, Collection和其SubSequence之间, 应该存在Index的复用关系. 例如:

  • ArraySubSequence: ArraySlice, 复用Array的 index, 即["a", "b", "c"].dropFirst()startIndex是1, 而不是0.
  • StringSubSequence: SubString, 复用String的 index, 即"hello word".prefix(6)endIndex, 在原来 string 中可以索引出"w".

CountableRange也应如此, 那么(-10..<10)[0] 应该是多少呢, 如果把CountableRangefirstIndex一律定位0, 那么上述表达式会返回 range 中的第一个值"-10". 但这是不符合Collection约定的, 因为(-10..<10).dropFirst()的结果-9..<10, 仍然是一个CountableRange. 但它的firstIndex显然不应该是0, 因为(-9..<10)[0]会返回9, 这就违反CollectionSubSequence中, 同一个 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
2
3
4
5
6
7
8
struct Container<T: Equatable> {
func check(_ t: T) {}
}

// 要求 T == Int 时, 无法调用 check
Container<Int>().check(1) // 期望 Compiler Error
Container<String>().check("") // OK
Container<Double>().check(1) // OK

具体实现原理是: 在特定情况下, 针对不允许调用的方法定义一个特殊的重载, 使用户调用该方法时, 会出现二义性, 从而无法通过编译.

(1) 首先给Container的泛型参数的条件中, 插入一个特殊的标记, 这个标记默认指向一个人畜无害的类型

1
2
3
4
enum __UnavailableFlag {}
protocol SpecialEquatable: Equatable {
associatedtype _Unavailable = __UnavailableFlag
}

(2) 接着针对想特殊处理的泛型类型, 改写这个标记, 使之引用自身

1
2
3
4
5
extension Int: SpecialEquatable {
typealias _Unavailable = Int
}
extension Double: SpecialEquatable {}
extension String: SpecialEquatable {}

(3) 给我们的泛型类型, 增加一个重载, 使仅针对我们要规避的参数类型, 产生二义性

1
2
3
4
struct Container<T: SpecialEquatable> {
func check(_ t: T) {}
func check(_ t: T._Unavailable) {}
}

此时, 当我们调用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 _Unavailableenum __UnavailableFlag {}. 当然作为 Swift Standard Library, 在输出给我们用时, API interface 里是看不到的, 但也只有 Swift 开发者能有这个优势.

也许是这样的 trick 太过惊艳, 在最新的 Swift 中, 借重构Range族的机会, Swift 标准库把这个 trick 从曾经的CountableRange (注: 现在CountableRange只是Range的一个带约束同义语) 中去掉了[2]. 当然, 这样的代价就是需要我们开发者确保自己清楚RangeIndex是如何生效的.


这个 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
2
3
4
5
6
7
8
struct Container<T> { let item: T }

// protocol hierarchy
protocol General {}
protocol Special: General {}

extension Container: General where T: General {}
extension Container: Special where T: Special {}

当我们使用不同的模板参数实例化Container时, 形成的不同的类型本来彼此就是异构 (heterogeneous) 的, 无法再使用单个类型指示他们, 因此我们只需考虑静态情况下的问题. 并且, 某个Container实例是否遵循 protocol 在实例化瞬间就已经确定了, 因此可以尽早对不同的Container进行区分.

(1) 首先针对不同的约束情况建立一组不同的实例类型

1
2
3
struct Container<T> { let item: T }
struct GeneralContainer<T: General>: General { let item: T }
struct SpecialContainer<T: Special>: Special { let item: T }

(2) 然后编写一个"静态"的工厂方法即可, 这个方法其实针对不同的输入参数类型, 进行重载决议, 在编译时确定遵循到哪一层 protocol.

1
2
3
4
5
6
7
8
9
func makeContainer<T>(_ item: T) -> Container<T> { 
return Container(item: item)
}
func makeContainer<T: General>(_ item: T) -> GeneralContainer<T> {
return GeneralContainer(item: item)
}
func makeContainer<T: Special>(_ item: T) -> SpecialContainer<T> {
return SpecialContainer(item: item)
}

这种方案的缺陷在于: 当同时存在多组彼此正交的 protocol 体系时, 我们要定义的泛型类型族会很庞大, 需要定义的类型个数是各个 protocol 体系笛卡尔积的尺寸.

在 Swift 4.1 前, Swift Standard Library 对Slice<Base: Collection>就是这么处理的, 由于 Collection丰富的继承体系, 要定义的Slice变体也非常多. 实际上:

  • 在 index 构建方面, Collection派生出BidirectionalCollectionRandomAccessCollection
  • 在 in-place mutation 方面, Collection派生出MutableCollection
  • 在可变长方面, Collection派生出RangeReplaceableCollection

因此只能建立总计(1+1)(1+1)(1+2)=12(1 + 1) \cdot (1 + 1) \cdot (1 + 2) = 12个 concrete 的Slice变体.


  1. https://github.com/apple/swift-evolution/blob/master/proposals/0157-recursive-protocol-constraints.md ↩︎

  2. https://swiftdoc.org/v3.1/type/countablerange/ ↩︎

  3. https://github.com/apple/swift-evolution/blob/master/proposals/0143-conditional-conformances.md ↩︎