1️⃣ 典型示例
protocol NumericProtocol {
static func +(lhs: Self, rhs: Self) -> Self
}
func addGeneric<T: NumericProtocol>(_ a: T, _ b: T) -> T {
return a + b
}
func addExistential(_ a: NumericProtocol, _ b: NumericProtocol) -> NumericProtocol {
return a + b
}
调用:
let x: Int = 1, y: Int = 2
let g = addGeneric(x, y) // 泛型调用
let e: NumericProtocol = 1
let f = addExistential(e, e) // 协议调用
- addGeneric → 编译器知道
T = Int→ 可静态分发 → 高性能 - addExistential → 编译器只知道是
NumericProtocol→ 动态派发 → 慢
2️⃣ 本质差异:编译期 vs 运行期
| 特性 | 泛型调用 | 协议存在类型调用 |
|---|---|---|
| 类型已知 | 编译期已知 T | 运行期才知道具体类型 |
| 调用方式 | 静态分发 / 内联 | 动态派发(witness table) |
| 内存布局 | 直接存储值类型在栈 | 包装在 existential container |
| CoW 优化 | 可消除 runtime 检查 | 运行时仍需 CoW 检查 |
| LLVM 优化 | 内联、SIMD、向量化 | 内联困难,优化受限 |
3️⃣ 为什么协议存在类型慢
(1)存在类型封装开销
-
协议类型变量被包装成 existential container:
- 小型值类型(≤ 3 words)直接存储
- 大型值类型或引用类型使用指针 → 额外间接访问
-
每次访问协议方法都要 解包 或间接调用
(2)动态派发(witness table)
- 调用
x + y时,编译器不知道 x/y 的具体类型 - 通过 witness table 找到对应函数指针 → 调用
- 比静态泛型调用多一次或多次 指针间接跳转
(3)CoW 仍然在运行时检查
var arr: [Int] = [1,2,3]
let e: [Int] = arr as NumericProtocol
- 结构体数组是 CoW
- 协议类型包装后,编译器无法消除 CoW 检查
- 写入可能触发 runtime buffer 复制
(4)内联优化受限
- 泛型特化 → LLVM 可静态内联 → 去掉函数调用
- 协议存在类型 → 函数指针动态调用 → LLVM 很难内联
4️⃣ 性能差异总结
| 特性 | 泛型 | 协议存在类型 |
|---|---|---|
| 调用方式 | 静态分发 | 动态派发(witness table) |
| 类型已知 | 编译期 | 运行期 |
| 内联优化 | 可以 | 很难 |
| CoW 优化 | 编译期可消除 | runtime 仍需检查 |
| 内存访问 | 栈上直接 | existential container(间接) |
| 典型开销 | 0~1 条跳转 | 1~3 条跳转 + 容器解包 |
5️⃣ 核心结论
协议存在类型慢的本质:
- 类型在运行期才知道 → 静态优化受限
- 动态派发(witness table) → 每次方法调用多一次指针跳转
- 内存包装/解包 → existential container 的额外开销
- CoW 无法编译期消除 → 大型值类型写入仍需 runtime 检查
- 泛型调用 → 编译期类型已知 → 静态分发、内联、CoW 优化 → 高性能
- 协议调用 → 类型运行期解析 → 动态派发 → 开销明显
💡 实践建议
- 高频计算 / 热路径 → 用泛型,不要用协议存在类型
- 异构集合 / 接口抽象 → 协议存在类型即可,性能牺牲可接受
- 混合策略 → 协议存在类型存储 → 内部调用泛型方法做热路径计算