Swift 与 Objective-C

[Swift 进阶 #2] 用泛型消除重复与风险

第一次在 Swift 代码中看到尖括号 <T> 时,你可能会停顿一下。函数名旁边多了一个大写字母,打开文档后,还会看到类似 func map<T>( transform: (Element) -> T) -> [T] 的签名。泛型是 Swift 进阶的入门关卡……

5 分钟阅读
[Swift 进阶 #2] 用泛型消除重复与风险 封面图

第一次在 Swift 代码中看到尖括号 <T> 时,你可能会停顿一下。函数名旁边多了一个大写字母,打开文档后,还会看到类似 func map<T>(_ transform: (Element) -> T) -> [T] 的签名。泛型是 Swift 进阶的入门关卡。标准库几乎所有内容(包括 Array、Dictionary 和 Optional)都是用泛型实现的;跨过这道门槛后,才能开始读懂库代码。

这是进阶系列的第 2 篇。本文总结泛型解决的问题、何时需要约束和 where 子句,以及它对性能的影响。

泛型解决的问题——拒绝在代码重复与类型安全之间二选一

在没有泛型的世界里,尝试编写一个交换两个值的函数,问题马上就会暴露出来。

为 Int 编写的版本不能用于 String。按类型复制代码又会增加重复。因此,人们会想到一个能接收任意类型的盒子,也就是 Swift 中的 Any。但一旦使用 Any,类型信息就消失了。每次取出都要做类型转换,把 Int 放进去却按 String 取出的错误会通过编译,最终在运行时崩溃。

总之,过去只有两个选择:没有重复但有风险的 Any,或安全但重复的按类型复制。泛型拒绝这种二选一。

func swapValues<T>(_ a: inout T, _ b: inout T) {
    let temp = a
    a = b
    b = temp
}

<T>表示一个稍后填入类型的空位。调用时,T 会确定为具体类型,编译器再用这个确定的类型检查全部代码。像 swapValues(&intA, &strB) 这样的类型不匹配调用会产生编译错误。代码只有一份,但类型检查按类型进行,这就是泛型的本质。

类型也同样适用。声明 struct Stack<Element> 后,Stack<Int>Stack<String> 会成为不同类型,编译器会阻止你把字符串放进 Int 栈。Array 正是这种结构;Optional 也如可选值一篇所见,是名为 enum Optional<Wrapped> 的泛型 enum。换句话说,你早就在每天使用泛型。

约束——从“任意类型”到“具备这些能力的类型”

默认情况下,T 这个空位可以是任意类型。但这也意味着它什么都做不了:编译器不了解 T,因此无法比较、输出或相加。

func largest<T>(_ items: [T]) -> T? {
    items.max(by: <)  // 编译错误 — T无法保证可比较
}

这时就轮到约束登场了。写成 <T: Comparable>,就附加了“T 只能是遵循 Comparable 的类型”这一条件,于是可以对 T 使用 <。

func largest<T: Comparable>(_ items: [T]) -> T? {
    items.max()
}

约束不是损失,而是一种交换:缩小可接收的类型范围,却增加对这些类型可执行的操作。协议一篇中讲过的“能力组合”,正是在这里与泛型相遇,因为约束使用的就是协议。

条件复杂时,可以使用 where 子句。位置不同,含义相同;当需要约束类型参数的关联类型时,where 就是必需的。

// Element仅比较 Equatable相同的集合
func allEqual<C: Collection>(_ items: C) -> Bool where C.Element: Equatable {
    guard let first = items.first else { return true }
    return items.allSatisfy { $0 == first }
}

C.Element指的是集合所包含的元素类型,即关联类型(associated type)。这是后续篇章会重点讨论的主题;这里先记住 where 是施加这一条件的位置即可。

在实践中,约束应当只加到必要程度。只需要 Comparable 的函数如果同时要求 Hashable,可用类型反而会减少。约束列表应准确对应函数体实际使用的能力。

约束缩小入口,却扩大内部可执行的操作
约束缩小入口,却扩大内部可执行的操作

性能——编译器消除了抽象的成本

面对“泛型不是会变慢吗?”这个问题,可以用 Swift 的代表性优化来回答:特化。

原则上,泛型函数不知道会传入什么类型,因此需要在运行时携带类型信息并间接执行。但如果编译器能看到调用点,就会为 swapValues<Int> 单独生成专用版本。最终机器码与手写 Int 版本相同。使用泛型抽象并不意味着支付运行时成本;编译器会剥离抽象,生成具体代码。这正是哲学一篇所说的“零成本抽象”的代表案例。

在同一模块内,这项优化通常运行良好;跨越模块边界后会受到限制(取决于库的发布形式)。实践结论是,不必因为担心性能而避开泛型;真正的瓶颈应通过测量找出。提前优化的问题已有另一篇文章讨论。

这里自然会出现一个问题:“如果接收协议类型(例如 items: [Comparable]),它和泛型有什么区别?”这是下一篇的主题。泛型是编译时确定类型的静态多态,而协议类型(existential)是在运行时混合类型的动态多态。下一篇还会解释 Swift 为什么用 some 和 any 明确区分二者。

什么时候创建泛型——实践判断标准

阅读泛型和亲自设计泛型是两个层次,因此这里总结创建泛型时的判断标准。

当同一逻辑只改变类型却第二次出现时,就是信号。 一开始就想着“以后可能会有其他类型”而使用泛型,大多属于过度设计。遵循 YAGNI(You Aren’t Gonna Need It——需要时再做):先从具体类型开始,真正出现重复时再泛化。

与其用于领域概念,不如用于结构和算法。 缓存、分页响应、栈等与内容无关的结构,正是泛型的主场。比如 APIResponse<User>Cache<ImageKey, UIImage>。相反,强行将订单支付等特定领域逻辑泛型化,只会让签名更难理解。

当签名复杂度超过使用收益时,就是该退一步的信号。 如果有三四个类型参数,where 子句又占三行,就该问问调用它的同事是否看得懂。Progressive Disclosure 一篇中的原则同样适用:复杂性应由声明端吸收,不能泄漏到使用端。

特化会剥离抽象,生成与手写代码相同的机器码
特化会剥离抽象,生成与手写代码相同的机器码

总结

  • 泛型拒绝在无重复复用与类型安全之间二选一。代码只有一份,但类型检查按类型进行。
  • <T>是类型空位声明;约束(T: Comparable和 where 子句)缩小空位范围,同时扩大可执行的操作。
  • 得益于特化,泛型通常能达到与手写具体代码相同的性能。
  • 设计标准:出现第二次重复时再泛化,用于结构和算法;如果签名复杂度超过收益,就停下来。

下一篇介绍泛型的兄弟语法,也是现代 Swift 中最容易混淆的语法:some 和 any。我们将深入探究 some View 的真相,以及 existential 的成本。

延伸阅读