Swift 4.2 新特性詳解 Hashable 和 Hasher

Hashable 的 Conditional Conformance

使用 DictionarySet 的時候要求用作 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
  }
}

在這個例子中,我們不應當『提前』計算出 beginhashValue,儘管這從結果上是可行的。而是應當像 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 多次執行中的隨機性是爲了解決潛在的哈希碰撞攻擊。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章