Swift 里的“橡皮擦”与“标签”——搞懂 existentials 与 primary associated type

Swift 的协议一旦带上 associatedtype,就像给类型贴了一张"待填写的支票"------编译时必须知道具体填什么数字,否则无法兑现。

这导致一个经典编译错误:

swift 复制代码
protocol Shape {
    associatedtype Parameters
    func area(with parameters: Parameters) -> Double
}

struct Circle: Shape {
    struct CircleParameters {
        let radius: Double
    }

    func area(with parameters: CircleParameters) -> Double {
        return Double.pi * parameters.radius * parameters.radius
    }
}

struct Rectangle: Shape {
    struct RectangleParameters {
        let width: Double
        let height: Double
    }

    func area(with parameters: RectangleParameters) -> Double {
        return parameters.width * parameters.height
    }
}

struct Triangle: Shape {
    // 定义三角形面积计算所需的参数结构体
    struct TriangleParameters {
        let base: Double  // 底边长
        let height: Double  // 对应底边的高
    }
    
    // 实现Shape协议的area方法,使用底乘高除以2计算面积
    func area(with parameters: TriangleParameters) -> Double {
        return (parameters.base * parameters.height) / 2.0
    }
}

// Use of protocol 'Shape' as a type must be written 'any Shape'; this will be an error in a future Swift language mode
// 目前只是警告,后续会演变成错误
let shapes: [Shape] = [Circle(), Rectangle()]

existentials(any Protocol)与 Swift 5.7 引入的 primary associated type 正是为了解决"既要协议约束,又要异质容器"的两难问题。

基础概念速览

术语 一句话解释 本文代号
Protocol with associated type (PAT) 带关联类型的协议 Shape
Existential(存在量词类型) 编译期"橡皮擦",把具体类型擦成盒子 any Shape
Primary associated type 给协议额外贴一个"标签",在 any盒子外再写关联类型 Shape<CircleParameters>
Type erasure 把不同具体类型抹平成同一盒子,代价是丢失静态信息 下文详解

业务场景:统一计算任意图形的面积

定义协议(含关联类型)

swift 复制代码
protocol Shape {
    /// 每个图形需要的参数不一样,用关联类型抽象
    associatedtype Parameters
    
    /// 根据外部传入的参数计算面积
    func area(with parameters: Parameters) -> Double
}

具体实现:圆与矩形

swift 复制代码
// MARK: - Circle
struct Circle: Shape {
    struct CircleParameters {
        let radius: Double
    }
    
    func area(with parameters: CircleParameters) -> Double {
        Double.pi * parameters.radius * parameters.radius
    }
}

// MARK: - Rectangle
struct Rectangle: Shape {
    struct RectangleParameters {
        let width: Double
        let height: Double
    }
    
    func area(with parameters: RectangleParameters) -> Double {
        parameters.width * parameters.height
    }
}

异质容器:把圆、矩形、三角形放一起

swift 复制代码
// ❌ 直接写 [Shape] 会报警告
// var shapes: [Shape] = [Circle(), Rectangle()]

// ✅ 使用 existential(类型擦除盒子)
var shapes: [any Shape] = [Circle(), Rectangle()]

注意:any Shape 是 Swift 5.6+ 的显式语法;老版本可省略 any,但 Xcode 14 起会警告。

运行期"拆盒子"------向下转型

类型被擦除后,编译器不知道盒子里的真实类型,只能手动拆:

swift 复制代码
let circleParams   = Circle.CircleParameters(radius: 10)
let rectangleParams = Rectangle.RectangleParameters(width: 5, height: 8)

for shape in shapes {
    if let circle = shape as? Circle {
        print("Circle area: \(circle.area(with: circleParams))")
    } else if let rectangle = shape as? Rectangle {
        print("Rectangle area: \(rectangle.area(with: rectangleParams))")
    }
}

缺点

  1. 运行时转型,错一个字母就崩溃。
  2. 每新增一个图形都要改 if/else
  3. 无法利用泛型静态派发,失去性能优势。

Primary associated type:给协议加"标签"

Swift 5.7 允许在协议名后直接把关联类型"提"到尖括号里,成为 primary associated type。

升级协议

swift 复制代码
protocol Shape<Parameters> {
    associatedtype Parameters
    func area(with parameters: Parameters) -> Double
}

