雲端 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;若識別碼只在租戶內唯一,業務鍵還須包含租戶識別碼。