讓自訂 Swift 相等性與雜湊使用同一個不可變值定義。本指南會建立一個本機圖塊索引鍵,其名稱不區分字母大小寫,但尺寸必須完全相同,接著檢查相等性、集合成員資格和字典查找。

明確定義兩個圖塊何時相同

假設你正在製作一個小型 Mac 桌面繪圖練習。你需要依名稱、寬度和高度將合成圖塊描述分組。讀者的任務是以一致的方式實作分組:尺寸相同時,Orbit 和 ORBIT 應產生相等的索引鍵;任一尺寸變更,都應產生不同的索引鍵。

在實作任何一個協定之前,先寫下規則。在此範例中,兩個索引鍵的名稱經過大小寫折疊後相同,且寬度與高度也相同,才視為相等。尺寸使用正整數繪圖單位。開頭和結尾的空格仍有意義。這是此範例採用的應用程式規則,並非 Swift 強制要求。

合成輸入 預期關係 原因
Orbit、40 × 20 和 ORBIT、40 × 20 相等 不區分字母大小寫
Orbit、40 × 20 和 orbit、41 × 20 不相等 寬度會參與相等性判斷
Orbit、40 × 20 和 orbit、40 × 21 不相等 高度會參與相等性判斷
Orbit、40 × 20 和 orbit 、40 × 20 不相等 結尾空格會保留

當特定分組規則與顯示圖塊的完整值不同時,請使用專用索引鍵。原始顯示拼法則保留在外層的呈現模型中。這樣可以避免宣稱兩個完整圖塊描述可互換,卻又把可見名稱差異視為其值的一部分。Swift 的 Equatable 文件以可替代性描述相等性,並要求具備自反性、對稱性和遞移性。Equatable

讓相等性與雜湊使用相同的組成部分

Swift 的 Hashable 協定繼承自 Equatable。其核心要求是:相等的實例必須依相同順序,將相同的值提供給雜湊器。Hashable 值可作為集合元素和字典索引鍵。Hashable

常見錯誤是在 == 中比較正規化後的名稱,卻以原始拼法計算雜湊。如此一來,兩個方法對名稱的意義便不一致。另一個錯誤是在相等性判斷中省略寬度,卻在雜湊時納入寬度。這兩種設計都會讓被宣告為相等的值提供不同的雜湊輸入。Apple 明確指出,自訂實作的兩個方法都應使用相同的重要屬性。採用常見協定

在此範例中,將相等性判斷的組成部分放入私有巢狀結構。讓 Swift 合成該結構的相等性與雜湊實作,然後讓外層的兩個操作都委派給它。如此一來,組成欄位清單只會宣告一次,方便檢視。若結構的儲存屬性符合所需協定,Swift 支援合成協定遵循實作。協定——使用合成實作來採用協定

如果你的型別恰好只儲存相等性判斷所需的組成部分,通常只要合成 Hashable 遵循實作即可。下方明確列出外層方法,是為了示範如何讓既有自訂實作保持一致,而不必維護兩份彼此獨立的欄位清單。

在 Mac 上執行完整的本機範例

你需要安裝含有 Foundation 的 Swift 工具鏈。在 Terminal 中執行 swift --version 檢查,這也是 Swift 入門文件建議的做法。如果無法使用 Swift,請先依照官方 macOS 安裝指南完成安裝,再繼續操作。開始使用、安裝 Swift — macOS

以下是一個包含完整 Swift 程式的 shell 命令。將整個區塊貼到 Terminal 中。它會透過標準輸入提供程式;範例只執行記憶體內的計算並輸出結果。研究環境無法使用 Swift,因此程式碼雖已檢視,卻無法實際執行。

swift - <<'SWIFT'
import Foundation

struct TileKey: Hashable, Sendable {
    private struct Components: Hashable, Sendable {
        let foldedName: String
        let width: Int
        let height: Int
    }

    private let components: Components

    init(name: String, width: Int, height: Int) {
        precondition(width > 0 && height > 0,
                     "Tile dimensions must be positive")

        let foldedName = name.folding(
            options: [.caseInsensitive],
            locale: Locale(identifier: "en_US_POSIX")
        )
        components = Components(
            foldedName: foldedName,
            width: width,
            height: height
        )
    }

    static func == (lhs: TileKey, rhs: TileKey) -> Bool {
        lhs.components == rhs.components
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(components)
    }
}

let a = TileKey(name: "Orbit", width: 40, height: 20)
let b = TileKey(name: "ORBIT", width: 40, height: 20)
let c = TileKey(name: "orbit", width: 40, height: 20)
let wider = TileKey(name: "orbit", width: 41, height: 20)
let taller = TileKey(name: "orbit", width: 40, height: 21)
let spaced = TileKey(name: "orbit ", width: 40, height: 20)
let samples = [a, b, c, wider, taller, spaced]

precondition(a == b && b == c && a == c)
precondition(a != wider && a != taller && a != spaced)

// Check the equality laws over this finite sample.
for first in samples {
    precondition(first == first, "Reflexivity failed")
    for second in samples {
        precondition((first == second) == (second == first),
                     "Symmetry failed")
        if first == second {
            precondition(first.hashValue == second.hashValue,
                         "Equal values have different hashes")
        }
        for third in samples where first == second && second == third {
            precondition(first == third, "Transitivity failed")
        }
    }
}