语法糖等价于:

swift 复制代码
protocol Shape {
    associatedtype Parameters
    func area(with parameters: Parameters) -> Double
}

差别在于:调用方现在可以显式写 any Shape<CircleParameters>,把擦除粒度变细。

同质容器:不再瞎猜类型

swift 复制代码
// 只接受 Circle 的盒子
let circles: [any Shape<Circle.CircleParameters>] = [Circle(), Circle()]

for circle in circles {
    // 无需转型,编译器已知 Parameters == CircleParameters
    print(circle.area(with: .init(radius: 3)))
}

异质容器:回到 any Shape

swift 复制代码
// 仍然可以擦除到底
let mixed: [any Shape] = [Circle(), Rectangle()]

primary associated type 并没有破坏"擦除"能力,只是让你有机会在需要细粒度时把类型信息拉回来。

对比总结:何时用谁?

场景 推荐写法 转型成本 新增类型成本
同质集合(全是 Circle) [any Shape<CircleParameters>] 0
异质集合(圆+矩形) [any Shape]+ 转型 高(要改 if/else)
性能敏感 & 静态派发 用泛型 func draw<T: Shape>(_ shape: T) 0 0

可落地的扩展场景

网络层抽象

swift 复制代码
protocol Request<Response> {
    associatedtype Response: Decodable
    var url: URL { get }
}

class User: Decodable {}

struct GetUsers: Request {
    typealias Response = [User]
    var url: URL {
        URL(string: "")!
    }
}

class SearchUsers: Request {
    typealias Response = [User]
    
    let query: String
    init(query: String) {
        self.query = query
    }
    
    var url: URL {
        URL(string: "")!
    }
}

let endpoints: [any Request<[User]>] = [GetUsers(), SearchUsers(query: "Swifty")]

数据库 DAO

swift 复制代码
protocol PersistentModel {}

protocol DAO<Entity> {
    associatedtype Entity: PersistentModel
    func insert(_ e: Entity) throws
}

struct User: PersistentModel {}

struct UserDAO: DAO {
    func insert(_ e: User) throws {
        
    }
}

// 只操作用户表
let userDAO: any DAO<User> = UserDAO()

SwiftUI 的 View & Reducer

利用 any Store<State, Action> 在预览时注入 mock,生产环境注入真实 store,一套代码两端复用。

最佳实践

  1. 优先泛型,次选 existential

    泛型函数/类型在编译期就能确定具体类型,零成本抽象;existentials 是运行期盒子,有轻微内存与派发开销。

  2. primary associated type 不是银弹

    它只能把"关联类型"提到签名里,不能把 Self 提出来。若协议里出现 func f(_: Self),仍然无法消除转型。

  3. 用 typealias 降低视觉噪音

swift 复制代码
typealias AnyCircleShape = any Shape<Circle.CircleParameters>
  1. 大型项目给 existential 写单元测试

    转型分支容易遗漏,用 XCTest 参数化遍历所有 conforming type,确保 area 计算正确。

一句话收束

existentials 像"橡皮擦",让不同类型共处一室;primary associated type 像"标签",让擦除后仍保留关键线索。

掌握这对组合拳,你就能在"灵活"与"性能"之间自由切换,写出既 Swifty 又高效的代码。

相关推荐
用户0917 小时前
TipKit与CloudKit同步完全指南
ios·swift
东坡肘子1 天前
完成 Liquid Glass 的适配了吗?| 肘子的 Swift 周报 #0102
swiftui·swift·apple
HarderCoder2 天前
【Swift Concurrency】深入理解 `async let` 与 `TaskGroup`:并发任务的生命周期与错误传播机制
swift
HarderCoder2 天前
深入理解 Swift Concurrency:从 async/await 到 Actor 与线程池的完整运行机制
swift
HarderCoder2 天前
Swift 结构化并发 6 条铁律 —— 一张图 + 一套模板,让 `async let` / `TaskGroup` / `Task {}` 不再踩坑
swift
M-finder3 天前
Mac菜单栏综合工具FancyTool更新啦
mac·swift
HarderCoder5 天前
在同步代码里调用 async/await:Task 就是你的“任意门”
swift
HarderCoder5 天前
Swift 三目运算符指南:写法、场景与避坑
swift
YungFan5 天前
iOS26适配指南之UISlider
ios·swift