Swift 集合类型详解(二):自定义 Hashable、值语义与性能陷阱

为什么要"自定义"Hashable?

官方文档只告诉你"Set/Dictionary 的元素/键必须 Hashable",却没说:

  • 系统默认的哈希算法什么时候会"翻车"?
  • 手写 hash(into:) 怎样既保证"分布均匀"又保证"向后兼容"?
  • struct 与 class 在"值语义"下对哈希的影响到底有何不同?

下面用 3 个真实踩坑案例回答。

案例 1:struct 默认 Hashable 够用吗?

swift 复制代码
struct User: Hashable {
    let userId: Int
    let name: String
}

上面代码完全合法,也能直接丢进 Set。

但产品迭代后,需求改成"同一 userId 即算同一人,name 可改"。

此时默认生成的 ==hash(into:) 仍然把 name 算进去,导致:

swift 复制代码
let u1 = User(userId: 1, name: "A")
let u2 = User(userId: 1, name: "B")
print(Set([u1, u2]).count)   // 输出 2,业务想要 1

解决:只拿"业务主键"做哈希。

swift 复制代码
extension User {
    // 1)重载 ==,只比 userId
    static func == (lhs: User, rhs: User) -> Bool {
        lhs.userId == rhs.userId
    }

    // 2)手写 hash(into:),只混入 userId
    func hash(into hasher: inout Hasher) {
        hasher.combine(userId)
    }
}

结论:

  • 默认合成只适用于"所有存储属性都是关键"的场景;
  • 一旦业务主键≠全部属性,必须手写,避免"同名不同人"或"同人不同名"逻辑错误。

案例 2:class 的"对象身份"陷阱

swift 复制代码
class Photo: Hashable {
    var url: String
    init(url: String) { self.url = url }
    
    static func == (lhs: Photo, rhs: Photo) -> Bool {
        lhs.url == rhs.url          // 只比内容
    }
    func hash(into hasher: inout Hasher) {
        hasher.combine(url)
    }
}

看起来没问题,但 class 默认是"引用类型"。

swift 复制代码
let p1 = Photo(url: "cat.jpg")
let p2 = p1                       // 只复制指针
p1.url = "dog.jpg"
print(p2.url)                     // dog.jpg,意外共享

把实例扔进 Dictionary 后,外部改属性 → 哈希值变 → 字典 桶索引失效 → 找不到 key,这不是崩溃,是静默逻辑 bug。

方案:

  1. 用 struct 就能天然隔离;
  2. 如果必须用 class(例如 @objc 继承),加 final 并把属性设为 let,或手动保证"哈希依赖字段"不可变。

案例 3:哈希碰撞与性能

写个"极差"的哈希函数:

swift 复制代码
struct BadKey: Hashable {
    let value: Int
    func hash(into hasher: inout Hasher) {
        hasher.combine(0)   // 所有实例哈希值相同
    }
    static func == (lhs: BadKey, rhs: BadKey) -> Bool {
        lhs.value == rhs.value
    }
}

测试:

swift 复制代码
let N = 1_0000
let dict = Dictionary(uniqueKeysWithValues: (0..<N).map { (BadKey(value: $0), $0) })
// 查找耗时:O(N) 退化到链表

Playground 时间线:

  • 正常哈希:查找 1 万次 ≈ 0.2 ms
  • BadKey:查找 1 万次 ≈ 25 ms,125 倍差距

结论:

  • 哈希不要求"密码安全",但分布必须均匀;
  • 多字段场景,用 Hasher 连续 combine 即可,别偷懒写 xor 大法。

值语义 & 写时复制(COW)

  1. Array/Set/Dictionary 都是"值语义",但内部通过引用 + 写时复制优化。
  2. 何时触发复制?
    • 对变量做任何"可能改变其内容"的操作时,且引用计数 > 1。
  3. 大集合性能陷阱:
swift 复制代码
var a = Array(0..<1_000_000)
var b = a          // O(1),只复制指针
b[0] = -1          // 这一刻才复制 1_000_000 个元素
  1. 提前规避:
swift 复制代码
a.reserveCapacity(a.count + 100)   // 减少后续再分配
  1. 对 Dictionary/Set 同样适用:
swift 复制代码
dict.reserveCapacity(expectedCount)

容量预分配实战

场景:批量解析 JSON,逐步往数组里 append

