Swift 的协议抽象能大幅提升代码复用度,但一旦涉及异构集合或带关联类型的协议,就会立刻撞上编译器的硬限制。存在类型和类型擦除是两条不同的解决路径:前者由语言机制直接支持,后者更多依赖手工包装。真正理解它们的运行模型和适用边界,比记住几个 API 名字重要得多。

存在类型:any Protocol 的运行时盒子
存在类型是协议作为类型使用时的一种运行方式。简单说,当你写下 [any Shape],编译器不会把数组元素当作某个具体的 Circle 或 Rectangle,而是为每个元素创建一个统一的容器。这个容器内部保存了值本身、类型信息以及一组函数指针,通常被称为 witness table。有了函数指针,调用 area() 时就能跳转到真正的实现。
下面的代码中,Shape 没有关联类型,因此可以作为存在类型使用。数组里可以同时放入圆形和矩形,并且循环中直接调用协议方法。
protocol Shape {
func area() -> Double
}
struct Circle: Shape {
let radius: Double
func area() -> Double { Double.pi * radius * radius }
}
struct Rectangle: Shape {
let width: Double
let height: Double
func area() -> Double { width * height }
}
let shapes: [any Shape] = [Circle(radius: 3), Rectangle(width: 4, height: 5)]
for shape in shapes {
print(shape.area())
}
存在类型最典型的优点是声明简单。你只需要在协议名前加上 any,就得到异构集合能力。但它的约束也很明显:一旦协议包含 associatedtype 或方法签名里出现了 Self,编译器就无法为该协议构造稳定的 existential container。原因是这种协议的类型关系不是自包含的,调用方无法在不知道具体模型类型的情况下生成函数签名。这也是为什么 let store: any DataStore 这类写法会直接报错。
另外,存在类型并非完全没有成本。小的值可以内联在容器里,大的值会触发堆分配;方法调用走动态派发,编译器无法像泛型特化那样针对具体类型做内联优化。不过很多场景下这点开销可以忽略,真正的瓶颈通常出在滥用协议导致类型信息丢失。
手工类型擦除:用包装类型换回编译能力
带关联类型的协议无法直接作为类型使用,类型擦除就是围绕这个限制建立的手工包装方案。它的核心思路是:创建一个普通的具体类型 AnyDataStore,在初始化时接受任意符合 DataStore 的具体类型,并通过一个内部抽象层把方法调用转发到真正的实现上。
先定义一个带关联类型的协议,再给出具体实现。下面没有使用任何复杂依赖,只为了让 Model 被擦除后仍然能统一调用。
protocol DataStore {
associatedtype Model
func save(_ model: Model)
func loadAll() -> [Model]
}
final class FileStringStore: DataStore {
typealias Model = String
private var storage: [String] = []
func save(_ model: String) {
storage.append(model)
}
func loadAll() -> [String] {
storage
}
}
把 DataStore 直接当作变量类型会失败,因为编译器不知道 Model 到底是谁。手工类型擦除的一个经典实现是基类加泛型子类。基类定义所有协议方法的抽象接口,子类在泛型参数中捕获具体类型,并负责转发给真正的实例。
private class AnyDataStoreBase<Model> {
func save(_ model: Model) { fatalError("Must override") }
func loadAll() -> [Model] { fatalError("Must override") }
}
private final class DataStoreBox<S: DataStore, Model>: AnyDataStoreBase<Model> where S.Model == Model {
private var store: S
init(_ store: S) {
self.store = store
}
override func save(_ model: Model) {
store.save(model)
}
override func loadAll() -> [Model] {
store.loadAll()
}
}
struct AnyDataStore<Model> {
private let base: AnyDataStoreBase<Model>
init<S: DataStore>(_ store: S) where S.Model == Model {
base = DataStoreBox(store)
}
func save(_ model: Model) {
base.save(model)
}
func loadAll() -> [Model] {
base.loadAll()
}
}
还有更轻量的闭包实现。在 AnyDataStore 的初始化方法里,把具体实例的方法包成闭包存储下来,调用 save 或 loadAll 时直接执行闭包。相比基类方案,闭包代码更短,但捕获具体对象后同样会引入堆分配,而且每次调用会多一层闭包跳转。
struct AnyDataStore<Model> {
private let _save: (Model) -> Void
private let _loadAll: () -> [Model]
init<S: DataStore>(_ store: S) where S.Model == Model {
_save = { store.save($0) }
_loadAll = { store.loadAll() }
}
func save(_ model: Model) { _save(model) }
func loadAll() -> [Model] { _loadAll() }
}
为什么叫类型擦除?因为在 AnyDataStore 的公开接口中,Model 虽然保留了,但具体实现类型 FileStringStore 已经被隐藏。外部只面对 AnyDataStore<String>,不再依赖具体存储类。这种隐藏对模块边界和依赖注入非常有用。
标准库中的类型擦除:AnyView 与 AnyPublisher 的启示
Swift 标准库和官方框架中已经有大量类型擦除实践,最直观的是 SwiftUI 的 AnyView。View 协议带 associatedtype Body,你无法声明 [View] 这样的异构数组。但只要用 AnyView 包装,每个具体视图都变成统一类型。
import SwiftUI
struct RedDot: View {
var body: some View {
Circle().fill(Color.red).frame(width: 20, height: 20)
}
}
let mixedViews: [AnyView] = [
AnyView(RedDot()),
AnyView(Text("Swift"))
]
Combine 的 AnyPublisher 同理。Publisher 的 Output 和 Failure 是关联类型,发布者链中的具体类型可能非常复杂。通过 eraseToAnyPublisher(),函数返回签名从一长串嵌套类型简化为 AnyPublisher<Int, Error>。这种擦除不仅方便阅读,也避免暴露内部实现细节。
import Combine
func fetchNumberPublisher() -> AnyPublisher<Int, Error> {
return URLSession.shared.dataTaskPublisher(for: URL(string: "https://ipipp.com")!)
.map { $0.data.count }
.mapError { $0 as Error }
.eraseToAnyPublisher()
}
还有 AnyHashable、AnySequence 等,都是通过包装统一了不同具体类型。它们的内部思路与上面的基类方案或闭包方案类似:公开一个非泛型或部分泛型的具体类型,将协议中的关联类型消化在实现里。标准库的实现通常经过高度优化,但概念上并没有神秘之处。
实战:依赖注入与异构集合
在一个真实模块里,数据来源可能包括本地缓存、远程接口和内存临时数据。它们都遵循同一个 DataStore 协议,但每个实现返回的模型类型和存储方式不同。通过 AnyDataStore,可以把它们统一注入到服务层。
struct User {
let id: Int
let name: String
}
class LocalUserStore: DataStore {
typealias Model = User
private var cache: [User] = []
func save(_ model: User) {
cache.append(model)
}
func loadAll() -> [User] {
cache
}
}
class RemoteUserStore: DataStore {
typealias Model = User
private var buffer: [User] = []
func save(_ model: User) {
buffer.append(model)
}
func loadAll() -> [User] {
buffer
}
}
let stores: [AnyDataStore<User>] = [
AnyDataStore(LocalUserStore()),
AnyDataStore(RemoteUserStore())
]
for store in stores {
store.save(User(id: 1, name: "Alice"))
print(store.loadAll())
}
这个例子里的数组 [AnyDataStore<User>] 已经是具体类型,因此可以作为属性、函数参数或返回值正常传递。循环内部对每个元素调用 save 和 loadAll,底层分发到 LocalUserStore 或 RemoteUserStore。如果把存储实现换成数据库或文件,服务层代码不需要改动。
当然,类型擦除不等于万能。每新增一个带关联类型的协议,通常需要为它单独写一个包装类型。虽然可以用代码生成或宏减少样板,但维护成本依然存在。另外,擦除后编译器失去了对具体类型的可见性,也就无法进行跨模块泛型特化。这是换取抽象边界时必须接受的代价。
性能取舍与选择建议
存在类型和类型擦除都涉及运行时间接调用,性能特性并不相同。存在类型的容器大小固定,小值可以直接内联在容器内,大值才会堆分配。手工类型擦除如果用闭包捕获对象,通常一开始就会产生堆分配;如果用基类加子类,也会通过虚表派发。相比之下,泛型函数在编译期针对每个调用类型生成特化版本,能够内联并省略动态派发。
| 方案 | 适用协议 | 存储开销 | 扩展方式 |
|---|---|---|---|
| 存在类型 any Shape | 无关联类型、无 Self | 小值内联,大值堆分配 | 增加具体类型简单 |
| 手工类型擦除 AnyDataStore | 带 associatedtype 或 Self | 闭包或基类通常堆分配 | 每个协议需手工包装 |
| 泛型函数加特化 | 不限 | 编译期确定,无运行时盒子 | 无法直接构成异构集合 |
选择时可以先看协议本身。无关联类型、无 Self 约束,且需要异构集合,优先用 any。协议有关联类型,或你希望对外隐藏复杂的具体类型,类型擦除是更合理的选择。如果类型数量固定且调用频繁,可以先用枚举列出所有具体类型,再通过泛型函数或 switch 做分派,这样既保留静态类型信息,又避免堆分配。
需要特别留意,any 和 AnyDataStore 解决的是不同层面的问题。前者由编译器生成容器,后者由你手动构造容器。混用并不会自动提升抽象能力,反而可能带来双重间接调用。先明确要擦除的是类型信息还是协议关联类型,再决定实现路径,代码会清晰很多。

