协议(protocol)声明类型必须提供的能力; 泛型(generic) 把具体类型留给调用点决定,同时保留输入、输出与关联类型之间的编译期关系。
把泛型参数改成 any Protocol 会擦除类型身份;只写在协议扩展中、却没有列为协议要求的同名成员,也不会按遵循类型动态替换。
先写清由谁选择具体类型,再使用最窄约束:调用方选择用泛型,返回实现隐藏固定类型用 some,确实需要异构值时才用 any。
是什么,为什么存在
协议描述一组可依赖的能力,不规定存储布局,也不要求类型通过继承获得这些能力。结构体、枚举和类都能提供协议要求的成员并声明遵循。调用方因此可以依赖一份明确契约,而不必知道具体实现类型。
泛型使用类型参数编写一次算法或容器,再由调用点提供具体类型。它与 Any 的关键差别不是能否接收多种类型,而是同一个类型参数会保留各位置之间的关系。func first<Element>(_ values: [Element]) -> Element? 能保证返回类型与数组元素类型相同。
两者组合后,协议成为泛型约束,泛型则让协议能力带着具体类型身份传播。T: Comparable 不表示 T 在运行时变成一个协议盒;它表示调用方选择某个具体 T,而函数体可以使用 Comparable 保证的操作。这种组合常见于集合算法、解码层、存储接口和依赖边界。
本主题关注协议与泛型相接的位置。协议声明、泛型语法各自的完整规则由前置主题介绍;这里要解决的是如何保留类型关系、如何表达关联类型,以及何时有意擦除具体类型。
工作原理
泛型声明中的 Value 是类型参数。每次调用都必须为同一次调用中的所有 Value 位置选择一个一致的具体类型。编译器根据实参与上下文推断它,也可以从显式类型标注取得信息。
泛型约束(generic constraint) 限制哪些类型可以替换类型参数,并允许函数体使用相应能力。<Value: Hashable> 是简短写法;涉及多个类型或依赖成员时,where Left.Element == Right.Element 更清楚。同类型要求表达完全相同的类型,不是可以互相转换的类型。
协议可以用 关联类型(associated type) 描述由遵循类型决定的相关类型。Sequence.Element 就是这种依赖成员:算法知道元素与序列存在关系,却不必预先指定元素是 String 还是 Int。遵循类型通常通过满足要求的方法和属性让编译器推断关联类型。
主关联类型(primary associated type)把选定的关联类型名称列在协议名后的尖括号中,便于写出 some Catalog<Product> 或 any Catalog<Product>。它仍然是关联类型,由遵循者确定;协议不会因此变成像 Array<Element> 那样的普通泛型类型。
三个相似写法把具体类型的选择权放在不同边界:
| 写法 | 谁选择具体类型 | 保留的关系 | 适合的边界 |
|---|---|---|---|
<T: P> | 每个调用方 | 所有 T 位置保持同一类型 | 算法与容器 |
-> some P | 返回实现 | 调用方看不到、但编译器保留一个固定底层类型 | 隐藏实现的返回值 |
any P | 赋值或存储位置 | 只保留协议暴露的接口 | 异构集合与运行时替换 |
参数位置的 some P 是一个不能在签名其他位置命名的泛型参数,具体类型仍由调用方选择。返回位置的 some P 则由实现方选择一个底层类型。不要只凭关键词判断,位置也是契约的一部分。
any P 表示 存在类型(existential type) 。一个存在值可以在不同时间保存不同遵循类型,因此具体身份被封装起来;能调用哪些成员取决于协议要求以及仍然可表达的关联类型关系。需要跨过这条边界时,应先确认调用方是否真的只需要协议接口。
协议要求会参与遵循关系的见证选择。协议扩展能提供要求的默认实现,也能增加便利成员;但只有前者属于协议契约。泛型代码通过协议约束调用一个仅存在于扩展中的成员时,会选择扩展实现,而不会把遵循类型上的同名成员当作可替换要求。
示例
下面四个独立程序从单一协议约束开始,再加入关联类型、some、any 和扩展分派。当前环境没有 Swift 工具链,因此每个代码块都按要求标明未执行;输出块保留确定性的预期转录,不能视为本次本地运行记录。
用协议约束泛型算法
printSummary 接受任意 Summarizable 具体类型。参数与协议能力都保留下来,函数体不需要类型转换或 switch。
// # not executed here: Swift toolchain is not installed.
protocol Summarizable {
var summary: String { get }
}
struct Ticket: Summarizable {
let id: Int
let title: String
let isOpen: Bool
var summary: String {
let state = isOpen ? "open" : "closed"
return "#\(id) \(state): \(title)"
}
}
func printSummary<Value: Summarizable>(_ value: Value) {
print(value.summary)
}
printSummary(Ticket(id: 42, title: "Login fails", isOpen: true))
printSummary(Ticket(id: 43, title: "Export works", isOpen: false))#42 open: Login fails
#43 closed: Export works每次调用都选择一个具体 Value,本例中是 Ticket。编译器仍然知道参数的完整类型,只把函数体允许使用的接口限制为 Summarizable 要求。新增另一个遵循类型不需要修改函数。
协议约束不能替代领域校验。Summarizable 只保证存在 summary,并不保证编号为正或标题非空;这些不变量应由 Ticket 的构造边界负责。
用关联类型连接协议成员
Catalog 的索引方法返回自己的 Item。泛型函数通过 Source.Item: NamedItem 继续约束这个关联类型,因此无需把结果降级成 Any。
// # not executed here: Swift toolchain is not installed.
protocol NamedItem {
var name: String { get }
}
protocol Catalog<Item> {
associatedtype Item
func item(at index: Int) -> Item?
}
struct ArrayCatalog<Element>: Catalog {
let items: [Element]
func item(at index: Int) -> Element? {
items.indices.contains(index) ? items[index] : nil
}
}
struct Product: NamedItem {
let name: String
}
func firstName<Source: Catalog>(_ source: Source) -> String
where Source.Item: NamedItem {
source.item(at: 0)?.name ?? "empty"
}
let products = ArrayCatalog(items: [Product(name: "Keyboard")])
let empty = ArrayCatalog<Product>(items: [])
print(firstName(products))
print(firstName(empty))Keyboard
emptyArrayCatalog<Product> 让 Item 推断为 Product。firstName 可以接受其他目录实现,但每个实现的返回值仍与自己的 Item 保持一致。越界由可选返回值表达,而不是强制解包。
主关联类型语法让约束位置更紧凑,却没有改变谁决定 Item。若 API 需要同时表达两个目录拥有相同元素类型,仍可以用 where Left.Item == Right.Item 写出关系。
区分 some 与 any
工厂始终返回 StaffBadge,所以 some Badge 隐藏实现而保留一个固定底层类型。数组需要同时保存员工与访客徽章,才使用 [any Badge]。
// # not executed here: Swift toolchain is not installed.
protocol Badge {
var label: String { get }
}
struct StaffBadge: Badge {
let name: String
var label: String { "staff:\(name)" }
}
struct GuestBadge: Badge {
let number: Int
var label: String { "guest:\(number)" }
}
func makeStaffBadge(name: String) -> some Badge {
StaffBadge(name: name)
}
let primary = makeStaffBadge(name: "Ana")
let badges: [any Badge] = [
primary,
GuestBadge(number: 7)
]
print(primary.label)
print(badges.map(\.label).joined(separator: ", "))staff:Ana
staff:Ana, guest:7some 不是「任意遵循类型」。同一个不透明返回声明必须有一个可确定的底层类型,不能在一个普通条件分支返回 StaffBadge,另一个分支返回 GuestBadge。需要这种运行时异构性时,返回 any Badge 或重新设计为统一具体类型。
存在值适合存储边界,但会隐藏具体身份。若后续算法必须让徽章类型与另一参数保持相同,泛型签名比先放进 any Badge 再强制转换更准确。
暴露扩展成员的静态分派陷阱
debugName() 只出现在协议扩展中,不是 Renderable 的要求。具体值直接调用时能看到 Report 的成员,泛型约束内调用时则使用扩展成员。
// # not executed here: Swift toolchain is not installed.
protocol Renderable {
func render() -> String
}
extension Renderable {
func debugName() -> String {
"protocol default"
}
}
struct Report: Renderable {
func render() -> String { "quarterly report" }
func debugName() -> String { "Report" }
}
func inspect<Value: Renderable>(_ value: Value) {
print(value.debugName())
print(value.render())
}
let report = Report()
print(report.debugName())
inspect(report)Report
protocol default
quarterly report如果 debugName() 必须允许每个遵循类型定制,就应把它写进协议声明,并让扩展提供默认实现。这样调用会通过协议要求选择对应见证。仅仅在具体类型上添加同名方法,不会追溯地把扩展便利成员变成要求。
这个差异在生成代码中很隐蔽,因为直接单元测试具体值可能通过,而真正的泛型辅助函数表现不同。测试必须覆盖 API 实际使用的静态类型边界。
陷阱
把 Any 当作泛型
修复方法: 输入与输出共享类型时,用同一个泛型参数表达关系。只有异构性本身属于数据模型,而且消费方只需要共同协议接口时,才在存储边界使用存在类型。
为整个类型添加过宽约束
修复方法: 把约束放在真正使用能力的最窄稳定边界上,通常是方法或受约束扩展。若底层存储的不变量始终依赖某项能力,约束才属于类型声明。
假定 some 能返回多个具体类型
修复方法: 若差异只是数据,统一为一个具体包装类型;若运行时替换就是契约,使用 any P。不要用 as! 或 Any 绕过不透明返回类型的限制。
把扩展便利成员当作协议要求
修复方法: 必须由遵循类型定制的操作要出现在协议声明中。分别以具体类型、泛型约束和 any P 值测试调用,确认分派结果符合契约。
用强制转换实现类型擦除
修复方法: 类型擦除(type erasure) 包装器应在构造时通过泛型约束验证关系,并保存类型正确的闭包。能直接使用 any P<Argument> 时,不要额外编写擦除层。
类型关系的所有权
设计签名时,先问谁拥有具体类型选择权。泛型参数把选择权交给每个调用方;不透明返回类型把选择权留给实现;存在类型允许值的提供方在运行时改变具体类型。这不是语法偏好,而是 API 两侧能作出哪些假设的边界。
同一个函数可以同时使用这些形式,但每一种都要有独立理由。例如,函数可以接收泛型解析器并返回 some Sequence<Token>,表示调用方选择解析器,实现方选择一种固定序列。若再把结果存入 [any Sequence<Token>],则是在更外层有意擦除不同序列的身份。
类型关系应尽量在首次知道它的位置写进签名。若两个参数必须共享元素类型,where Left.Element == Right.Element 比函数体里的运行时检查更早、更完整地表达失败。约束也会进入公开契约,因此不能为了让当前实现方便就无限扩大。
关联类型与主关联类型
关联类型适合表达一个遵循关系自带的类型成员。容器决定元素类型,解析器决定输出类型,存储决定实体类型;泛型算法再通过 C.Element、P.Output 等依赖成员引用它们。这个模型把选择权放在遵循类型,而不是每次方法调用。
主关联类型只选择哪些关联类型可以在协议名后的尖括号位置约束。声明 protocol Catalog<Item> 时,协议体仍要声明 associatedtype Item,而遵循者仍为它提供见证。它改善的是使用位置的表达力,不是把协议改写成泛型结构体。
对主关联类型写出的约束也不是新的遵循。any Catalog<Product> 表示盒中具体类型的 Item 必须是 Product;它不会创建名为 Catalog<Product> 的独立运行时协议,也不会允许一个遵循类型为同一协议提供多套不同 Item。
协议见证与扩展成员
遵循类型用属性、方法、下标或构造器满足协议要求,这些实现形成协议调用所需的见证。扩展提供的默认实现可以成为某项要求的见证。调用方通过泛型约束或存在值使用该要求时,仍能获得遵循关系选择的实现。
协议扩展还能声明协议本身没有要求的成员。泛型函数编译时只知道约束,因此这类成员按照扩展定义静态选择。具体类型恰好声明同名成员,只会影响以该具体静态类型解析的调用。
这个规则说明「默认实现」需要拆成两类:协议要求的默认见证,以及扩展新增的便利 API。若行为必须多态,先在协议中声明要求;若行为只是由已知要求组合出的固定算法,扩展便利成员通常更合适。
存在值的能力边界
存在容器保存一个未知的具体值以及支持协议操作所需的信息。any P 的价值是运行时异构性:数组元素、依赖注册项或路由处理器可以拥有不同具体类型。代价是调用方不能继续假定每个值的具体身份。
带有关联类型的协议并非一律不能作为存在类型。现代 Swift 允许写出 any P,主关联类型还能保留指定关系,例如 any Catalog<Product>。但某个成员能否调用,仍取决于其签名是否能在擦除具体 Self 和其他关联类型后成立。
不要为了「面向协议」而在最内层把所有值都擦除。核心算法若需要跨参数的同类型关系,应保持泛型;需要汇集不同实现的外层边界再使用存在类型。这样动态性集中在真正需要它的位置。
类型擦除的责任
手写类型擦除包装器本身是一个具体类型,对外隐藏被包装值。可靠的包装器在泛型构造器中接收满足约束的值,并把允许的操作捕获为类型正确的闭包。构造完成后,包装器不应再依赖 Any 字典或强制转换恢复信息。
擦除层需要定义值语义、引用语义、可发送性和错误传播。闭包捕获一个类实例时,复制包装器可能仍共享同一对象;捕获可变状态时,还要确定并发所有权。只复制方法签名而不说明这些语义,会制造看似统一、实际行为不一致的抽象。
语言原生存在类型已经满足需求时,手写包装器只会增加维护面。仍需包装器的常见理由是提供额外值语义、隐藏多个内部对象,或把协议没有直接暴露的操作组合成稳定接口;应在类型文档中写明这个额外承诺。
条件遵循与约束传播
泛型类型可以只在类型实参满足条件时遵循另一个协议。例如,容器只有在 Element: Equatable 时才能可靠合成元素级相等。 条件遵循(conditional conformance) 让基础类型继续接受其他元素,同时为合格实例提供额外能力。
约束会沿使用链传播。若一个公开函数调用只对 Element: Hashable 可用的成员,函数本身也必须证明这项条件,或者改用不需要哈希的实现。遇到编译器错误时应追踪实际需要能力的表达式,而不是在最外层不断追加约束。
条件遵循不能根据运行时值开关。它由完整的静态类型决定,因此 Box<Int> 的所有值拥有相同遵循集合。若能力取决于某个实例配置,应把它建模为普通状态与方法结果,而不是尝试用协议遵循表达。
编译与性能边界
泛型保留静态类型关系,但源码语义不保证每个调用都产生一份特化机器码。优化级别、可见性、模块边界和编译器版本都会影响特化。正确理由是类型安全与契约表达,性能判断必须基于目标平台的发布构建测量。
存在类型可能需要间接调用或容器存储,但不能据此断言某个业务路径一定更慢。值大小、逃逸、内联、调用频率与优化结果都会改变成本。没有测量时,只描述语义差异,不写速度倍数或笼统排名。
公共库还要考虑弹性与演进。把 some P 改成 any P、增加约束,或把关联类型暴露为主关联类型,都可能改变调用方能表达和依赖的关系。评审 API 变更时应把这些签名变化当作契约变化,而不是格式整理。
测试类型契约
运行时断言只能检查成功编译后的行为,不能证明错误调用会被编译器拒绝。库若依赖某项重要的静态保证,应在编译测试或独立失败夹具中保存代表性反例,并检查诊断位置。不要把预期无法编译的代码塞进普通运行测试。
至少覆盖下面四类调用边界:
- 两种不同的有效遵循类型都能调用泛型 API。
- 违反同类型要求的调用无法编译。
some返回值不会泄漏实现方不承诺的具体成员。any存储能混合预期类型,但消费方只能依赖协议接口。
分派测试还要按静态类型分组。对同一个值分别通过具体类型、泛型约束和存在类型调用,可以暴露扩展便利成员与协议要求的差异。只测试最终字符串而不保留调用边界,会让一次签名重构悄悄改变结果。
类型擦除包装器需要契约测试,而不只是转发测试。应复制包装器并观察状态是否共享,把底层实现替换为另一遵循类型,并让底层操作返回错误。这样才能验证擦除层承诺的所有权与错误语义。
控制抽象规模
一个协议若携带许多关联类型和同类型要求,每个使用方都可能被迫重复复杂约束。这不一定表示 Swift 类型系统不够灵活,更可能表示协议同时承担了读取、写入、生命周期和传输等多个角色。先按调用方真正需要的能力拆分接口。
拆分不等于为每个方法创建一个协议。能独立使用、拥有明确语义的能力才值得形成边界;总是共同出现的要求留在一起更容易维护。评价标准是调用方能否用短而准确的约束说明需要什么。
也不要为了隐藏复杂签名就立刻返回 any P。若复杂性来自真实的类型关系,擦除只会把它转移到运行时转换与文档中。先简化模型,再决定最外层是否需要异构存储。
最终签名应让误用难以表达。调用方无需知道的具体实现可以隐藏,调用方必须依赖的同类型关系则应保留。协议与泛型配合的价值,正是把这两类信息分开。
延伸阅读
5个问题 · 1 道输出预测题 · 1 道找错题