泛型

用类型参数、约束、where 子句与关联类型表达可复用的 Swift 代码,同时保留编译期类型关系。

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

泛型(generic) 用类型参数表示尚未确定的类型,让一份函数或类型定义服务多种具体类型,同时保留这些位置之间的类型关系。

trap

无约束的类型参数只能使用所有类型都具备的能力;把泛型改成 Any、随意添加约束或混用 someany,都会改变 API 契约。

fix

先写清哪些位置必须是同一类型,再为实现真正使用的能力添加最窄约束,并让调用上下文完成类型推断。

是什么,为什么存在

泛型声明把一个或多个具体类型替换成命名的 类型参数(type parameter) 。调用泛型函数时,调用点提供的实参和预期返回类型共同决定类型参数;创建泛型类型时,尖括号中的类型实参决定其存储和成员签名。Array<Int>Array<String> 使用同一份声明,却是不同的静态类型。

泛型解决的不是“接受任意值”,而是“在未知具体类型时仍保留关系”。函数 choose<T>(_ left: T, _ right: T) -> T 说明两个参数和返回值使用同一个 T。如果把三个位置都写成 Any,编译器便无法保证返回值与输入类型一致,调用方还需要转换。

你会在 Swift 标准库的 Array<Element>Dictionary<Key, Value>Optional<Wrapped>Result<Success, Failure> 中持续遇到泛型。应用代码也常用它编写容器、转换函数、数据访问边界和复用算法。只服务一个明确业务类型的代码不必为了形式而泛型化。

类型参数本身没有比较、哈希、编码或领域成员。实现需要某项能力时,声明 泛型约束(generic constraint) ,通常要求类型遵循协议。约束既允许函数体使用协议成员,也会拒绝不满足条件的调用。

泛型抽象应该保留对调用方有意义的类型信息。如果一个参数只被记录或原样传回,可能不需要约束;如果算法只对 Int 有业务含义,具体类型反而更诚实。判断标准是契约,不是尖括号越多越复用。

工作原理

泛型函数在函数名后声明参数列表,例如 <Element>。编译器会从每个实参、显式类型标注和周围表达式收集约束,求出一致的具体类型。Swift 不支持在调用处写 identity<Int>(42) 这种显式函数特化语法;需要更多上下文时,应标注实参或接收结果的类型。

冒号约束表达遵循或继承关系。<Element: Comparable> 允许实现使用 Comparable 保证的 <> 和相等操作,但不会自动授予 HashableCodable 的能力。多个要求可以写在泛型参数列表中,也可以放进 where 子句。

where 子句能约束关联类型,并能声明两个类型表达式必须相同。Left.Element == Right.Element 同类型要求(same-type requirement) :两个集合类型可以不同,但它们的元素必须相同。只有调用点能证明所有要求时,泛型声明才可用。

泛型类型把类型参数纳入类型身份。Stack<String>push 接收 Stringpop 返回 String?Stack<Int> 的成员签名则使用 Int。实例创建后不能把同一个 Stack<String> 改成保存 Int 的栈。

协议中的 关联类型(associated type) 由遵循协议的类型确定。它与泛型参数都表示类型关系,但选择位置不同:泛型类型的使用者写出类型实参,协议的遵循者通过实现确定关联类型。编译器通常可以从属性和方法签名推断该类型,无需显式 typealias

常用语法可以按“谁声明占位类型、谁提供具体类型”来读:

语法表达的关系具体类型来自哪里
func load<T>(_: T)一个命名类型参数调用点推断
struct Box<Value>泛型类型身份的一部分创建类型时提供
T: HashableT 必须满足协议编译器验证遵循
where A.Item == B.Item两个类型表达式相同调用点同时满足
associatedtype Item协议留下类型槽位遵循类型确定

类型推断不是动态类型。编译完成后,每个表达式仍有确定的静态类型;推断只是免去重复书写。错误信息很长时,先在调用边界增加一个有意义的类型标注,通常比在实现内部堆叠转换更容易定位冲突。

推断会双向收集信息

类型推断不只从实参流向返回值。赋值目标、闭包参数与返回类型、重载候选和字面量默认类型都会参与同一次求解。例如 let ids: Set<Int> = [] 用左侧类型确定空字面量,而 let ids = [] 没有足够信息。

检查一次泛型调用时,可以依次标出四类信息来源:

  • 每个实参本身的静态类型;
  • 接收结果的位置是否声明预期类型;
  • 闭包体对参数和结果施加的操作;
  • 泛型声明、where 子句与所选重载带来的要求。