let uniqueKeys = Set(samples)
precondition(uniqueKeys.count == 4)
precondition(uniqueKeys.contains(b))

let patterns = [a: "striped"]
precondition(patterns[b] == "striped")

print("Unique keys:", uniqueKeys.count)
print("Equivalent key found:", uniqueKeys.contains(b))
print("Pattern:", patterns[b] ?? "missing")
print("Sample contract checks passed")
SWIFT

預期輸出:

唯一索引鍵:4
找到相等索引鍵:true
圖樣:條紋
範例契約檢查已通過

這六個輸入會形成四組:三種大小寫拼法共用一個索引鍵,而寬度不同、高度不同和結尾空格變體各自形成一組。字典檢查會確認能否使用相等的索引鍵 b,查到以 a 關聯的值。這些檢查會驗證預期的集合行為,且不依賴任何特定的數值雜湊結果。

此初始化器會在尺寸不是正數時停止。對於這個固定的合成練習,無效尺寸代表程式設計錯誤,因此這種處理方式很合適。如果桌面介面允許使用者尚未填完尺寸,就應在建立索引鍵前驗證輸入,或將初始化器改為回傳選用值或拋出錯誤。不要默默替換尺寸,因為那會改變相等性規則。

明確規定文字正規化方式

正規化步驟透過 Swift 的字串 API 呼叫 Foundation 的 folding(options:locale:)。這個 API 會回傳套用指定折疊選項後的字串。此處只指定 .caseInsensitive。StringProtocol.folding(options:locale:)

Foundation 將大小寫、字寬和變音符號折疊列為不同選項。文件也說明,地區設定可能改變折疊結果,且折疊後的文字可能不適合顯示。因此,範例指定固定的地區設定,並只在索引鍵內儲存折疊後的結果。此處的合成名稱刻意保持簡單。這項地區設定選擇定義了本練習的行為,並非適用於所有命名介面的通用建議。NSString.folding(options:locale:)

實用的設計決策是在建立索引鍵時只正規化一次。之後,相等性與雜湊都會使用儲存的結果,因此兩種操作都不會各自選擇地區設定或轉換方式。若應用程式日後需要不同的比較規則,就應按照新規則建立索引鍵,並重建受影響的記憶體內集合。

擴大正規化範圍前,先加入能區分預期分組方式的範例。空格是否重要?帶重音字母是否應保持不同?字寬看起來不同的字元是否應視為同一個名稱?先把這些問題定為產品規則,再實作一套供兩種操作共用的轉換方式。

不依賴雜湊數值,診斷不一致問題

將此設計套用到既有型別時,請依照以下步驟:

  1. 寫下一組必須判定為相等的值,以及每個必須區分值的欄位各一組範例。
  2. 檢視 == 實際比較的值,包括正規化結果和衍生屬性。
  3. 檢視 hasher.combine 收到的值及其順序。
  4. 將共用定義移至單一不可變的組成值中,或明確讓兩種實作都使用相同的組成部分。
  5. 使用相同的測試資料,檢查相等性規則、相等值的雜湊、集合成員資格和字典查找。

不要透過比較 hashValue 來實作相等性。相等的值必須有相等的雜湊,但雜湊相同並不能證明值相等。完整的相等性組成部分仍是 == 的依據。同樣地,若測試要求每一組不相等的值都必須有不同雜湊,那就超出了雜湊契約的保證範圍。Hashable

檢查此契約時,只應比較目前這次執行期間的雜湊值。Swift 的 Hasher 使用隨機種子,其演算法也可能隨標準函式庫版本而改變。因此,昨天執行時印出的整數不適合作為今天執行的預期結果。Hasher

這些有限的範例檢查適合作為回歸檢查,但無法證明所有可能的字串與尺寸都符合規則。結構上的論證也很重要:兩個外層方法都會委派給同一個不可變的 Components 值,而其儲存欄位完整表達了所選定的契約。

將並行需求與相等性分開處理

範例將兩個結構都宣告為 Sendable,因為它們的儲存值是字串、整數和另一個可傳送結構。Swift 文件指出,可傳送的結構必須只包含可傳送的儲存屬性。可傳送性關乎在不同並行區域間傳遞值;它不會定義哪些值互相相等。Sendable、Swift 程式語言——並行處理

這項本機計算不需要任務或 actor。若日後將它用於並行桌面程式碼,請讓相等性和雜湊維持為對索引鍵儲存組成部分進行的同步操作。把任何共用可變集合視為獨立的並行設計問題。語言指南說明,actor 和隔離機制可用來協調對可變狀態的存取。Swift 程式語言——並行處理

套用此模式時也要留意 Foundation 的界線:NSObject 子類別會使用繼承而來的相等性與雜湊行為。Apple 支援的自訂方式是同時覆寫 isEqual(_:) 和 hash。此範例中的純 Swift 結構則使用 == 和 hash(into:)。採用常見協定

對本機圖塊索引鍵而言,易於維護的做法是先寫下比較規則,再用單一不可變表示法保存該規則,並透過實際集合查找進行檢查。定義變更時,請一併更新組成表示法與範例,讓相等性與雜湊持續描述同一個值。

來源