协议约束与存在类型

用 Swift 协议声明能力,以泛型保留类型关系,并准确选择关联类型、some 与 any 的抽象边界。

难度 进阶 时长 标准深度约 11分钟
版本 Swift 6.3.3
what

协议(protocol)声明类型必须提供的能力; 泛型(generic) 把具体类型留给调用点决定,同时保留输入、输出与关联类型之间的编译期关系。

trap

把泛型参数改成 any Protocol 会擦除类型身份;只写在协议扩展中、却没有列为协议要求的同名成员,也不会按遵循类型动态替换。

fix

先写清由谁选择具体类型,再使用最窄约束:调用方选择用泛型,返回实现隐藏固定类型用 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) 。一个存在值可以在不同时间保存不同遵循类型,因此具体身份被封装起来;能调用哪些成员取决于协议要求以及仍然可表达的关联类型关系。需要跨过这条边界时,应先确认调用方是否真的只需要协议接口。

协议要求会参与遵循关系的见证选择。协议扩展能提供要求的默认实现,也能增加便利成员;但只有前者属于协议契约。泛型代码通过协议约束调用一个仅存在于扩展中的成员时,会选择扩展实现,而不会把遵循类型上的同名成员当作可替换要求。

示例

下面四个独立程序从单一协议约束开始,再加入关联类型、someany 和扩展分派。当前环境没有 Swift 工具链,因此每个代码块都按要求标明未执行;输出块保留确定性的预期转录,不能视为本次本地运行记录。

用协议约束泛型算法

printSummary 接受任意 Summarizable 具体类型。参数与协议能力都保留下来,函数体不需要类型转换或 switch

generic_summary.swift
// # 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

associated_catalog.swift
// # 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
empty

ArrayCatalog<Product>Item 推断为 ProductfirstName 可以接受其他目录实现,但每个实现的返回值仍与自己的 Item 保持一致。越界由可选返回值表达,而不是强制解包。

主关联类型语法让约束位置更紧凑,却没有改变谁决定 Item。若 API 需要同时表达两个目录拥有相同元素类型,仍可以用 where Left.Item == Right.Item 写出关系。

区分 someany

工厂始终返回 StaffBadge,所以 some Badge 隐藏实现而保留一个固定底层类型。数组需要同时保存员工与访客徽章,才使用 [any Badge]

opaque_existential.swift
// # 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:7

some 不是「任意遵循类型」。同一个不透明返回声明必须有一个可确定的底层类型,不能在一个普通条件分支返回 StaffBadge,另一个分支返回 GuestBadge。需要这种运行时异构性时,返回 any Badge 或重新设计为统一具体类型。

存在值适合存储边界,但会隐藏具体身份。若后续算法必须让徽章类型与另一参数保持相同,泛型签名比先放进 any Badge 再强制转换更准确。

暴露扩展成员的静态分派陷阱

debugName() 只出现在协议扩展中,不是 Renderable 的要求。具体值直接调用时能看到 Report 的成员,泛型约束内调用时则使用扩展成员。

extension_dispatch.swift
// # 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.ElementP.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 变更时应把这些签名变化当作契约变化,而不是格式整理。

测试类型契约

运行时断言只能检查成功编译后的行为,不能证明错误调用会被编译器拒绝。库若依赖某项重要的静态保证,应在编译测试或独立失败夹具中保存代表性反例,并检查诊断位置。不要把预期无法编译的代码塞进普通运行测试。

至少覆盖下面四类调用边界:

  1. 两种不同的有效遵循类型都能调用泛型 API。
  2. 违反同类型要求的调用无法编译。
  3. some 返回值不会泄漏实现方不承诺的具体成员。
  4. any 存储能混合预期类型,但消费方只能依赖协议接口。

分派测试还要按静态类型分组。对同一个值分别通过具体类型、泛型约束和存在类型调用,可以暴露扩展便利成员与协议要求的差异。只测试最终字符串而不保留调用边界,会让一次签名重构悄悄改变结果。

类型擦除包装器需要契约测试,而不只是转发测试。应复制包装器并观察状态是否共享,把底层实现替换为另一遵循类型,并让底层操作返回错误。这样才能验证擦除层承诺的所有权与错误语义。

控制抽象规模

一个协议若携带许多关联类型和同类型要求,每个使用方都可能被迫重复复杂约束。这不一定表示 Swift 类型系统不够灵活,更可能表示协议同时承担了读取、写入、生命周期和传输等多个角色。先按调用方真正需要的能力拆分接口。

拆分不等于为每个方法创建一个协议。能独立使用、拥有明确语义的能力才值得形成边界;总是共同出现的要求留在一起更容易维护。评价标准是调用方能否用短而准确的约束说明需要什么。

也不要为了隐藏复杂签名就立刻返回 any P。若复杂性来自真实的类型关系,擦除只会把它转移到运行时转换与文档中。先简化模型,再决定最外层是否需要异构存储。

最终签名应让误用难以表达。调用方无需知道的具体实现可以隐藏,调用方必须依赖的同类型关系则应保留。协议与泛型配合的价值,正是把这两类信息分开。

延伸阅读

检查点

5个问题 · 1 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?