这些信息必须组成一组一致解。若两个实参分别推出互不兼容的 T,编译器不会任选其一,也不会自动插入数值转换。修复应明确业务上需要哪个类型,再在边界执行有名称的转换。

约束属于最小能力边界

约束可以出现在泛型声明、方法、受约束扩展和条件遵循上。位置决定整项 API 还是单一能力受限。把约束放得越外层,受影响的无关成员越多。

放置位置适合表达的要求对其他成员的影响
struct Cache<Key: Hashable, Value>存储本身始终需要哈希键所有实例都受限
func contains(...) where Element: Equatable只有该操作需要相等比较其他方法不受限
extension Box where Value: Codable一组编码成员共享要求仅该扩展的成员可用
extension Box: Equatable where Value: Equatable遵循关系有条件成立不影响基础类型的创建

最窄位置并不总是方法。如果类型的不变量依赖字典键,Key: Hashable 属于类型本身;若只有调试导出需要编码,Codable 就不应污染核心声明。沿着实际使用约束的表达式向外找到第一个稳定 API 边界即可。

示例

下面四个独立程序依次展示泛型函数、泛型类型、where 约束和关联类型。当前环境没有本地 Swift 工具链,因此代码块按要求标明未执行;所示输出已另用 Swift 6.3.3 编译器逐段核对。

从调用点推断类型参数

earlier 的两个参数和返回值共享 ValueComparable 约束让函数体可以使用 <,而 repeated 不检查值,因此不需要额外约束。

inferred_types.swift
// # not executed here: Swift toolchain is not installed.
func earlier<Value: Comparable>(_ first: Value, _ second: Value) -> Value {
    first < second ? first : second
}

func repeated<Value>(_ value: Value, count: Int) -> [Value] {
    precondition(count >= 0)
    return Array(repeating: value, count: count)
}

let earlierNumber = earlier(42, 17)
let earlierWord = earlier("pear", "apple")
let stages = repeated("ready", count: 3)

print(earlierNumber)
print(earlierWord)
print(stages.joined(separator: ","))
17
apple
ready,ready,ready

第一次调用把 Value 推断为 Int,第二次推断为 String。每次调用中的两个参数仍必须采用同一种类型;泛型不会自动把 IntDouble 混成一个共同数值类型。

返回值也参与推断。当实参不能提供足够信息时,可以写 let result: DesiredType = ... 或先创建带类型的输入。不要让生成代码用强制转换伪造编译器无法证明的关系。

precondition 表达 count 的值域要求,这与泛型约束不同。泛型约束检查类型能力,运行时前置条件检查某个具体值。二者不能互相替代。

让泛型类型保存一种元素

Stack 的存储、pushpop 都使用同一个 Element。它的 map 方法另外声明 Output,因此转换结果可以是另一种栈类型。

generic_stack.swift
// # not executed here: Swift toolchain is not installed.
struct Stack<Element> {
    private var storage: [Element] = []

    var count: Int { storage.count }

    mutating func push(_ element: Element) {
        storage.append(element)
    }

    mutating func pop() -> Element? {
        storage.popLast()
    }

    func map<Output>(_ transform: (Element) -> Output) -> Stack<Output> {
        var result = Stack<Output>()
        for element in storage {
            result.push(transform(element))
        }
        return result
    }

    func values() -> [Element] { storage }
}

var jobs = Stack<String>()
jobs.push("build")
jobs.push("deploy")
let lengths = jobs.map { $0.count }

print(jobs.pop() ?? "none")
print(lengths.values())
deploy
[5, 6]

jobs 的类型固定为 Stack<String>,所以 push(3) 会在编译期失败。map 没有把原栈改成整数栈,而是创建新的 Stack<Int>;这种返回类型保留了转换前后的类型关系。

pop 返回可选值,因为空栈是有效状态。泛型并不会替 API 决定边界行为。用 Element? 表达缺失,比强制解包或返回一个无法适用于所有 Element 的哨兵值更准确。

这个实现故意保持接口很小。若加入查找,只应在该操作所在的受约束扩展上要求 Element: Equatable,而不是让整个 Stack 拒绝不可比较元素。

用 where 描述跨类型关系

sameElements 接受两种不同集合,只要求元素类型相同且可比较。unique 只关心序列元素可哈希,并在保留首次出现顺序的同时去重。

