使用 Swift 协议时,总会遇到一道墙:尝试把 Equatable 当作变量类型,或把 Collection 放进属性时,会出现大量关联类型错误。曾经臭名昭著的 “Protocol can only be used as a generic constraint because it has Self or associated type requirements” 就是典型例子。这道墙的真面目就是关联类型(associatedtype)。
这是中级系列第 4 篇。本文整理关联类型是什么、为什么会出现那个错误,以及发展到 primary associated types 的语言演进。读过泛型篇和 some·any 篇后,就已经具备全部基础了。
什么是关联类型 — 协议中的类型空位
在泛型篇中,我们把 <T>总结为“稍后填入类型的空位”。关联类型就是把这个空位放进协议中的版本。
假设要创建一个容器协议。我们希望抽象出栈和队列共有的“放入并取出元素”能力,但问题在于元素类型。IntStack 的元素是 Int,StringQueue 的元素是 String,因此协议阶段无法确定元素类型,只能将其声明为空位。
protocol Container {
associatedtype Item
mutating func append(_ item: Item)
var count: Int { get }
subscript(i: Int) -> Item { get }
}
采用该协议的类型会填入空位。IntStack 将 Item 填为 Int,StringQueue 填为 String。大多数情况下,甚至不需要明确写出 typealias Item = Int,因为编译器会根据 append 的参数类型进行推断。
事实上,这并不是新概念。标准库的骨架全部由关联类型构成:Collection 的 Element 和 Index、IteratorProtocol 的 Element,以及泛型篇 where 子句中出现的 C.Element,都是指向这个空位的语法。就连 Equatable 也有作为关联类型近亲的 Self 要求(static func == (lhs: Self, rhs: Self) -> Bool)。总之,关联类型是协议世界的一等公民,无法绕开。
用一句话概括它与泛型 <T>的区别:泛型参数由使用方填入,关联类型由采用方填入。Stack<Int>由使用者选择 Int,而 Container 的 Item 则由 IntStack 类型自行决定。
错误原因 — 无法确定容器规格
现在可以剖析那个臭名昭著的错误了。为什么包含关联类型的协议不能放在类型位置使用?
在 some·any 篇中,我们把协议类型变量称为存在类型,也就是一个容器。写成 var c: Container,意味着创建一个“装有某个遵循 Container 的对象的容器”。但从容器中取出元素时,问题出现了。c[0]的类型是什么?它是 Item,但 Item 的具体类型取决于容器里实际装的内容。IntStack 是 Int,StringQueue 是 String。编译器只看容器本身,无法回答这个问题。
无法确定类型的表达式在静态类型语言中不被允许,因此旧版 Swift 会直接在入口处拦截。这个错误的实际含义是:“这个协议只能作为约束使用。”使用泛型 <C: Container>时,C 会在调用时确定,C.Item 也会同时确定,因此没有问题。错误信息虽然不友好,但“使用泛型而不是容器”确实是准确的解决方案。
语言演进 — 门闩逐一解除的历史
这个不便长期臭名远扬,而 Swift 通过几项提案逐步解除限制。
**Swift 5.7 的 Swift Evolution 提案 SE-0309。**包含关联类型的协议也可以使用 any 了。var c: any Container可以编译。不过,从容器中取出的 Item 会被视为“未知类型”,因此可执行的操作仍然有限。门开了,但里面能做的事还不多。
**同一时期,SE-0346 引入了 primary associated types。**真正的游戏规则改变者就是它:协议声明可以用尖括号公开主要关联类型。
protocol Container<Item> {
associatedtype Item
// ...
}
var numbers: any Container<Int> // Item是 Int容器
func process(_ c: some Container<Int>) // 在泛型中也能保持简洁
any Container<Int>是一个“Item 已确定为 Int 的容器”。编译器知道取出的元素是 Int,容器的可用性因此大幅提升。标准库也使用这种语法进行了重构。从那时起,就可以写成 any Collection<String>、some Sequence<Int>这样的形式。
理解这种演进方向很有帮助。“包含关联类型的协议不能作为类型使用”这一绝对规则,变成了“可以使用,但能力会随着未确定的关联类型而减少”这一精确规则。这是 some·any 篇所见理念的延伸:从禁止转向明确成本。
实战模式 — 如何与关联类型协作
让我们把理论带入实际场景。
**设计时:关联类型适用于“每个采用者都会确定一个不同类型”的位置。**Repository 协议就是很好的例子。加入 associatedtype Entity后,UserRepository 填入 User,OrderRepository 填入 Order。如果类型与采用者无关,而应由使用方选择,那么应该使用泛型函数或泛型类型,而不是协议关联类型。
**使用时:默认选择泛型约束;需要存储和混合时,使用填入 primary associated type 的 any。**优先像 func sync<R: Repository>(_ repo: R) where R.Entity == User这样作为约束,只有需要存储时才使用 var repos: [any Repository<User>]。
**遇到阻碍时:最后手段是类型擦除的 AnyX 模式。**如果即使使用 primary associated type 也无法解决复杂约束,就只能像标准库的 AnySequence 或 Combine 的 AnyPublisher 一样,用具体类型包装并隐藏关联类型,手动进行类型擦除(type erasure)。不过 SE-0346 之后,直接这样做的场景大幅减少了。重新编写 AnyX 包装器前,应先确认能否通过 primary associated type 解决。
再说一个 Self 要求。像 ==这样的操作只有“相同类型之间”才有意义,因此使用 Self 声明。正因为如此,两个 any Equatable容器不能直接比较,因为容器中的类型可能不同。这种场景的标准做法是放弃容器,改用泛型解决。
总结
- 关联类型是协议中的类型空位,由采用它的类型填入,通常可以通过推断完成。标准库的骨架也建立在其上,例如 Collection 的 Element。
- 旧错误的真相:由于无法确定从存在类型容器中取出的值的关联类型,入口才被拦截。使用泛型约束时,原本就不存在这个问题。
- SE-0309 开放了 any 的使用,SE-0346 的 primary associated types(
any Container<Int>)则让容器变得实用。 - 实战顺序:优先使用泛型约束,其次使用填入 primary associated type 的 any,手动类型擦除放在最后。
下一篇是更适合动手实践的主题:从实战角度整理 map、filter、reduce,以及 compactMap 与 flatMap 的区别,并延伸到 lazy 序列的性能。
参考资料
- SE-0309: Unlock existentials for all protocols
- SE-0346: Lightweight same-type requirements for primary associated types

![[Swift 中级 #4] 掌握 Swift 关联类型(associatedtype) 封面图](/assets/images/posts/843650cf-8f58-411e-9ef4-14639a9f6490/swift-associatedtype-1.jpg)