Swift 4.2 新特性详解 Hashable 和 Hashe
Hashable 的 Conditional Conformance
使用 Dictionary
和 Set
的时候要求用作 Key 的类型实现 Hashable
协议。由于大多数内置类型天生是 Hashable
,因此大多数情况下,无需手动实现。但是对于一个自定义的类型,需要由我们来实现 Hashable
。然而实现var hashValue: Int
并非如它的接口那么显而易见。其中的原因我们在 Swift 4.1 新特性 (3) 合成 Equatable 和 Hashable 中详细的讨论过了,其中也讲到编译器在一定条件下会帮助合成 Hashable
中的函数。例如:
struct Person: Hashable {
var age: Int
var name: String
}
上述代码在 Swift 4.1 和 Swift 4.2 中都可以编译过,由于 Hashable
is a Equatable
,所以编译器实际上自动合成了 ==
以及 hashValue
两个函数。但是下一个相似的例子却在 Swift 4.1 中编译不过,在 Swift 4.2 中可以编译过。
struct Person: Hashable {
var age: Int
var pets: [String]
}
这是为什么呢?其实这是由于 [String]
在 Swift 4.1 中不是 Hashable
,所以编译器无法合成;而在 Swift 4.2 中由于标准库中添加了一组 Hashable
的 Conditional Conformance 扩展,所以可以合成。其中包含:
extension Array : Hashable where Element : Hashable
其含义是:当 Array
的元素是 Hashable
时,这个 Array
也是 Hashable
:由于String
本身是 Hashable
,所以[String]
在 Swift 4.2 中是 Hashable
,编译器的自动合成得以继续。
有关 Conditional Conformance,我们在另一篇文章中已经进行了详细的讨论 Swift 4.2 新特性详解 Conditional Conformance 的更新,它属于泛型特性,不是标准库的特权,我们完全自己也可以定义。在 Swift 4.2 中,如果有重复的定义,编译器会给出警告。
简化 Hashable 的实现
即便编译器合成 Hashable
的情况在 Swift 4.2 中得到了进一步的改进,我们在很多情况下也不得不自己实现 Hashable
:
- class 类型声明
Hashable
时 - extension 中声明
Hashable
时 - 有数据成员需要排除出
hashValue
计算时 - 自己能够提供更好的
hashValue
实现时
首先,我们看一下,一个好的 hashValue
实现在 Swift 4.1 中是怎么样的:
// Swift 4.1
struct Person: Hashable {
var age: Int
var name: String
var hashValue: Int {
return age.hashValue ^ name.hashValue &* 16777619
}
}
这段代码要求开发人员对于如何计算一个哈希值非常专业:首先 ^
是异或,&*
是防止乘法溢出 crash 的运算符,16777619
显然也不是一个随便选择的数字。所以简化 Hashable 第一个目的,是要简化 Hash 算法给程序员带来的心智负担。因此,在 Swift 4.2 中,实现同样的功能简化成为:
// Swift 4.2
struct Person: Hashable {
var age: Int
var name: String
func hash(into hasher: inout Hasher) {
hasher.combine(age)
hasher.combine(name)
}
}
在这段代码中,转而实现的是 Hashable
中定义的新方法 func hash(into hasher: inout Hasher)
,在这个方法的实现中,我们 99 % 的情况只要调用 hasher.combine
,传入需要纳入 Hash 计算的 Hashable
数据成员即可。对于字节流,Hasher
提供另一个combine
方法。我们来看一下 Hasher
的定义:
// Swift 4.2
public struct Hasher {
public mutating func combine<H>(_ value: H) where H : Hashable
public mutating func combine(bytes: UnsafeRawBufferPointer)
public __consuming func finalize() -> Int
}
而谁负责传入这个 Hasher
呢?其实是编译器自动生成的另一个 Hashable
的老方法 hashValue
,如下:
// Swift 4.2 supplied by the compiler
var hashValue: Int {
var hasher = Hasher()
self.hash(into: &hasher)
return hasher.finalize()
}
最后调用 finalize
一次生成最后的计算结果。可以看到新的 Hashable
设计不仅简化了用户的实现代码,还将计算 Hash 的职责抽离,使得将来在不改变用户代码的情况下,也能在标准库中优化计算 Hash 的代码。
Hashable 的向后兼容
由于 Hashable
作为协议加了一个新的方法, Swift 4.2 之前的代码还能编译过吗?答案是可以,编译器自动生成新的方法的实现如下:
// Supplied by the compiler:
func hash(into hasher: inout Hasher) {
hasher.combine(self.hashValue)
}
因此,在 Swift 4.2 下,实现任意一个 Hashable
的函数都可以通过编译,但我们推荐实现新的 hash(into:)
函数。
Hashable 的性能
首先,我们需要了解我们自己的代码可能带来的潜在性能问题。
struct Point: Hashable {
var x: Int
var y: Int
}
struct Line: Hashable {
var begin: Point
var end: Point
func hash(into hasher: inout Hasher) {
hasher.combine(begin.hashValue) // potential performance issue
hasher.combine(end) // correct
}
}
在这个例子中,我们不应当『提前』计算出 begin
的 hashValue
,尽管这从结果上是可行的。而是应当像 end
那样仅仅像Hasher
提出计算需求。那么combine
究竟做了什么呢?来看源码:
@inlinable
@inline(__always)
public mutating func combine<H: Hashable>(_ value: H) {
value.hash(into: &self)
}
简单来看,combine
仅仅是一个语法糖,实质上形成的是 Hashable.hash(into:)
的层层调用。为了消除这个语法糖带来的函数调用性能影响,标准库将它的接口定义和实现统统作为模块的一部分暴露出来了,允许用户代码内联,这就是@inlinable
的作用。而且只有实现稳定到与接口一样的程度,才应该这样声明。与@inlinable
配合的是@usableFromInline
,它同样作为模块ABI的一部分(但不作为API),@inlinable
的函数可以调用@usableFromInline
函数。这是Swift 4.2 的一个不常用的新特性,也是 Hashable
性能相关的另一方面。
Hashable 多次执行中的随机行为
最后我们讨论一下 1.hashValue
的值到底是什么?在 Xcode 9 中,他永远是固定的;然而在 Xcode 10 中它在每次运行的时候数字都不一样。
-9043285239196511288
-3192328192178018481
2941366561895793247
这是因为新的版本的默认行为是在程序每次执行的时候,加入不同的随机Seed,因此在多次运行过程中的结果是不同的,一次程序运行时候的多次1.hashValue
的调用结果是保持相同的。这个默认行为可以通过将环境变量 SWIFT_DETERMINISTIC_HASHING
设置成 1
变回原先的方式,但是我们不推荐,因为 Hash 每次执行加入随机性是为了防止哈希碰撞的攻击,这对于特别是服务端上 的 Swift 程序是有很重要价值的。
小结
- 讨论了标准库中新加入的
Hashable
Conditional Conformance,以及它对于自动合成Hashable
的意义。 - 默认情况下,在 Swift 4.2 中实现
Hashable
的新方法、不实现老方法。或者在恰当的情况下依赖编译器的自动合成。 - 编译器的自动合成行为 保证了 Swift 4.2 前的
Hashable
的实现代码的向后兼容。 -
Hashable
性能相关的问题:实现Hashable
不要提前计算出局部hashValue
以及@inlinable
消除函数调用性能消耗。 -
Hashable
多次执行中的随机性是为了解决潜在的哈希碰撞攻击。