where_constraints.swift
// # not executed here: Swift toolchain is not installed.
func sameElements<Left: Collection, Right: Collection>(
    _ left: Left,
    _ right: Right
) -> Bool where Left.Element == Right.Element, Left.Element: Equatable {
    left.elementsEqual(right)
}

func unique<Values: Sequence>(_ values: Values) -> [Values.Element]
where Values.Element: Hashable {
    var seen: Set<Values.Element> = []
    return values.filter { seen.insert($0).inserted }
}

let states = ["queued", "running", "done"]
let activeStates = states[0...1]

print(sameElements(["queued", "running"], activeStates))
print(sameElements(["running", "queued"], activeStates))
print(unique(["A", "B", "A", "C", "B"]))
true
false
["A", "B", "C"]

左侧是 Array<String>,右侧是 ArraySlice<String>,所以两个集合类型不必相同。Left.Element == Right.Element 保证 elementsEqual 比较的是同一种元素,Equatable 则提供相等比较。

unique 使用 Set 记录已经出现的元素,因此需要 Hashable。这个要求放在函数上而不是输入类型的其他操作上。返回数组保留首次出现顺序,不依赖 Set 的迭代顺序。

约束是公开 API 的一部分。以后若实现不再需要哈希,应考虑移除 Hashable;保留无用约束会阻止本来能够正确工作的类型调用函数。

让遵循类型确定关联类型

Catalog 声明了 主关联类型(primary associated type) Item。尖括号让 Catalog<Product> 可以约束该关联类型,但它不会把协议本身变成 Catalog 结构体那样的泛型类型。

associated_types.swift
// # not executed here: Swift toolchain is not installed.
struct Product {
    let id: Int
    let name: String
}

protocol Catalog<Item> {
    associatedtype Item
    var items: [Item] { get }
}

struct MemoryCatalog<Item>: Catalog {
    let items: [Item]
}

func first<C: Catalog>(in catalog: C) -> C.Item? {
    catalog.items.first
}

func names(in catalog: some Catalog<Product>) -> [String] {
    catalog.items.map(\.name)
}

let products = MemoryCatalog(items: [
    Product(id: 1, name: "Keyboard"),
    Product(id: 2, name: "Mouse")
])

if let product = first(in: products) {
    print("First: \(product.name)")
}
print(names(in: products).joined(separator: ", "))
First: Keyboard
Keyboard, Mouse

MemoryCatalog<Product> 通过 items 属性让编译器推断 Catalog.Item == Productfirst 保留任意遵循类型自己的关联类型,返回 C.Item?,没有退化成 Any?

参数位置的 some Catalog<Product> 是一个无需命名类型参数的泛型参数。每次调用仍传入某一个具体遵循类型,函数体知道其 ItemProduct。如果两个参数必须共享完全相同的目录类型,就需要给该类型参数命名并在两个位置复用。

主关联类型名称必须对应协议中声明的关联类型。它的主要用途是让 some Catalog<Product>any Catalog<Product> 这样的约束更容易书写,不表示调用方在协议声明上填入普通泛型实参。

陷阱

在函数调用处显式填写类型实参

**修复:**让实参和预期返回类型参与推断,例如 let value: Int = identity(42)。如果仍有歧义,给数据边界添加类型标注,不要用 as! 掩盖问题。

用 Any 代替类型参数

**修复:**关系存在时使用命名类型参数,例如 [Element] -> Element?。只有异构值本来就是数据模型的一部分,而且每种运行时分支都已定义时,才选择 Any 或存在类型。

把约束加在过宽的位置

**修复:**把要求放到真正使用它的方法、扩展或条件遵循上,并删除实现没有使用的协议。为受支持和应被拒绝的类型各写一个编译测试。

假定一个类型参数能表示异构元素

**修复:**需要同一具体类型和静态关系时使用 T: Shape;需要一个集合在运行时保存不同遵循类型时,明确使用 [any Shape] 或合适的枚举。选择会影响可用成员与类型身份,不只是拼写。

把 some 与 any 当成可互换语法

**修复:**先说明谁选择具体类型,以及调用之间能否变化。调用方提供一种具体类型时用泛型参数;实现方隐藏单一返回类型时用 some;确实需要运行时异构性时用 any

深入 具体类型由谁选择

具体类型由谁选择

泛型参数、someany 都能让源码只依赖协议能力,但它们保留的信息不同。最有效的判断问题是:具体类型由谁选择,以及两个值之间的同类型关系是否需要跨过 API 边界。

