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 的哈希算法差异。
相关推荐
东坡肘子7 小时前
Sora 2:好模型,但未必是好生意 | 肘子的 Swift 周报 #0105
人工智能·swiftui·swift
HarderCoder18 小时前
Swift 6 并发深渊:@unchecked Sendable 与“隐式 MainActor”如何合谋杀死你的 App
swiftui·swift
HarderCoder19 小时前
告别 UIKit 生命周期:SwiftUI 视图一生全解析——从 init 到 deinit 的“隐秘角落”
swiftui·swift
HarderCoder20 小时前
Swift 中的基本运算符:从加减乘除到逻辑与或非
ios·swift
HarderCoder20 小时前
Swift 中“特性开关”实战笔记——用编译条件+EnvironmentValues优雅管理Debug/TestFlight/AppStore三环境
ios·swift
HarderCoder20 小时前
Swift 并发任务中到底该不该用 `[weak self]`?—— 从原理到实战一次讲透
ios·swift
大熊猫侯佩1 天前
天网代码反击战:Swift 三元运算符的 “一行破局” 指南
swiftui·swift·apple
大熊猫侯佩2 天前
在肖申克监狱玩转 iOS 26:安迪的 Liquid Glass 复仇计划
ios·swiftui·swift