Hashable / Hasher

SwiftGG翻譯組發表於2019-03-01

作者:Mattt,原文連結,原文日期:2018-08-13
譯者:Damonwong;校對:LisionYousanflics;定稿:Forelax

當你在蘋果商店預約天才吧服務後,相關工作人員會幫你登記並且安排特定的服務時間,在被帶到座位上之後,工作人員會記錄你的身份資訊並新增到服務佇列當中。

根據一份來自零售店某位前員工的報告表示,對於顧客的描述有著嚴格的指導方針。他們的外貌特徵如:年齡、性別、種族、身高都沒有被使用 —— 甚至連頭髮的顏色都沒有被使用。而是通過顧客的著裝來描述,例如“黑色的高領毛衣,牛仔褲和眼鏡”。

這種描述顧客的方式和程式設計中的雜湊函式有很多共同之處。同許多優秀的雜湊函式一樣,它是連續和易計算的,可用於快速找到你正在尋找的內容(或者人)。我想你肯定也覺得這樣比佇列要好用多了。

這周我們的主題是 Hashable 和相關的新型別 Hasher。它們共同組成了 Swift 最受喜愛的兩個集合類 DictionarySet 的基礎功能。

假設你有一個可以比較相等性的物件**列表**。要在這個列表中找到一個特定的物件,你需要遍歷這個列表的元素,直到找到匹配項為止。隨著你向列表中新增更多的元素時,需要找到其中任何一個元素所需的平均時間是線性級的(O(n))。

如果將這些物件儲存在一個**集合**中,理論上可以在常量級時間(O(1))內找到它們中的任何一個 – 也就是說,在一個包含 10 個元素的集合中查詢或在一個包含 10000* 個元素的集合中查詢所需的時間是一樣的。這是怎麼回事呢?因為集合不是按順序儲存物件的,而是將物件內容計算的雜湊值作為索引儲存。當在集合中查詢物件時,可以使用相同的雜湊函式計算新的雜湊值然後查詢物件儲存位置。

* 如果兩個不同的物件具有相同的雜湊值時,會產生雜湊衝突。當發生雜湊衝突時,它們將儲存在該地址對應的列表中。物件之間發生衝突的概率越高,雜湊集合的效能就會更加線性增長。

Hashable

在 Swift 中,Array 為列表提供了標準的接⼝,Set 為集合提供了標準的接⼝。如果要將物件儲存到 Set 中,就要遵循 Hashable 協議及其擴充套件協議 Equatable。Swift 的標準對映介面 Dictionary 對它的關聯型別 Key 也需要遵循 Hashable 協議及其擴充套件協議。

在 Swift 之前的版本中,為了讓自定義型別能支援 SetDictionary 儲存需要寫⼤量的 樣板程式碼

以下面的 Color 型別為例,Color 使⽤了 8 位整型值來表示紅,綠,藍色值:

struct Color {
    let red: UInt8
    let green: UInt8
    let blue: UInt8
}
複製程式碼

要符合 Equatable 的要求,你需要提供一個 == 操作符的實現。要符合 Hashable 的要求,你需要提供⼀個名為 hashValue 的計算屬性:

// Swift < 4.1
extension Color: Equatable {
    static func ==(lhs: Color, rhs: Color) -> Bool {
        return lhs.red == rhs.red &&
               lhs.green == rhs.green &&
               lhs.blue == rhs.blue
    }
}

extension Color: Hashable {
    var hashValue: Int {
        return self.red.hashValue ^
               self.green.hashValue ^
               self.blue.hashValue
    }
}
複製程式碼

對於大多數開發者⽽⾔,實現 Hashable 只是為了能儘快讓要做的事情步入正軌,因此他們會對所有的儲存屬性使⽤邏輯異或操作,並在某一天呼叫它。

然⽽這種實現的一個缺陷是高雜湊衝突率。由於邏輯異或滿⾜交換率,像⻘色和⻩色這樣不同的顏色也會發⽣雜湊衝突:

// Swift < 4.2
let cyan = Color(red: 0x00, green: 0xFF, blue: 0xFF)
let yellow = Color(red: 0xFF, green: 0xFF, blue: 0x00)

cyan.hashValue == yellow.hashValue // true, collision
複製程式碼

大多數時候這樣做不會出問題;現代計算機已經足夠強大以至於你很難意識到效能的衰減,除⾮你的實現細節存在⼤量問題。

但這並不是說這些細節⽆關緊要 —— 它們往往極其重要。稍後會詳細介紹。

自動合成 Hashable 實現

從 Swift 4.1 開始,如果某個型別在宣告時遵循了 EquatableHashable 協議並且它的成員變數同時也滿足了這些協議,編譯器會為其自動合成 EquatableHashable 的實現。

除了大大的提高了開發人員的開發效率以外,還可以大幅減少程式碼的數量。比如,我們之前 Color 的例子 —— 現在是最開始程式碼量的 1/3 :

// Swift >= 4.1
struct Color: Hashable {
    let red: UInt8
    let green: UInt8
    let blue: UInt8
}
複製程式碼