形式选择具体类型的一方保留的关系
<T: P>每个调用点所有 T 位置相同
参数位置的 some P每个调用点该参数有一个未命名具体类型
返回位置的 some P函数实现每次返回同一底层类型
any P运行时值只保证值遵循 P

参数位置的两个独立 some P 各自引入一个未命名类型参数,因此不保证彼此同类型。如果实现需要交换、比较或在两者之间传值,应写成命名的 <T: P> 并复用 T。省略名字只适合实现不需要再次引用该类型的情况。

不透明返回类型由实现选择,并对调用方隐藏名字,但它仍代表一个固定底层类型。存在类型允许每个值装入不同遵循类型,因此适合异构存储和运行时替换。后者会在需要时引入间接层,但不能据此宣称一个固定倍率的性能差异;应在真实调用路径上测量。

关联类型形成依赖成员

协议的关联类型依赖于遵循类型的 Self。对 C: CatalogC.Item 是依赖成员类型;只有确定 C 或增加约束后,编译器才能知道 Item。这正是泛型函数能够把输入目录与输出元素连接起来的机制。

同类型要求可以连接多个依赖成员,例如 Left.Item == Right.Item。遵循类型仍可以不同,只有指定的成员相等。把要求误写成 Left == Right 会把契约收紧为完全相同的容器类型,失去原本允许不同实现协作的能力。

主关联类型只提供轻量约束语法。Catalog<Product> 表示主关联类型 Item 被约束为 Product;尖括号中的名字必须在协议声明中列出,并对应一个关联类型声明。协议遵循者仍然负责满足这个关系。

存在类型打开后,关联类型信息可能只在局部范围内可用。若函数要把一个值的关联类型传给另一个参数或返回给调用方,命名泛型参数通常能把关系写得更清楚。不要先擦除类型,再用强制转换试图重建编译器已经丢失的证明。

特化不是源码契约

Swift 编译器可以针对具体类型特化泛型代码,也可以在不改变可观察语义的前提下选择共享实现。是否特化受优化级别、模块边界、可见性和编译器版本影响。源码不能假定“每个类型一定生成一份机器码”,更不能据此写入固定性能数字。

泛型的首要收益是静态类型关系和复用。性能问题需要在发布构建、目标平台和真实数据上测量,并查看实际调用路径。没有测量时,只能描述可能存在的间接调用、装箱或代码体积权衡,不能把它们写成已发生的成本。

约束也会影响优化机会,但更多约束不等于更快。无用约束首先改变的是谁能调用 API,并可能传播到上层声明。先写最小正确契约,再用分析工具确认瓶颈,最后才考虑为热点提供具体重载或调整边界。

重载与推断需要清楚边界

重载集合也参与泛型求解。两个重载都能接受同一实参时,编译器会比较哪一个更具体;复杂闭包、默认参数和返回类型上下文可能让这个选择难以预测。公共 API 不应依赖读者猜测细微的重载优先级。

若两个泛型重载表达不同业务语义,使用不同的基本名称通常更清楚。若它们只是为更具体的类型提供优化路径,行为和失败规则必须与通用版本一致。调用测试应覆盖静态类型不同但运行时数据相同的情况。

错误发生在长链式表达式中时,把每一阶段赋给带类型的局部常量。这样既能指出是哪一段缺少上下文,也能阻止上游改动意外选择另一重载。诊断完成后可以保留这些名称,因为它们常常也说明了数据转换的含义。

泛型边界应保持可读

一个公开签名若同时暴露许多类型参数、嵌套关联类型和同类型要求,调用方很难判断真正的不变量。先寻找能否用现有标准库协议表达输入,再为业务关系命名。类型别名能缩短重复拼写,但不会减少底层复杂度。

编译器诊断变得难读时,可以把长表达式拆成带类型的中间值,或把约束移到命名辅助函数。这些修改给求解器和读者都提供局部边界。随意加入 Anyas! 或额外协议只是把问题推迟到运行时或上层 API。

库演进还要求区分语义约束和实现约束。若公开函数声明 Element: Hashable,以后即使实现不再使用哈希,旧调用方也已经把它视为契约的一部分。对外暴露前,应逐项回答调用方为什么需要知道这项要求。

测试泛型 API 时,不要只运行一个 Int 快乐路径。选择两个结构不同但都满足约束的类型,验证关系确实来自协议;再保留一个不能编译的示例,证明边界拒绝了什么。运行时测试验证行为,编译测试验证类型契约,两者职责不同。

延伸阅读

检查点

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

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