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 的哈希算法差异。
相关推荐
HarderCoder3 小时前
Swift 中的不透明类型与装箱协议类型:概念、区别与实践
swift
HarderCoder3 小时前
Swift 泛型深度指南 ——从“交换两个值”到“通用容器”的代码复用之路
swift
东坡肘子4 小时前
惊险但幸运,两次!| 肘子的 Swift 周报 #0109
人工智能·swiftui·swift
胖虎14 小时前
Swift项目生成Framework流程以及与OC的区别
framework·swift·1024程序员节·swift framework
songgeb21 小时前
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