WWDC Quick Look 💓 By SwiftGGTeam
Design protocol interfaces in Swift

Design protocol interfaces in Swift

观看原视频

Highlight

Swift 5.7 让带关联类型的协议(protocol with associated type)可以与 existential any 类型直接交互,同时 opaque result type 支持主关联类型约束,where 子句中的 same-type requirement 可以精确建模多个具体类型之间的等价关系。

核心内容

从类型擦除说起

你有一个农场,里面养着各种动物:鸡和牛。鸡下蛋,牛产奶。你想用一个统一的 Animal 协议来抽象它们,但每种动物产出的食物类型不同。

以前的做法是用关联类型(associated type)来建模这个差异。ChickenCommodityTypeEggCowCommodityTypeMilk

但当你把这些动物放进一个异构数组 any Animal 里时,问题就来了。调用 produce() 方法返回的关联类型,编译器该怎么处理?

01:14)Swift 5.7 引入了 associated-type erasure。当关联类型出现在方法的返回位置(producing position)时,编译器会把它类型擦除到其上界(upper bound)。

protocol Food { }
protocol Animal {
    associatedtype CommodityType: Food
    func produce() -> CommodityType
}

struct Farm {
    var animals: [any Animal]

    func produceCommodities() -> [any Food] {
        animals.map { animal in
            animal.produce()  // 返回类型被擦除为 any Food
        }
    }
}

关键点:

  • animal.produce() 的返回类型是关联类型 CommodityType
  • any Animal 上调用时,编译器将 CommodityType 擦除为 any Food
  • 这是 Swift 5.7 的新能力,因为关联类型出现在 producing position

反过来,如果关联类型出现在参数位置(consuming position),类型擦除就不安全了。

protocol Animal {
    associatedtype FeedType: AnimalFeed
    func eat(_ food: FeedType)  // consuming position
}

let animal: any Animal = Cow()
// animal.eat(someHay)  // 编译错误!

05:34any AnimalFeed 不能安全转换为具体的 Hay 类型,因为具体类型在编译时未知。这时你需要把 existential 解包为 opaque some 类型。

Opaque Result Type 与主关联类型

07:54)你的 hungryAnimals 属性用 lazy.filter 实现,避免了临时数组分配。但返回类型暴露了实现细节:LazyFilterSequence<Array<any Animal>>

Swift 5.7 引入了 constrained opaque result type,让你既能隐藏具体类型,又能暴露足够的类型信息:

struct Farm {
    var animals: [any Animal]

    var hungryAnimals: some Collection<any Animal> {
        animals.lazy.filter { $0.isHungry }
    }

    func feedAnimals() {
        for animal in hungryAnimals {
            // animal 的类型是 any Animal,可以调用 Animal 协议的方法
            animal.eat(...)
        }
    }
}

关键点:

  • some Collection<any Animal> 隐藏了 LazyFilterSequence 的具体类型
  • 但客户端仍然知道元素类型是 any Animal
  • 这依赖于 Collection 协议声明了主关联类型(primary associated type)Element

你也可以在自己的协议上声明主关联类型:

protocol Container<Element> {
    associatedtype Element
    var count: Int { get }
}

Same-Type Requirement 建模类型关系

14:54)现在场景更复杂了:喂动物之前要先种作物、收获、加工成饲料。Cow 吃 Hay,Hay 由 Alfalfa 加工而来。

你定义了 AnimalFeedCrop 两个协议,各自有对方的关联类型。但这样写有个问题:

protocol AnimalFeed {
    associatedtype CropType: Crop
    static func grow() -> CropType
}

protocol Crop {
    associatedtype FeedType: AnimalFeed
    func harvest() -> FeedType
}

19:40)编译器推导出的类型是 (some Animal).FeedType.CropType.FeedType,而 eat() 方法期望的是 (some Animal).FeedType。类型不匹配。

根本原因是协议定义太宽泛了,没有保证”种出来的作物加工后能得到原来的饲料类型”这个不变量。

Swift 5.7 的 same-type requirement 解决了这个问题:

