在 Swift 中,当你使用 any Protocol(存在类型)时,编译器会通过 Existential Container(存在容器) 来抹除具体类型。这种灵活性并非免费,它在内存布局、性能损耗和方法派发上都有显著的开销。
以下是底层的详细拆解:
1. 内存布局:40 字节的“黑盒”
在 64 位架构下,一个标准的 Existential Container 占用 5 个内存字(Words) ,共 40 字节。其布局如下:
| 组成部分 | 大小 | 功能描述 |
|---|---|---|
| Value Buffer | 3 Words (24 字节) | 存储数据的区域。 |
| Metadata Pointer | 1 Word (8 字节) | 指向具体类型的 Metadata,用于查找类型信息和 VWT。 |
| PWT Pointer | 1 Word (8 字节) | 指向 Protocol Witness Table,用于方法派发。 |
- 横向扩展:如果是协议组合(如
any P1 & P2),容器会增加更多的 PWT 指针,每多一个协议增加 8 字节。
2. 内存成本:内联 vs 堆分配
Existential Container 处理数据的方式取决于数据的大小:
-
内联存储 (Inline) :如果具体类型(如
Int或小型Struct)的大小 字节,数据直接存入 Value Buffer。此时开销较小,仅为栈上的拷贝。 -
堆分配 (Indirect/Heap Allocation) :如果类型大小 字节,Swift 必须在堆(Heap)上开辟内存来存放数据,而 Value Buffer 只存储一个指向该堆地址的指针。
- 开销:触发
malloc/free级别的系统调用,显著降低性能。 - 引用计数:即便是值类型(Struct),放入容器后,容器也需要通过引用计数来管理这块堆内存的生命周期。
- 开销:触发
3. 方法派发开销:多重间接寻址
由于 any 类型在编译期不知道具体是谁,它无法使用静态派发,甚至不能直接使用类的 V-Table 派发,而是使用 PWT(协议见证表)派发。
调用一个协议方法的步骤:
- 加载 PWT:从容器中读取 PWT 指针。
- 查找函数地址:根据协议方法的索引,在 PWT 中找到具体的函数地址。
- 获取 self:从 Value Buffer 中决定是将值作为参数传递,还是将指针作为参数传递。
- 间接跳转:跳转到对应的机器码地址执行。
性能成本:
- 无法内联 (Inlining Barrier) :编译器几乎无法跨越 PWT 进行内联优化。这意味着原本几纳秒的操作,因为无法内联而增加了函数调用的固定开销。
- 流水线预测失败:间接跳转会增加 CPU 分支预测失败的概率,导致计算流水线清空。
4. 性能损耗总结表
| 维度 | some Protocol (Opaque) | any Protocol (Existential) |
|---|---|---|
| 派发方式 | 静态派发(可内联) | 动态派发(不可内联) |
| 内存布局 | 等同于具体类型(透明) | 固定的 Container(40字节+) |
| 堆分配 | 无(除非类型本身就在堆上) | 有可能(大型结构体触发) |
| 泛型优化 | 享受特化 (Specialization) | 无法特化 |
5. 什么时候该担惊受怕?
这种开销在以下场景会成为瓶颈:
- 高频循环:在每秒运行数百万次的循环中使用
any,间接寻址和无法内联会使耗时成倍增加。 - 大型集合:
[any MyProtocol]数组。每个元素都占 40 字节,且可能伴随大量的零散堆分配。
优化建议
- 优先使用泛型/
some:这能让编译器在编译期“看穿”类型,实现特化和内联。 - 减少协议组合:每多一个协议组合,容器就变大一点,查表压力也随之增加。
- 结构体瘦身:尽量将协议实现的结构体控制在 24 字节以内(例如使用
Box或减少属性数量),以避免触发堆分配。