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 又高效的代码。

相关推荐
HarderCoder9 小时前
Swift 中的不透明类型与装箱协议类型:概念、区别与实践
swift
HarderCoder9 小时前
Swift 泛型深度指南 ——从“交换两个值”到“通用容器”的代码复用之路
swift
东坡肘子10 小时前
惊险但幸运,两次!| 肘子的 Swift 周报 #0109
人工智能·swiftui·swift
胖虎110 小时前
Swift项目生成Framework流程以及与OC的区别
framework·swift·1024程序员节·swift framework
songgeb1 天前
What Auto Layout Doesn’t Allow
swift
YGGP1 天前
【Swift】LeetCode 240.搜索二维矩阵 II
swift
YGGP2 天前
【Swift】LeetCode 73. 矩阵置零
swift
非专业程序员Ping3 天前
HarfBuzz 实战:五大核心API 实例详解【附iOS/Swift实战示例】
android·ios·swift
Swift社区4 天前
LeetCode 409 - 最长回文串 | Swift 实战题解
算法·leetcode·swift
YGGP6 天前
【Swift】LeetCode 54. 螺旋矩阵
swift