Swift 与 Objective-C

[Swift 中级 #3] some 与 any 以及存在型的开销

查看 Swift 5.6 之后的代码,会看到协议名称前面出现 some 或 any:some View、any Error、some Collection。以前可以直接把协议名称写在类型位置,但现在编译器会警告或报错,要求添加 any。…

6 分钟阅读
[Swift 中级 #3] some 与 any 以及存在型的开销 封面图

先说结论:原本行为不同的两种情况被合并成了一种写法,而 Swift 把这种区别带到了表面。some Viewany Errorsome Collection。以前可以直接把协议名称写在类型位置,但现在编译器会警告或报错,要求添加 any。究竟发生了什么变化?

简单来说,这就是泛型篇预告过的静态多态与动态多态之分。中级系列第 3 篇将梳理 some 与 any。

问题的起源 — 协议处于类型位置时有两副面孔

协议本质上是一种描述约束的语言,用来表达资格,例如遵循 Comparable 的类型。但当协议名称出现在变量或返回类型的位置时,它的性质就变了。

let shapes: [Shape] = [Circle(), Square(), Triangle()]

这个数组混合了不同类型。为了实现这一点,编译器会把每个值放进盒子里:一个标记为“遵循 Shape 的某种东西”的盒子,这就是 existential type。盒子中的具体类型只有在运行时才能知道,方法调用也会变成打开盒子、查找实际类型实现的间接调用。

问题在于,这个盒子并不是免费的。大小不同的值必须放进统一的盒子,因此需要单独的 existential container 规格;较大的值会放在堆上,盒子只保存指针。调用会经过名为 witness table 的函数列表,形成动态分派,并阻碍内联、特化等大量编译器优化。功能上也有限制。由于盒子里的实际类型已被擦除,很难回答两个 Shape 是否属于同一类型。

旧语法隐藏了这项开销。写成 func draw(shape: Shape) 会创建盒子,而写成 func draw<S: Shape>(shape: S) 则会以泛型方式运行,不需要盒子。两段看似相似的代码具有完全不同的性能特征,但语法把差异藏了起来。这正是 Swift Evolution 提案 SE-0335 引入 any 关键字的原因:在创建盒子的地方明确写出盒子。这是第 1 篇中“不要隐藏开销”这一理念延伸为语法修改的又一个例子。

some — 不用盒子也能隐藏

如果说 any 是可以容纳任何东西的盒子,那么 some 就是相反方向的工具。some Shape 表示具体类型已经确定为一种,但不公开它的名称,因此称为 opaque type。

func makeShape() -> some Shape {
    Circle(radius: 10)  // 始终 Circle 只返回一种类型
}

调用方不知道 Circle 这个名称,但编译器知道。因此既没有盒子,也没有间接调用。静态分派和优化都得以保留,实际上会像泛型一样被处理。代价是灵活性:返回 some Shape 的函数必须在所有返回路径上返回同一个具体类型。根据条件返回 Circle 或 Square 会导致编译错误。

SwiftUI 的 var body: some View 是这种语法的代表性用法。body 实际返回的类型可能是 VStack<TupleView<(Text, Image)>> 这样的怪物,既无法也不想把它写进签名。some View 只隐藏名称,同时让编译器知道具体类型,从而解决这个问题,而且不牺牲性能。Progressive Disclosure 篇中作为“初学者不必了解的复杂性”示例介绍的语法,其本质就是这个。

参数位置的 some 也值得了解。func draw(shape: some Shape)func draw<S: Shape>(shape: S) 的简写(SE-0341)。当函数体不需要类型参数名称时,可以用更轻量的方式使用泛型。

盒子附带一张价目表:堆分配和 witness table
盒子附带一张价目表:堆分配和 witness table

选择标准 — 默认使用 some,有理由时才用 any

把两个关键字的区别压缩成一张表:some 在编译时确定一种类型(静态分派、可优化、保留类型关系),any 则允许运行时使用任意类型(动态分派、盒子开销、类型擦除)。

实践标准很明确:默认使用 some(或泛型),只有确实需要混合多种类型时才使用 any。

any 大致有三种合理使用场景。第一,异构集合。像 [any Shape] 一样把不同类型放进同一个数组,没有盒子就无法实现。第二,类型在运行时决定的存储属性,例如根据配置插入不同实现的 var strategy: any PaymentStrategy。策略模式和依赖注入中的协议属性大多属于这一类。第三,返回类型随条件变化的函数,也就是 some 不允许的情况。

换句话说,如果你只是想让函数参数接收任意遵循该协议的类型,答案就是 some。每次调用时类型都会确定为一种。Swift 团队的指南也指向同一方向:只有需要在集合中混合或存储时,才提升为 any。

不必夸大,也不必忽视性能差异。在只处理几次 UI 事件的代码中,any 的开销微不足道。但在每秒运行数万次的循环中,盒子开销和受阻的优化会产生可测量的差异。一如既往,标准是测量;默认使用 some,也会减少需要测量的地方。

解读错误消息 — “添加 any”在告诉你什么

理解这个区别后,过去只能靠记忆来应付的编译器消息也变得容易理解了。

“Use of protocol ‘X’ as a type must be written ‘any X’”要求通过语法承认:把协议用作类型会创建盒子。在机械地添加 any 之前,应先问问这个位置是否真的需要盒子(是否可以改用 some 或泛型)。这才是正确处理该错误的方式。

“Protocol ‘X’ can only be used as a generic constraint”是旧版 Swift 中的著名错误:当带有 associatedtype 或 Self 的协议被用在类型位置时就会出现。它表示由于不知道关联类型,无法确定盒子的规格。现在语言通过 SE-0309、primary associated types 等功能已经允许了相当多的情况,但要彻底理解仍需讨论关联类型本身。这就是下一篇的主题。

默认使用 some,只有确实需要混合时才使用 any
默认使用 some,只有确实需要混合时才使用 any

总结

  • 把协议写在类型位置会创建 existential type(盒子),并带来动态分派、容器开销和类型擦除。any 是贴在这个盒子上的诚实标签。
  • some 则相反:确定一个具体类型,只隐藏它的名称。由于没有盒子,性能与泛型相同;SwiftUI 的 some View 是代表性案例。
  • 默认使用 some(泛型),any 只用于异构集合、运行时决定的存储、条件返回等多种类型真正混合的场景。
  • 把编译器要求使用 any 看作“请意识到盒子开销”的信号,在无条件添加之前,先确认是否可以使用 some。

下一篇按预告介绍 associatedtype:梳理“generic constraint”错误的根源、协议包含类型占位符的含义,以及 primary associated types。

延伸阅读