为什么要"自定义"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。
方案:
- 用 struct 就能天然隔离;
- 如果必须用 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)
- Array/Set/Dictionary 都是"值语义",但内部通过引用 + 写时复制优化。
- 何时触发复制?
- 对变量做任何"可能改变其内容"的操作时,且引用计数 > 1。
- 大集合性能陷阱:
swift
var a = Array(0..<1_000_000)
var b = a // O(1),只复制指针
b[0] = -1 // 这一刻才复制 1_000_000 个元素
- 提前规避:
swift
a.reserveCapacity(a.count + 100) // 减少后续再分配
- 对 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 集合后:
- 会触发"隐式转换",可能额外分配;
- 作为 Dictionary Key 时,哈希实现走 NSObject 的
hash
属性,与 Swift 哈希算法不一致,跨语言传递时可能出意外。
建议:
- 纯 Swift 模块尽量用 Swift 原生类型;
- 必须桥接时,用
as String/
as NSError` 明确转换,再入库。
弱引用键 & 自动清理
Swift 标准库目前没有 NSMapTable 的"弱引用键"版本。
若要做"缓存 + 自动释放":
- 用 NSMapTable 桥接(失去值语义);
- 或用第三方库 WeakMap ;
- 也可自己包一层:
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 | 天生 | --- |
总结
- 默认合成的 Hashable 经常≠业务主键,手写时只哈希"不变的主键"。
- class 的引用身份会破坏值语义,哈希依赖字段必须不可变。
- 哈希碰撞会让 Set/Dictionary 性能瞬间崩塌,多字段就用
hasher.combine
连续混写。 - 大集合提前
reserveCapacity
,写时复制能省 2030% 时间。 - 与 Objective-C 桥接时,注意 NSString/NSError 的哈希算法差异。