swift 复制代码
// 普通写法:可能多次重新分配
var output: [Item] = []
for json in jsonArray {
    output.append(parse(json))
}

// 优化:一次到位
var output: [Item] = []
output.reserveCapacity(jsonArray.count)
for json in jsonArray {
    output.append(parse(json))
}

Benchmark(100 万元素):

  • 无 reserve:总耗时 380 ms,分配 18 次
  • 有 reserve:总耗时 280 ms,分配 1 次

节省约 26% ,且代码只多一行。

在集合里存 @objc / NSError?

Objective-C 桥接类型(如 NSString、NSArray、NSError)并非真正的值类型,桥接到 Swift 集合后:

  1. 会触发"隐式转换",可能额外分配;
  2. 作为 Dictionary Key 时,哈希实现走 NSObject 的 hash 属性,与 Swift 哈希算法不一致,跨语言传递时可能出意外。

建议:

  • 纯 Swift 模块尽量用 Swift 原生类型;
  • 必须桥接时,用 as String/ as NSError` 明确转换,再入库。

弱引用键 & 自动清理

Swift 标准库目前没有 NSMapTable 的"弱引用键"版本。

若要做"缓存 + 自动释放":

  1. 用 NSMapTable 桥接(失去值语义);
  2. 或用第三方库 WeakMap ;
  3. 也可自己包一层:
swift 复制代码
final class WeakBox<T: AnyObject> {
    weak var value: T?
    init(_ value: T) { self.value = value }
}
struct WeakDictionary<Key: Hashable, Value: AnyObject> {
    private var storage: [Key: WeakBox<Value>] = [:]
    mutating func cleanup() {
        storage = storage.filter { $0.value.value != nil }
    }
}

注意:需手动或定时调用 cleanup(),否则容器会越来越大。

Swift 5.9 新玩意:@inlinable & 集合

给集合写 extension 时,如果加 @inlinable

  • 允许编译器跨模块内联,提高 Release 性能;
  • 但会暴露实现细节,library evolution 需打开 BUILD_LIBRARY_FOR_DISTRIBUTION。

结论:

  • App 内模块可大胆用;
  • 开源库想保持 ABI 稳定,慎加。

性能清单速查表

操作 Array Set Dictionary
随机访问 O(1) 不支持 O(1) 平均
末尾追加 O(1) 摊销 O(1) 平均 ---
查找 O(n) O(1) 平均 O(1) 平均
插入/删除中间 O(n) O(1) 平均 O(1) 平均
顺序遍历 最快 慢(需跳转桶)
去重 需转 Set 天生 ---

总结

  1. 默认合成的 Hashable 经常≠业务主键,手写时只哈希"不变的主键"。
  2. class 的引用身份会破坏值语义,哈希依赖字段必须不可变。
  3. 哈希碰撞会让 Set/Dictionary 性能瞬间崩塌,多字段就用 hasher.combine 连续混写。
  4. 大集合提前 reserveCapacity,写时复制能省 2030% 时间。
  5. 与 Objective-C 桥接时,注意 NSString/NSError 的哈希算法差异。
相关推荐
用户79457223954131 天前
【AFNetworking】OC 时代网络请求事实标准,Alamofire 的前身
objective-c·swift
报错小能手1 天前
SwiftUI 框架 认识 SwiftUI 视图结构 + 布局
ui·ios·swift
东坡肘子1 天前
被 Vibe 摧毁的版权壁垒,与开发者的新护城河 -- 肘子的 Swift 周报 #131
人工智能·swiftui·swift
报错小能手2 天前
ios开发方向——swift错误处理:do/try/catch、Result、throws
开发语言·学习·ios·swift
小夏子_riotous2 天前
openstack的使用——5. Swift服务的基本使用
linux·运维·开发语言·分布式·云计算·openstack·swift
mCell2 天前
MacOS 下实现 AI 操控电脑(Computer Use)的思考
macos·agent·swift
用户79457223954132 天前
【DGCharts】iOS 图表渲染事实标准——8 种图表类型、高度可定制,3 行代码画出一条折线
swiftui·swift
chaoguo12342 天前
Any metadata 的内存布局
swift·metadata·value witness table
tangweiguo030519874 天前
SwiftUI布局完全指南:从入门到精通
ios·swift
用户79457223954134 天前
【RxSwift】Swift 版 ReactiveX,响应式编程优雅处理异步事件流
swift·rxswift