雲端 Mac 上驗收 iOS 集合識別:Hashable、去重與欄位更新

雲端 Mac 上驗收 iOS 集合識別:Hashable、去重與欄位更新

雲端 Mac 上驗收 iOS 集合識別:Hashable、去重與欄位更新

從遠端取回同一筆記錄,名稱已更新,清單卻多出一列;另一處把它當作 Dictionary 的鍵,卻再也查不到原本的狀態。遇到這類問題,先別急著重寫重新整理邏輯。把模型放到雲端 Mac 上,獨立驗收相等關係與集合行為,往往就能直接找出識別定義的偏差。以下只測試值型別模型,不需要模擬器,也不串接真實 API。

先定義什麼算同一筆記錄

假設應用程式管理多個租戶的成員:成員識別碼只在各自的租戶內唯一,名稱則可以修改。因此,業務識別是「租戶識別碼+成員識別碼」,名稱不屬於識別的一部分。先把這項規則寫進測試說明,再實作協定。

這裡有兩個不同的問題:兩份資料是否代表同一位成員,以及兩份資料的所有欄位是否完全相同。若直接讓結構自動合成 Hashable 實作,所有儲存屬性通常都會參與比較;名稱一旦改變,兩份快照就不再相等。

不要只為了去重,就任意改變所有模型的相等語意。需要比較完整快照時,更穩妥的設計是另外定義不可變的識別鍵,並以字典建立識別鍵到完整記錄的對應。本文選擇以識別判定相等的模型,是為了直接呈現這份契約。

相等的值必須使用一致的雜湊規則,但雜湊相同的值不一定相等。雜湊用來縮小查找範圍,不是記錄的永久識別碼。

用最小腳本固定相等與雜湊規則

確認雲端 Mac 上的 Xcode 命令列工具可用,執行 xcrun swift --version,並將輸出與程式碼提交識別碼一併記錄。建立 identity-contract.swift,填入以下內容。這個範例沒有外部相依套件,也不會讀取憑證。

struct Member: Hashable {
    let tenantID: String
    let memberID: String
    var displayName: String

    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.tenantID == rhs.tenantID &&
        lhs.memberID == rhs.memberID
    }

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

let original = Member(
    tenantID: "team-a",
    memberID: "member-7",
    displayName: "Old"
)
var renamed = original
renamed.displayName = "New"

let otherTenant = Member(
    tenantID: "team-b",
    memberID: "member-7",
    displayName: "Old"
)

precondition(original == renamed, "Rename changed identity")
precondition(original.hashValue == renamed.hashValue,
             "Equal values hash differently")
precondition(original != otherTenant, "Tenant boundary lost")

var members: Set<Member> = [original]
precondition(!members.insert(renamed).inserted,
             "Duplicate identity inserted")
precondition(members.count == 1, "Unexpected member count")
precondition(members.first?.displayName == "Old",
             "Insert unexpectedly replaced the member")

let previous = members.update(with: renamed)
precondition(previous?.displayName == "Old",
             "Previous member missing")
precondition(members.count == 1, "Update changed member count")
precondition(members.first?.displayName == "New",
             "Updated fields missing")

members.insert(otherTenant)
precondition(members.count == 2, "Distinct tenants merged")

let states: [Member: String] = [original: "selected"]
precondition(states[renamed] == "selected",
             "Lookup failed after rename")
precondition(states[otherTenant] == nil,
             "Lookup crossed tenant boundary")

相等比較與 hash(into:) 使用同一組識別欄位。識別欄位宣告為 let,顯示欄位則可以更新。尤其要檢查一種不易察覺的錯誤:比較時忽略名稱,雜湊時卻把名稱加進去。這違反協定要求,不能期待每次執行都會立即暴露問題。

驗收去重,也驗收更新後的內容

在腳本所在的目錄執行:

set -eu
xcrun swiftc -Onone identity-contract.swift -o identity-contract
./identity-contract

成功時,腳本會安靜結束;前置條件不成立時,則會傳回非零結束狀態。保留 -Onone,不要使用會停用安全檢查的 -Ounchecked。整合進現有管線時,直接以這條命令的結束狀態判定測試是否通過。

只斷言 Set 的元素數量為一還不夠。insert 遇到相等成員時,不會替換既有內容,因此去重成功不代表名稱也已更新。上述範例先確認舊名稱仍然保留,再使用 update(with:) 驗證新名稱已寫入。

Dictionary 的測試則回答另一個問題:修改名稱後的鍵,是否仍能找到原本的狀態。若需要保存最新的完整記錄,建議讓字典的值存放記錄,鍵只承載識別,避免把顯示資料放進鍵裡。

補齊邊界案例,不測試特定雜湊數值

將識別規則與輸入規則分開

字串識別碼是否區分大小寫、是否允許空值,應由 API 契約決定,不要在雜湊函式裡偷偷轉換。需要正規化時,應在建立模型之前完成,而且相等比較與雜湊必須使用同一份正規化結果。

將下列檢查加入專案測試:

不要斷言某個 hashValue 必須等於固定整數,也不要要求不同識別的雜湊絕不重複。跨行程的隨機化與合法的雜湊碰撞,都會讓這類測試失去意義。重新執行腳本可以協助發現不穩定的行為,但不能取代對欄位規則的審查。

將契約落實到正式模型與合併檢查

獨立腳本能證明的只是範例邏輯。正式整合時,應將斷言移入測試目標,直接匯入正式模型;不要長期維護一份測試專用複本,否則即使業務程式碼改錯,腳本仍可能通過。

若目前的模型是參考型別,尤其不能在它作為集合元素期間,修改參與雜湊的欄位。集合不會因為物件內部發生變化,就自動重建索引。應優先改用不可變的識別鍵;若確實需要改變識別,先移除舊元素,再建立並插入新元素。

最後檢查持久化資料與頁面的選取狀態:它們應儲存明確的業務識別碼,而不是雜湊數值或集合走訪位置。驗收完成的標準很具體:修改名稱不會產生重複記錄,更新後能讀到新內容,不會合併不同租戶的成員,查找與刪除也仍然正確。

常見問題

不同紀錄的 hashValue 相同,是否代表實作錯誤?

不代表。不同識別可以發生雜湊碰撞,集合仍會透過相等比較區分值。必須確保相等的值具有一致的雜湊規則,而不是要求所有不同值的雜湊都不同。

修改名稱後,Set 會自動取代既有紀錄嗎?

不會。修改集合外的值副本,不會改變已儲存的成員。應明確呼叫 update(with:) 取代相等成員,再驗證成員數不變且儲存的是新欄位。

可以將 hashValue 存入資料庫作為永久主鍵嗎?

不可以。Swift 雜湊具有隨機化特性,不保證跨程序或版本穩定。請使用伺服器識別碼或 UUID;若識別碼只在租戶內唯一,業務鍵還須包含租戶識別碼。

為你的專案保留一台專屬 Mac

Mac mini 實體節點,非虛擬機器。可按日、週、月或季租用;先選擇用途與規格,再確認控制台即時回傳的可用狀態。

選擇機型與租期 查看完整價格