儘管對語言進行了明顯的改進,但還是有一些實現細節有著無法忽視的問題。

在 Swift Evolution 提案 SE-0185: 合成 EquatableHashable 的實現 中, Tony Allevato 給雜湊函式提供了這個註釋:

雜湊函式的選擇應該作為實現細節,而不是設計中的固定部分;因此,使用者不應該依賴於編譯器自動生成的 Hashable 函式的具體特徵。最可能的實現是在每個成員的雜湊值上呼叫標準庫中的 _mixInt 函式,然後將他們邏輯異或(^),如同目前 Collection 型別的雜湊方式一樣。

幸運的是,Swift 不需要多久就能解決這個問題。我們將在下一個版本得到答案:

Hasher

Swift 4.2 通過引入 Hasher 型別並採用新的通用雜湊函式進一步優化 Hashable

在 Swift Evolution 提案 SE-0206: Hashable 增強 中:

使用一個好的雜湊函式時,簡單的查詢,插入,刪除操作都只需要常量級時間即可完成。然而,如果沒有為當前資料選擇一個合適的雜湊函式,這些操作的預期時間就會和雜湊表中儲存的資料數量成正比。

正如 Karoy LorenteyVincent Esche 所指出的那樣,SetDictionary 等基於雜湊的集合主要特點是它們能夠在常量級時間內查詢值。如果雜湊函式不能產生一個均勻的值分佈,這些集合實際上就變成了連結串列。

Swift 4.2 中的雜湊函式是基於偽隨機函式族 SipHash 實現的,比如 SipHash-1-3 and SipHash-2-4,分別在每個訊息塊異或雜湊之後執行一次 round + 三次 final round,或兩次 round + 四次 final round。(譯者注:這裡的 round 指的是偽隨機數變化。具體實現看 _round)

現在,如果你要自定義型別實現 Hashable 的方式,可以重寫 hash(into:) 方法而不是 hashValuehash(into:) 通過傳遞了一個 Hasher 引用物件,然後通過這個物件呼叫 combine(_:) 來新增型別的必要狀態資訊。

// Swift >= 4.2
struct Color: Hashable {
    let red: UInt8
    let green: UInt8
    let blue: UInt8

    // Synthesized by compiler
    func hash(into hasher: inout Hasher) {
        hasher.combine(self.red)
        hasher.combine(self.green)
        hasher.combine(self.blue)
    }

    // Default implementation from protocol extension
    var hashValue: Int {
        var hasher = Hasher()
        self.hash(into: &hasher)
        return hasher.finalize()
    }
}
複製程式碼

通過抽象隔離底層的位操作細節,開發人員可以利用 Swift 內建的雜湊函式,這樣可以避免再現我們原有的基於邏輯異或實現的衝突:

// Swift >= 4.2
let cyan = Color(red: 0x00, green: 0xFF, blue: 0xFF)
let yellow = Color(red: 0xFF, green: 0xFF, blue: 0x00)

cyan.hashValue == yellow.hashValue // false, no collision
複製程式碼

自定義雜湊函式

預設情況下,Swift 使用通用的雜湊函式將位元組序列縮減為一個整數。

但是,你可以使用你專案中自定義的雜湊函式來改進這個縮減的問題。比如,如果你正在編寫一個程式來玩國際象棋或者棋盤遊戲,你可以使用 Zobrist hashing 來快速的儲存遊戲的狀態。

避免雜湊氾濫(Hash-Flooding)

選擇像 SipHash 這樣的加密演算法有助於防止雜湊氾濫的 DoS 攻擊,這種攻擊會嘗試生成雜湊衝突,並試圖強制實施雜湊資料結構最壞的情況,最終導致程式慢下來。這在 2010 年初引發了一系列的網路問題

為了使事情變的更加安全,Hasher 會在每次啟動應用程式時生成一個隨機種子值,使得雜湊值更難以預測。

你不應該依賴特定的雜湊值,也不應該在程式執行中儲存特定的雜湊值。在極少數情況下,你確定要這麼做的話,可以設定 SWIFT_DETERMINISTIC_HASHING 識別符號來禁用隨機雜湊種子。

程式設計類比的挑戰在於它們通過邊界情況規範反社會行為。

當我們能夠考慮到攻擊者所有可能利用來達到某種險惡目的的情況時,這時能體現出我們優秀工程師的品質 —— 比如雜湊氾濫的 DoS 攻擊。在現實生活中,這麼做我們需要冒著失敗的風險去應用這些 AFK(Away From Keyboard)知識。

也就是說…親愛的讀者,我不希望你和你的朋友下次穿一樣的衣服去當地蘋果商店的天才吧中製造混亂和不和諧。

請不要這麼做。

相反的,希望你有下面的收穫:

當你在天才吧等候的時候,和穿同樣顏色衣服的人站得遠一點。這會讓每個人做事都變得容易得多。

本文由 SwiftGG 翻譯組翻譯,已經獲得作者翻譯授權,最新文章請訪問 swift.gg