为什么"泛型"还不够
上一篇我们写出了这样的代码:
swift
let calc: any DamageCalculator<Double> = CritCalculator(rate: 1.5)
它编译得快、跑得也快,但当你想把它存进数组、或者作为属性逃逸到运行时,就会遇到三个灵魂问题:
- 编译器不知道具体类型有多大,如何分配内存?
- 协议里有
associatedtype,为什么不能用DamageCalculator直接当做类型? - 同样一句
calculate(base:),为什么有时走内联、有时走虚表?
答案都指向同一个机制:Existential Container(存在性容器),社区俗称"类型擦除盒"。
Existential 是什么
Swift 把"符合某个协议的值"打包成一种统一大小的盒子,这个盒子就叫 existential。
语法层面:
any Protocol// Swift 5.6+ 显式 existential- 老代码里的
Protocol// 隐式 existential,即将被逐步废弃
盒子内部到底长什么样?继续看。
Existential Container 的内存布局
以 64 bit 为例,标准布局 5 个字(40 byte):
lua
+-------- 0: value buffer (3 ptr = 24 byte)
+--------24: value witness table (VWT)
+--------32: protocol witness table (PWT)
-
value buffer
- 小值(Int、Double、CGPoint...)直接内联;
- 大值(String、Array、自定义 class)堆分配,buffer 存指针;
-
VWT
管理"值语义"生命周期:拷贝、销毁、搬移。
-
PWT
管理"协议方法"派发地址,相当于 C++ 的 vtable。
结论:哪怕只是一个 Double,装进 any NumericValue 后也会膨胀到 40 字节;如果频繁在数组里拷贝,就会带来隐式堆分配和缓存抖动。
关联类型协议的"额外"盒子
当协议带 associatedtype 时,existential 还需要一份通用签名(generic environment),用于在运行时保存类型元数据。
因此:
swift
let x: any Attackable // ❌ 编译错误:associatedtype Value 未定
let y: any Attackable<Int> // ✅ Swift 5.9 新语法:parameterized existential
后者内部比"无关联类型"再多 8 byte,总计 48 byte。
苹果在 WWDC23 给出的性能警告:< 3 个 witness 方法且 value ≤ 24 byte 时,existential 才基本无额外开销;否则请考虑"手写类型擦除"或"泛型特化"。
实战:手写 AnyDamageCalculator
目标:
- 对外暴露固定大小(无动态盒子);
- 对内保存任意具体计算器;
- 仍保持
Value泛型参数。
- 定义抽象基类(引用语义)
swift
class AnyDamageCalculatorBox<Value: NumericValue>: DamageCalculator {
func calculate(base: Value) -> Value { fatalError("abstract") }
}
- 定义具体盒子(泛型类)
swift
final class ConcreteBox<T: DamageCalculator>: AnyDamageCalculatorBox<T.Value> {
private let concrete: T
init(_ concrete: T) { self.concrete = concrete }
override func calculate(base: Value) -> Value {
concrete.calculate(base: base)
}
}
- 定义值包装(对外类型)
swift
struct AnyDamageCalculator<Value: NumericValue>: DamageCalculator {
private let box: AnyDamageCalculatorBox<Value>
init<C: DamageCalculator>(_ concrete: C) where C.Value == Value {
self.box = ConcreteBox(concrete)
}
func calculate(base: Value) -> Value {
box.calculate(base: base)
}
}
- 使用:
swift
let crit = CritCalculator(rate: 1.5)
let erased: AnyDamageCalculator<Double> = AnyDamageCalculator(crit)
array.append(erased) // 数组元素大小 = 1 ptr,无 existential 盒子
- 内存大小:8 byte(一个 class 指针);
- 拷贝成本:一次 ARC retain;
- 方法派发:虚表一次,但不再额外带 VWT/PWT。
Swift 5.9 新武器:Parameterized Existential
swift
let list: [any DamageCalculator<Double>] = [
CritCalculator(rate: 1.5),
MultiplierCalculator(upstream: CritCalculator(rate: 2), multiplier: 1.2)
]
编译器会自动生成"隐藏盒子",但仍带 48 byte 拷贝成本。
适合场景:
- 原型阶段、快速迭代;
- 对性能不敏感的工具代码;
高性能路径(渲染、音频、网络解析)继续用手写擦除或泛型特化。
类型擦除的通用套路(模板)
任何带 associatedtype 的协议,都可以套下面 4 步:
- 创建
AnyProtocolBase<AssociatedType>抽象类; - 创建
ConcreteBox<T: Protocol>具体类,持有T; - 创建
AnyProtocol<AssociatedType>值类型,内部存AnyProtocolBase指针; - 对外 API 全部
override / forward到抽象类。
什么时候用哪种形态?
markdown
需求 \ 方案 泛型特化 any Protocol 手写擦除
------------------------------------------------------------
编译期已知类型 ✅ ❌ ❌
需要进数组/逃逸 ❌ ✅ ✅
对性能极度敏感 ✅ ❌ ✅
不想写样板代码 ✅ ✅ ❌(可用宏)
一句话:编译期能定类型就用泛型;运行时再决定就用擦除;原型阶段先 any 再说。