protocol AnimalFeed {
    associatedtype CropType: Crop
    static func grow() -> CropType
}

protocol Crop {
    associatedtype FeedType: AnimalFeed
    func harvest() -> FeedType
}

extension AnimalFeed where Self.CropType.FeedType == Self { }
extension Crop where Self.FeedType.CropType == Self { }

关键点:

  • Self.CropType.FeedType == Self 保证:从某种饲料种出来的作物,加工后得到的是同一种饲料
  • 这限制了哪些具体类型可以符合协议
  • feedAnimal() 方法现在可以安全地链式调用 grow() -> harvest() -> eat()

详细内容

类型擦除的方向性

Swift 5.7 的 associated-type erasure 有明确的方向规则:

// Producing position: 安全
func produce() -> CommodityType  // 返回 any Food

// Consuming position: 不安全
func eat(_ food: FeedType)       // 不能直接传入 any AnimalFeed

04:41) producing position 的类型转换方向是”子类型转父类型”,总是安全的。consuming position 需要”父类型转子类型”,编译器无法保证。

some 解包 existential

当关联类型在 consuming position 时,你需要用 generic 函数或 opaque type 来解包:

// 用 generic 函数
func feedAnimal<AnimalType: Animal>(_ animal: AnimalType, with feed: AnimalType.FeedType) {
    animal.eat(feed)
}

// 或者用 some
func feedAnimal(_ animal: some Animal, with feed: (some Animal).FeedType) {
    // 这里需要更精确的类型匹配
}

Constrained Existential Types

Swift 5.7 之前,要表达”一个元素类型为 X 的 Collection”,你需要自己写类型擦除包装器。现在可以直接写:

// 以前
struct AnyCollection<Element>: Collection { /* 几十行样板代码 */ }

// Swift 5.7
func process(_ items: any Collection<MailmapEntry>) {
    for item in items {
        // item 是 MailmapEntry
    }
}

25:00any Collection<MailmapEntry> 是内置的语言特性,不需要手写类型擦除包装器。

Same-Type Requirement 的完整示例

protocol Animal {
    associatedtype FeedType: AnimalFeed
    func eat(_ food: FeedType)
}

protocol AnimalFeed {
    associatedtype CropType: Crop
    static func grow() -> CropType
}

protocol Crop {
    associatedtype FeedType: AnimalFeed
    func harvest() -> FeedType
}

// 关键约束
extension AnimalFeed where Self.CropType.FeedType == Self { }
extension Crop where Self.FeedType.CropType == Self { }

// 现在 feedAnimal 可以安全编译
func feedAnimal(_ animal: some Animal) {
    let crop = type(of: animal).FeedType.grow()
    let feed = crop.harvest()
    animal.eat(feed)  // 类型匹配!
}

关键点:

  • type(of: animal).FeedType 获取动物对应的饲料类型
  • grow() 返回 CropTypeharvest() 返回 FeedType
  • same-type requirement 保证 CropType.FeedType == Self,所以最终类型就是 animal.FeedType

核心启发

1. 用 any 替代手写类型擦除包装器

以前为协议写 AnyXxx 包装器是 Swift 开发的常见痛点。Swift 5.7 的 any 语法让这成为历史。检查你项目中的类型擦除类,看看哪些可以直接换成 any SomeProtocol<SomeType>

入口:any Collection<Element>any Publisher<Output, Failure>

2. 为自定义集合协议声明主关联类型

如果你定义了类似 ContainerDataSource 这样的协议,给它们加上主关联类型声明:

protocol DataSource<Item> {
    associatedtype Item
    func item(at index: Int) -> Item
}

这样调用方就可以写 some DataSource<User>any DataSource<User>,既隐藏实现又保留类型信息。

3. 用 same-type requirement 建模双向关系

当你的数据模型中存在”A 关联 B,B 又关联回 A”的循环关系时,用 same-type requirement 保证一致性。典型场景:

  • 数据库关系模型(Table -> Column -> Table)
  • 图结构中的边和节点
  • 编译器中的 AST 节点关系

入口:where Self.Other.AssociatedType == Self

关联 Session

评论

GitHub Issues · utterances