广告位 · 页头(后台-模块管理-新建模块后替换此区域)

Swift存在类型与类型擦除设计模式该怎么实战?

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

Swift存在类型与类型擦除设计模式该怎么实战?

存在类型: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 解决的是不同层面的问题。前者由编译器生成容器,后者由你手动构造容器。混用并不会自动提升抽象能力,反而可能带来双重间接调用。先明确要擦除的是类型信息还是协议关联类型,再决定实现路径,代码会清晰很多。

广告位 · 文章底部(后台-模块管理替换此区域)