类型擦除(Type Erasure)是一种编程技术,用于在运行时隐藏具体的类型信息,使得不同类型可以通过统一的接口进行操作。在iOS开发中,Objective-C和Swift都有类型擦除的概念,但实现方式和应用场景有所不同。
Swift中的类型擦除
Swift是一门强类型语言,类型信息在编译时被严格检查。但在某些场景下,我们需要隐藏具体类型信息,这时就需要使用类型擦除技术。
存在类型与不透明类型
在理解类型擦除之前,需要先理解 Swift 类型系统中两个关键概念:存在类型(Existential Type) 和不透明类型(Opaque Type)。它们是协议在类型层面的两种使用方式,也是理解 any 和 some 关键字的基础。
存在类型(Existential Type)
“存在类型"这个术语来自类型论中的存在量词(∃)。它表达的语义是:“存在某个类型 T 遵循了协议 P,但我不知道也不关心 T 具体是什么。”
当你把一个协议当作类型来使用时(而非泛型约束),你就在使用存在类型:
protocol Animal {
func makeSound() -> String
}
struct Dog: Animal {
func makeSound() -> String { "Woof!" }
}
struct Cat: Animal {
func makeSound() -> String { "Meow!" }
}
// animals 数组中的每个元素都是存在类型
// 编译器不知道每个元素的具体类型,只知道"存在某个类型遵循了 Animal"
var animals: [any Animal] = [Dog(), Cat()]
存在类型的核心特征:
- 运行时多态:具体类型在运行时才确定,同一个变量可以在不同时刻持有不同的具体类型
- 有性能开销:Swift 通过存在容器(Existential Container)来存储值,方法调用需要通过见证表间接派发
- 类型信息被隐藏:调用方只能通过协议接口与值交互,无法访问具体类型的特有成员
在 Swift 5.6 之前,直接把协议写成类型(如 let x: Animal)就是存在类型,但语法上没有任何标记。Swift 5.6 引入 any 关键字使其变得显式,目的是提醒开发者这里存在运行时开销。Swift 5.7 起,对于带有关联类型的协议,必须使用 any 才能作为存在类型。
不透明类型(Opaque Type)
与存在类型对应,不透明类型来自全称量词(∀)的思想。使用 some 关键字声明,它表达的语义是:“有一个确定的具体类型遵循了协议 P,但我不告诉你是哪个。”
// 编译器知道返回的就是 Dog,但调用者只知道是 "some Animal"
func makePet() -> some Animal {
return Dog()
}
不透明类型的核心特征:
- 编译时确定:具体类型在编译时就已知,编译器可以进行静态派发和内联优化
- 零额外开销:不需要存在容器,性能与直接使用具体类型相同
- 类型一致性约束:同一位置必须始终是同一种具体类型(函数的所有返回路径必须返回相同类型)
两者的关系
| 维度 | 存在类型 any P | 不透明类型 some P |
|---|---|---|
| 谁知道具体类型 | 只有值自身知道,调用方和编译器都不知道 | 编译器知道,但对调用方隐藏 |
| 灵活性 | 同一变量可持有不同具体类型 | 同一位置只能是一种具体类型 |
| 性能 | 存在容器 + 见证表间接调用 | 无额外开销,可内联 |
| 典型场景 | 异构集合、动态分派 | 返回类型、属性类型、SwiftUI 的 body |
理解这两个概念后,就能明白为什么 Swift 需要类型擦除——存在类型天然就是一种类型擦除机制,但它存在局限性(尤其是对带有关联类型的协议),因此需要更多的技术手段来解决。
为什么Swift需要类型擦除
协议关联类型的限制
在Swift中,当协议带有关联类型(Associated Type)或Self约束时,这样的协议被称为PAT(Protocol with Associated Types)。PAT协议无法直接作为类型使用:
protocol Container {
associatedtype Item
func add(_ item: Item)
func get(at index: Int) -> Item
}
// 错误:Protocol 'Container' can only be used as a generic constraint
// because it has Self or associated type requirements
let containers: [Container] = [] // 编译错误
这是因为编译器无法确定Item的具体类型,无法为其分配内存或进行方法派发。
协议作为返回类型的限制
同样,带有关联类型的协议不能直接作为函数返回类型:
// 错误:无法直接返回带有关联类型的协议
func makeContainer() -> Container { } // 编译错误
Swift类型擦除的实现方式
1. 使用any关键字(Swift 5.6+)
如前面"存在类型与不透明类型"一节所述,存在类型是 Swift 内建的类型擦除机制。Swift 5.6 引入 any 关键字使其语法显式化,让开发者清楚地知道哪里使用了存在类型及其运行时开销:
protocol Animal {
associatedtype Food
func eat(_ food: Food)
func makeSound() -> String
}
struct Dog: Animal {
func eat(_ food: String) {
print("Dog eating \(food)")
}
func makeSound() -> String { return "Woof!" }
}
struct Cat: Animal {
func eat(_ food: Int) {
print("Cat eating \(food)")
}
func makeSound() -> String { return "Meow!" }
}
// 使用any进行类型擦除
let animals: [any Animal] = [Dog(), Cat()]
for animal in animals {
print(animal.makeSound())
}
// 函数参数和返回值
func processAnimal(_ animal: any Animal) {
print(animal.makeSound())
}
func getRandomAnimal() -> any Animal {
return Bool.random() ? Dog() : Cat()
}
any关键字的作用:
- 显式标记存在类型,提醒开发者此处有存在容器和见证表的运行时开销
- 提高代码可读性,区分"协议作为约束"和"协议作为类型"两种用法
- 为编译器提供明确意图,便于未来优化
2. 手动实现类型擦除包装器
在Swift 5.6之前,或者需要更精细控制时,可以手动创建类型擦除包装器。最经典的方式是使用闭包捕获具体类型的方法实现:
// 定义协议
protocol Animal {
associatedtype Food
func eat(_ food: Food)
func makeSound() -> String
}
// 具体实现
struct Dog: Animal {
typealias Food = String
func eat(_ food: String) { print("Dog eating \(food)") }
func makeSound() -> String { return "Woof!" }
}
struct Cat: Animal {
typealias Food = String
func eat(_ food: String) { print("Cat eating \(food)") }
func makeSound() -> String { return "Meow!" }
}
// 类型擦除包装器
class AnyAnimal<Food>: Animal {
private let _eat: (Food) -> Void
private let _makeSound: () -> String
init<A: Animal>(_ animal: A) where A.Food == Food {
_eat = animal.eat
_makeSound = animal.makeSound
}
func eat(_ food: Food) {
_eat(food)
}
func makeSound() -> String {
return _makeSound()
}
}
// 使用类型擦除
let animals: [AnyAnimal<String>] = [
AnyAnimal(Dog()),
AnyAnimal(Cat())
]
for animal in animals {
print(animal.makeSound())
animal.eat("food")
}
为什么这种方式能绕开 PAT 的限制?
带关联类型的协议不能直接当类型用,因为编译器不知道关联类型具体是什么,无法确定内存布局和方法签名。这种方式的策略是把"协议 + 关联类型"的问题转化为"泛型类 + 泛型参数"的问题——AnyAnimal<String> 是一个完整的具体类型,编译器能够处理。
类型擦除发生在哪一步?
关键在 init 中的赋值。init 的泛型参数 <A: Animal> 知道 A 的具体类型(比如 Dog),所以 animal.eat 是一个类型完全确定的方法引用 (String) -> Void。当它被赋值给 _eat: (Food) -> Void 后,闭包内部通过捕获持有了 Dog 实例及其方法实现,但闭包的签名只暴露 (Food) -> Void——具体类型 Dog 从此不再出现在任何类型签名中,这就是擦除发生的时刻。
之后调用 _eat(food) 时,闭包内部实际执行的是 Dog.eat(_:),但调用方只看到 (Food) -> Void,不知道也不需要知道背后是 Dog。
本质:闭包天然具有类型擦除能力——它对外只暴露输入输出签名 (Input) -> Output,内部捕获了什么具体类型的值,外界完全不可见。这种方式就是把协议的每个方法要求逐一"拆"成闭包属性来利用这个特性。
3. 使用Box模式
另一种常见的实现方式是使用内部Box类,完整的类型擦除包装器通常包含三个组件:
protocol Drawable {
associatedtype Output
func draw() -> Output
}
// 1. 抽象基类
private class _AnyDrawableBase<Output>: Drawable {
func draw() -> Output {
fatalError("Must be overridden")
}
}
// 2. 具体Box类
private class _AnyDrawableBox<T: Drawable>: _AnyDrawableBase<T.Output> {
private let _base: T
init(_ base: T) {
self._base = base
}
override func draw() -> Output {
return _base.draw()
}
}
// 3. 公开的类型擦除包装器
struct AnyDrawable<Output>: Drawable {
private let _box: _AnyDrawableBase<Output>
init<T: Drawable>(_ drawable: T) where T.Output == Output {
_box = _AnyDrawableBox(drawable)
}
func draw() -> Output {
return _box.draw()
}
}
类型擦除发生在哪一步?
这里有一个类型"传递消失"的过程:
_AnyDrawableBox<T>持有具体类型T(比如Circle),它知道T的一切。但它继承自_AnyDrawableBase<T.Output>,即_AnyDrawableBase<String>。_AnyDrawableBase<Output>的泛型参数只有Output,没有T。当_AnyDrawableBox<Circle>被向上转型为_AnyDrawableBase<String>赋值给_box时,泛型参数Circle就从类型签名中消失了——这就是擦除发生的时刻。- 之后调用
_box.draw()时,通过 class 的虚方法表(vtable)派发,运行时实际执行的是子类_AnyDrawableBox<Circle>重写的draw()实现,该实现内部调用了Circle.draw()。
本质:利用面向对象的子类型多态——父类引用可以指向子类实例,调用时走虚函数派发。具体类型信息藏在子类里,但父类的类型签名中只保留了泛型参数 Output,具体的 T 被继承层级"吞掉"了。
与闭包方式的对比:
| 维度 | 闭包捕获 | Box 模式 |
|---|---|---|
| 擦除机制 | 闭包天然隐藏捕获值的类型 | 子类向上转型时丢弃子类的泛型参数 |
| 运行时派发 | 闭包间接调用 | 虚方法表(vtable)派发 |
| 代码量 | 少,每个方法对应一个闭包属性 | 多,需要抽象基类 + Box 子类 + 公开包装器 |
| 能否持有原始对象 | 不能(拆散成独立闭包后原始引用丢失) | 能(_base 持有完整原始对象) |
最后一点差异值得注意:如果需要从包装器中还原出原始对象(比如做类型判断、相等性比较),Box 模式可以做到,而闭包方式做不到。
4. Swift标准库中的类型擦除
Swift标准库提供了多个类型擦除包装器:
AnySequence
let array = [1, 2, 3, 4, 5]
let set: Set = [6, 7, 8, 9, 10]
// 将不同类型的序列统一为AnySequence
let sequences: [AnySequence<Int>] = [
AnySequence(array),
AnySequence(set)
]
for sequence in sequences {
for element in sequence {
print(element)
}
}
AnyCollection / AnyBidirectionalCollection / AnyRandomAccessCollection
let array = [1, 2, 3]
let anyCollection = AnyCollection(array)
print(anyCollection.count) // 3
print(anyCollection.first) // Optional(1)
AnyIterator
var count = 0
let iterator = AnyIterator<Int> {
count += 1
return count <= 5 ? count : nil
}
for value in IteratorSequence(iterator) {
print(value) // 1, 2, 3, 4, 5
}
AnyHashable
let intValue: Int = 42
let stringValue: String = "Hello"
// AnyHashable可以包装任何Hashable类型
let hashables: [AnyHashable] = [
AnyHashable(intValue),
AnyHashable(stringValue)
]
// 可以用作字典的键
var dict: [AnyHashable: String] = [:]
dict[AnyHashable(42)] = "forty-two"
dict[AnyHashable("key")] = "value"
标准库包装器的内部实现
这些标准库包装器在内部采用的是前面介绍的 Box 模式,而非闭包捕获方式。以 AnySequence 为例,其内部结构与 _AnyDrawableBase / _AnyDrawableBox / AnyDrawable 的三层架构完全对应:
// 简化后的标准库实现
public struct AnySequence<Element>: Sequence {
internal let _box: _AnySequenceBox<Element>
public init<S: Sequence>(_ base: S) where S.Element == Element {
self._box = _SequenceBox(base) // 具体类型 S 在此处被向上转型擦除
}
}
// 抽象基类:只保留泛型参数 Element,不包含具体序列类型
internal class _AnySequenceBox<Element> {
func makeIterator() -> AnyIterator<Element> { fatalError() }
}
// 具体 Box 子类:持有原始序列,具体类型 S 藏在子类中
internal final class _SequenceBox<S: Sequence>: _AnySequenceBox<S.Element> {
let _base: S
override func makeIterator() -> AnyIterator<S.Element> {
return AnyIterator(_base.makeIterator())
}
}
选择 Box 模式而非闭包捕获有两个原因:一是标准库协议(Sequence、Collection 等)方法数量多且关系复杂,如果每个方法都拆成闭包属性会导致代码膨胀,Box 模式通过继承只需在子类中 override 必要方法;二是 Box 模式的 _base 保留了完整的原始对象引用,标准库内部在做性能优化时(比如判断底层是否为 Array 来走快速路径)需要能拿到原始值。
5. some vs any —— 不透明类型与存在类型的实际选择
前面"存在类型与不透明类型"一节介绍了两者的理论区别,这里通过代码来展示如何在实际开发中做选择:
protocol Shape {
func draw() -> String
}
struct Circle: Shape {
func draw() -> String { return "Circle" }
}
struct Square: Shape {
func draw() -> String { return "Square" }
}
// some(不透明类型):编译器知道具体类型是 Circle,可以静态派发
// 所有返回路径必须返回相同的具体类型
func makeSomeShape() -> some Shape {
return Circle()
}
// any(存在类型):运行时才确定具体类型,可以返回不同类型
func makeAnyShape() -> any Shape {
if Bool.random() {
return Circle()
} else {
return Square()
}
}
// 泛型约束:同构集合(所有元素必须是同一具体类型),编译器可优化
func processAll<T: Shape>(_ items: [T]) {
for item in items { print(item.draw()) }
}
// 存在类型:异构集合(元素可以是不同具体类型),有运行时开销
func processHeterogeneous(_ items: [any Shape]) {
for item in items { print(item.draw()) }
}
选择原则:当不需要异构能力时,优先用 some 或泛型约束,让编译器获得最大优化空间;只在需要"同一容器中存放不同具体类型"时才使用 any。这也是 SwiftUI 中 body 属性声明为 some View 而非 any View 的原因。
Swift类型擦除的底层原理
前面提到存在类型有运行时开销,这个开销的来源就是存在容器(Existential Container)。当你声明 any Animal 时,Swift 并不是简单地存一个指针,而是为每个值创建一个存在容器:
struct ExistentialContainer {
void* valueBuffer[3]; // 值缓冲区(24字节)
TypeMetadata* type; // 类型元数据指针
WitnessTable* witnessTable; // 协议见证表指针
};
三个组成部分各司其职:
- 值缓冲区(Value Buffer):24字节的内联存储空间。如果具体类型的大小 ≤ 24字节(如简单的 struct),值直接存在这里;如果超过 24 字节,则在堆上分配内存,缓冲区退化为存储堆指针。这就是为什么小类型的存在类型性能比大类型好。
- 类型元数据(Type Metadata):指向具体类型的元数据,运行时通过它来执行内存分配、拷贝、销毁等操作。这让存在容器能够正确管理它"不认识"的具体类型。
- 协议见证表(Protocol Witness Table):类似 C++ 的虚表(vtable),存放协议中每个方法要求对应的函数指针。当你对
any Animal调用makeSound()时,运行时通过见证表找到具体类型实现的那个函数,再进行间接调用。
这解释了存在类型与不透明类型的性能差异:some Animal 在编译时就知道具体类型,编译器直接生成对该类型方法的调用(静态派发),甚至可以内联;而 any Animal 每次调用都要经过见证表的间接跳转,编译器无法进行这些优化。
当协议遵循多个协议时(如 any Hashable & Comparable),存在容器中会包含多个见证表指针,开销也随之增加。
Objective-C中的类型擦除
天然的类型擦除特性
Objective-C作为一门动态语言,天然具有类型擦除的特性。所有Objective-C对象在运行时都可以被视为id类型,这是一种内建的类型擦除机制。
1. id类型
id是Objective-C中最典型的类型擦除机制。它可以指向任何Objective-C对象,编译器不会进行类型检查:
// id可以指向任何对象
id anyObject = [[NSString alloc] initWithString:@"Hello"];
anyObject = [[NSNumber alloc] initWithInt:42];
anyObject = [[NSArray alloc] init];
// 运行时通过消息派发确定方法调用
[anyObject description]; // 有效,因为所有NSObject子类都响应description
使用id类型时,具体的类型信息被"擦除"了,编译器只知道这是一个对象指针。方法调用通过运行时的消息派发机制(objc_msgSend)来解析。
2. 泛型集合的类型擦除
Objective-C在iOS 9引入了轻量级泛型(Lightweight Generics),在编译时提供类型检查,但在运行时类型信息会被擦除:
// 声明一个只包含NSString的数组
NSMutableArray<NSString *> *stringArray = [NSMutableArray array];
[stringArray addObject:@"Hello"];
// 编译时警告,但运行时可以执行
// [stringArray addObject:@42]; // 警告:Incompatible pointer types
// 运行时类型信息被擦除
NSArray *rawArray = stringArray; // 泛型信息丢失
// 类型擦除后可以存储不同类型
NSMutableArray *mixed = [NSMutableArray array];
[mixed addObject:@"string"];
[mixed addObject:@123];
[mixed addObject:[NSDate date]];
Objective-C的泛型仅用于编译时类型检查和代码补全,不会影响运行时行为。这与Java的泛型类型擦除机制类似。
3. Protocol类型
协议类型也是一种类型擦除形式:
@protocol Drawable <NSObject>
- (void)draw;
@end
// 使用协议类型,具体类型被擦除
id<Drawable> drawable = [[Circle alloc] init];
drawable = [[Rectangle alloc] init];
// 只能调用协议定义的方法
[drawable draw];
4. NSProxy实现完全类型擦除
NSProxy是Objective-C中实现完全类型擦除的强大工具。它是一个抽象基类,专门用于实现代理对象,通过消息转发机制将所有方法调用转发给被代理的对象。
工作原理:
NSProxy不继承自NSObject,它是Objective-C中另一个根类- 当向
NSProxy子类发送消息时,如果该类没有实现对应方法,会触发消息转发 - 通过重写
methodSignatureForSelector:和forwardInvocation:方法,可以将消息转发给任意目标对象
@interface TypeErasedProxy : NSProxy
@property (nonatomic, strong) id target;
- (instancetype)initWithTarget:(id)target;
@end
@implementation TypeErasedProxy
- (instancetype)initWithTarget:(id)target {
_target = target;
return self;
}
// 返回目标对象的方法签名,告诉运行时如何构造NSInvocation
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
return [self.target methodSignatureForSelector:sel];
}
// 将方法调用转发给目标对象
- (void)forwardInvocation:(NSInvocation *)invocation {
[invocation invokeWithTarget:self.target];
}
@end
// 使用
NSString *string = @"Hello World";
TypeErasedProxy *proxy = [[TypeErasedProxy alloc] initWithTarget:string];
// proxy的类型是TypeErasedProxy,但可以响应NSString的所有方法
NSLog(@"%@", [proxy uppercaseString]); // 输出:HELLO WORLD
这种方式实现了完全的类型擦除:调用方只知道proxy是一个TypeErasedProxy对象,完全不知道内部包装的是NSString,但却能调用NSString的所有方法。
类型擦除的应用场景
1. 异构集合
当需要在同一容器中存储遵循同一协议的不同类型时:
protocol Validator {
associatedtype Value
func validate(_ value: Value) -> Bool
}
struct EmailValidator: Validator {
func validate(_ value: String) -> Bool {
return value.contains("@")
}
}
struct AgeValidator: Validator {
func validate(_ value: Int) -> Bool {
return value >= 0 && value <= 120
}
}
// 使用类型擦除存储不同类型的验证器
class AnyValidator<T>: Validator {
private let _validate: (T) -> Bool
init<V: Validator>(_ validator: V) where V.Value == T {
_validate = validator.validate
}
func validate(_ value: T) -> Bool {
return _validate(value)
}
}
let emailValidator = AnyValidator(EmailValidator())
let ageValidator = AnyValidator(AgeValidator())
2. 依赖注入
protocol DataService {
associatedtype Data
func fetch() -> Data
}
class AnyDataService<T>: DataService {
private let _fetch: () -> T
init<S: DataService>(_ service: S) where S.Data == T {
_fetch = service.fetch
}
func fetch() -> T {
return _fetch()
}
}
class ViewModel {
let dataService: AnyDataService<[String]>
init(dataService: AnyDataService<[String]>) {
self.dataService = dataService
}
}
类型擦除的优缺点
优点
- 增加灵活性:允许在同一容器中存储不同具体类型的对象
- 简化API:隐藏复杂的泛型约束,提供简洁的接口
- 解耦合:调用方不需要知道具体实现类型
- 支持异构集合:可以创建包含不同类型元素的集合
缺点
- 性能开销:额外的包装层会带来间接调用的开销
- 类型安全性降低:运行时可能出现类型错误
- 调试困难:类型信息的丢失使得调试和错误追踪更困难
- 增加代码复杂度:手动实现类型擦除包装器需要更多代码
最佳实践
Swift中的建议
- 优先使用
some和泛型约束:不透明类型和泛型在编译时确定具体类型,无存在容器开销,应作为首选 - 需要异构能力时使用
any:在 Swift 5.6+ 中,当需要在同一容器中混合不同具体类型时,使用any标记存在类型 - 利用标准库:使用
AnySequence、AnyIterator等标准库类型 - 按需创建:只在确实需要且
any无法满足时,才手动实现类型擦除包装器
// 优先使用泛型约束
func process<T: Drawable>(_ items: [T]) {
for item in items {
item.draw()
}
}
// 必要时才使用存在类型
func processHeterogeneous(_ items: [any Drawable]) {
for item in items {
item.draw()
}
}
泛型约束之所以更高效,是因为编译器在开启优化时会进行泛型特化(Generic Specialization):当看到调用点的具体类型(如 process([Circle()]),T = Circle),编译器会为该类型生成一份专门的函数副本,其中方法调用变为静态派发,还可以进一步内联。而 any Drawable 的每个元素类型可能不同,每次方法调用都必须通过见证表间接跳转,无法特化和内联。不过需要注意,泛型特化依赖编译器优化(-O),Debug 模式下泛型同样走见证表派发。
Objective-C中的建议
- 使用协议:通过协议定义接口契约
- 合理使用id:在需要灵活性时使用
id,但注意类型安全 - 运行时检查:使用
isKindOfClass:和respondsToSelector:进行运行时检查
// 推荐:使用协议约束
- (void)processItems:(NSArray<id<MyProtocol>> *)items {
for (id<MyProtocol> item in items) {
if ([item respondsToSelector:@selector(process)]) {
[item process];
}
}
}
常见面试题
Q1: 什么是类型擦除?为什么Swift需要类型擦除?
类型擦除(Type Erasure)是一种在运行时隐藏具体类型信息的技术,使得不同类型可以通过统一接口操作。
Swift 需要类型擦除的直接原因是 PAT(Protocol with Associated Types)协议的限制:当协议带有关联类型或 Self 约束时,它无法直接作为类型使用,因为编译器不知道关联类型的具体绑定,无法确定内存布局和方法签名:
protocol Container {
associatedtype Item
func add(_ item: Item)
func get(at index: Int) -> Item
}
// 编译错误:Protocol 'Container' can only be used as a generic constraint
// because it has Self or associated type requirements
let containers: [Container] = []
编译器不知道 Item 到底是 String 还是 Int,就无法决定 add 方法接受什么参数、get 方法返回什么类型,也无法为容器中的值分配正确大小的内存。
类型擦除通过两种策略绕开这一限制:
- 存在类型(
any):编译器为值创建存在容器,在运行时通过见证表间接派发,不需要在编译时知道具体类型 - 手动包装器(
AnyXxx<T>):将协议层面的关联类型转化为泛型参数,AnyContainer<String>是一个完整的具体类型,编译器能处理
Q2: Swift 中的存在类型和不透明类型有什么区别?
两者都是协议在类型层面的使用方式,核心区别在于谁知道具体类型以及何时确定。
存在类型(any P):来自类型论的存在量词 ∃,表示"存在某个遵循协议 P 的类型,但不关心具体是哪个”。具体类型在运行时才确定,同一变量可以在不同时刻持有不同具体类型。底层通过存在容器(Existential Container)存储值,方法调用走协议见证表间接派发,有运行时开销:
// 存在类型:animals 数组中每个元素的具体类型可以不同
// 编译器不知道每个元素到底是 Dog 还是 Cat,运行时通过见证表派发
var animals: [any Animal] = [Dog(), Cat()]
// 同一变量可以在不同时刻持有不同类型
var pet: any Animal = Dog()
pet = Cat() // 合法
不透明类型(some P):来自全称量词 ∀,表示"有一个确定的具体类型遵循协议 P,但不告诉调用者是哪个"。具体类型在编译时就已确定,编译器可以静态派发和内联优化,零额外开销。但同一位置必须始终是同一种具体类型:
// 不透明类型:编译器知道返回的就是 Dog,但调用者只看到 "some Animal"
func makePet() -> some Animal {
return Dog()
}
// 编译错误:所有返回路径必须返回相同的具体类型
func makeRandomPet() -> some Animal {
if Bool.random() {
return Dog() // 返回 Dog
} else {
return Cat() // 返回 Cat —— 类型不一致,编译失败
}
}
Swift 5.6 引入 any 关键字使存在类型变为显式声明。在此之前,let x: Animal 就是存在类型,但语法上没有任何标记提醒开发者"这里有运行时开销"。any 的引入迫使开发者有意识地做选择:是用零开销的 some/泛型,还是用有开销但更灵活的 any。Swift 5.7 起,对于带有关联类型的协议,必须使用 any 才能作为存在类型。
Q3: any 和 some 分别在什么场景下使用?
用 some(不透明类型):当函数返回类型或属性类型需要隐藏具体实现,但始终返回同一种具体类型时。编译器知道具体类型,可以做静态派发和优化。典型例子是 SwiftUI 中的 var body: some View——编译器知道 body 每次返回的都是同一种 View 树结构:
struct ContentView: View {
// some View:编译器知道 body 的具体类型是 VStack<TupleView<(Text, Button<Text>)>>
// 但开发者不需要写出这个复杂类型,也不需要在每次修改 UI 时更新返回类型
var body: some View {
VStack {
Text("Hello")
Button("Tap") { }
}
}
}
用 any(存在类型):当需要在同一容器中混合不同具体类型(异构集合),或者函数在不同条件下需要返回不同具体类型时:
protocol Shape {
func area() -> Double
}
// 异构集合:Circle 和 Square 可以共存于同一数组
let shapes: [any Shape] = [Circle(radius: 5), Square(side: 3)]
// 动态返回不同类型
func randomShape() -> any Shape {
Bool.random() ? Circle(radius: 1) : Square(side: 1)
}
用泛型约束 <T: P>:当集合中所有元素类型相同(同构集合),且需要最大化性能时。编译器可以进行泛型特化,为具体类型生成专用的函数副本,实现静态派发甚至内联:
// 泛型约束:所有元素必须是同一具体类型
// 调用 process([Circle(), Circle()]) 时,编译器为 Circle 生成专用版本
func process<T: Shape>(_ items: [T]) {
for item in items {
print(item.area()) // 特化后变为直接调用 Circle.area(),可内联
}
}
// 这样调用会编译错误,因为 Circle 和 Square 不是同一类型
// process([Circle(radius: 5), Square(side: 3)]) // 错误
选择决策: some / 泛型约束 → 编译时确定,零开销,优先选择;any → 运行时确定,有开销,仅在需要异构能力时使用。
Q4: Swift 标准库中 AnySequence 等类型擦除包装器的内部原理是什么?
Swift 标准库的 AnySequence、AnyCollection、AnyIterator 等类型擦除包装器内部采用的是 Box 模式——通过抽象基类 + 具体 Box 子类 + 公开包装器的三层结构。以 AnySequence 为例,简化后的内部结构如下:
// 第一层:抽象基类,泛型参数只有 Element,没有具体的序列类型 S
internal class _AnySequenceBox<Element> {
func makeIterator() -> AnyIterator<Element> { fatalError() }
// Sequence 协议的其他方法要求...
}
// 第二层:具体 Box 子类,持有具体序列类型 S
internal final class _SequenceBox<S: Sequence>: _AnySequenceBox<S.Element> {
let _base: S
init(_ base: S) { self._base = base }
override func makeIterator() -> AnyIterator<S.Element> {
return AnyIterator(_base.makeIterator())
}
}
// 第三层:公开的类型擦除包装器
public struct AnySequence<Element>: Sequence {
internal let _box: _AnySequenceBox<Element>
public init<S: Sequence>(_ base: S) where S.Element == Element {
// 擦除发生在这里:_SequenceBox<Array<Int>> 向上转型为 _AnySequenceBox<Int>
// 具体类型 Array<Int> 从类型签名中消失
self._box = _SequenceBox(base)
}
public func makeIterator() -> AnyIterator<Element> {
return _box.makeIterator() // vtable 派发到子类的 override 实现
}
}
类型擦除的发生过程:
_SequenceBox<Array<Int>>持有具体的Array<Int>,它继承自_AnySequenceBox<Int>- 在
AnySequence.init中,_SequenceBox<Array<Int>>被赋给类型为_AnySequenceBox<Int>的_box属性——此时发生向上转型,泛型参数Array<Int>从类型签名中消失,只剩下Element(即Int) - 之后调用
_box.makeIterator()时,通过 class 的虚方法表(vtable)派发到子类_SequenceBox<Array<Int>>重写的实现,该实现内部调用了Array<Int>.makeIterator()
本质:利用面向对象的子类型多态——父类引用可以指向子类实例,具体类型信息藏在子类里,但父类的类型签名中只保留了泛型参数 Element,具体的序列类型 S 被继承层级"吞掉"了。
Q5: 存在类型的底层是怎么实现的?为什么有性能开销?
存在类型在底层通过存在容器(Existential Container) 实现。当声明 any Animal 时,Swift 为每个值创建如下结构:
struct ExistentialContainer {
void* valueBuffer[3]; // 值缓冲区(24字节)
TypeMetadata* type; // 类型元数据指针
WitnessTable* witnessTable; // 协议见证表指针
};
三个组成部分各司其职:
值缓冲区(Value Buffer,24字节):如果具体类型的大小 ≤ 24 字节(如简单 struct),值直接内联存储在这里;如果超过 24 字节,则在堆上分配内存,缓冲区退化为存储堆指针。这是为什么小类型的存在类型性能比大类型好。
类型元数据(Type Metadata):指向具体类型的元数据,运行时通过它来执行内存分配、拷贝、销毁等操作。这让存在容器能够正确管理它"不认识"的具体类型的值。
协议见证表(Protocol Witness Table):类似 C++ 的虚表(vtable),存放协议中每个方法要求对应的函数指针。当你对
any Animal调用makeSound()时,运行时通过见证表找到具体类型实现的那个函数,再进行间接调用。
性能开销来自三方面:
- 间接方法调用:每次方法调用都要通过见证表查函数指针再跳转,编译器无法内联
- 堆分配:大值类型(> 24 字节)会触发
malloc,带来分配和释放开销 - 多协议组合:当遵循多个协议时(如
any Hashable & Comparable),容器中包含多个见证表指针,开销随之增加
相比之下,some 和泛型约束在编译时确定具体类型,不需要存在容器,方法调用是静态派发可以内联,没有这些额外开销。
Q6: 泛型约束比存在类型更高效的原因是什么?
泛型约束 <T: P> 在编译时就确定了 T 的具体类型,编译器在开启优化(-O)时会进行泛型特化(Generic Specialization)。以一个具体例子说明:
func process<T: Shape>(_ items: [T]) {
for item in items {
item.area()
}
}
process([Circle(radius: 5), Circle(radius: 3)]) // 调用点:T = Circle
编译器看到 T = Circle,会生成等价于以下的特化版本:
// 编译器自动生成(等价逻辑,非真实代码)
func process_Circle(_ items: [Circle]) {
for item in items {
item.area() // 直接调用 Circle.area(),静态派发
}
}
特化后的优化链路:
- 内存布局已知:
Circle的大小在编译时确定,直接按值操作,不需要值见证表 - 方法地址已知:
Circle.area()的函数地址在编译时确定,直接call指令跳转,不需要查协议见证表 - 可以内联:如果
Circle.area()实现足够短,编译器将其函数体直接展开到循环内部,连函数调用开销都省了
而 any Shape 数组中每个元素的具体类型可能不同,编译器不可能为某一个类型特化,每次 item.area() 都必须查见证表间接跳转,也无法内联。
| 阶段 | 泛型 <T: Shape> | 存在类型 any Shape |
|---|---|---|
| 类型信息 | 编译时已知(特化后) | 运行时才知道 |
| 方法派发 | 静态派发 / 直接调用 | 见证表间接派发 |
| 内联 | 可以 | 不可能 |
| 内存操作 | 按已知大小直接操作 | 通过值见证表间接操作 |
| 函数副本 | 每个具体类型一份(空间换时间) | 只有一份通用版本 |
需要注意,泛型特化依赖编译器优化,Debug 模式(-Onone)下泛型同样走见证表派发,性能与 any 接近。关键区别在于泛型有能力被特化,而 any 在语义上就排除了这种可能。