Swift 與 Objective-C

[Swift 中階 #8] Swift KeyPath 總結:map(\.name) 的原理

\.name 不是 map 的縮寫語法,而是獨立型別 KeyPath 的值。本文整理將屬性存取轉成值的意義、唯讀、值型別寫入與參考型別寫入三個層級,以及實務上的應用場景。

閱讀 6 分鐘
[Swift 中階 #8] Swift KeyPath 總結:map(\.name) 的原理 封面圖

users.map(\.name)。這是現代 Swift 程式碼中常見的一行。若問起以反斜線開頭的 \.name 是什麼,常會得到「不是 map 的縮寫語法嗎?」的回答。這只對了一半。它是獨立型別 KeyPath 的值;這種語法之所以可行,不是因為 map 特別,而是因為 KeyPath 可以出現在函式的位置。

這是中階系列的最後一篇,共第 8 篇。我們將整理 KeyPath 所謂「將屬性存取變成值」的意義、三種 KeyPath 型別的差異,以及它在實務中展現價值的場景。

什麼是 KeyPath — 將通往屬性的路徑當成值

在閉包篇中,我們看過將函式當成值處理的威力。KeyPath 將這個概念套用到屬性存取上。從 user.name 這個存取動作中移除 user,只留下「通往 .name 的路徑」,並將它變成值,就是 \User.name

let path = \User.name        // KeyPath<User, String>
let user = User(name: "Kim", age: 30)
let name = user[keyPath: path]   // "Kim"

型別簽章揭示了本質。KeyPath<User, String> 是「從 User 出發、抵達 String 的路徑」。它尚未繫結至任何執行個體,因此可以放進變數、傳給函式,或收集到陣列中。這與使用字串鍵(「name」)存取屬性的動態語言,決定性的差異在於型別安全。像 \User.nmae 這樣的拼字錯誤會成為編譯錯誤,路徑的起點與終點型別也都會在編譯期驗證。這是把 Objective-C 的 KVC(Key-Value Coding)字串鍵所做的工作帶進型別系統,也再次體現第一篇所談的安全優先理念。

路徑可以串接。可以像 \User.address.city 一樣穿過巢狀屬性,也可以像 \User.name.count 一樣延伸到標準函式庫屬性。還能使用 appending(path:) 在執行期間串接兩條路徑。

出現在函式位置的 KeyPath — map(.name) 的原理

users.map(\.name) 能夠編譯,是因為 Swift Evolution 提案 SE-0249。在需要 (Root) -> Value 函式的位置傳入 KeyPath<Root, Value> 後,編譯器會自動將它轉換成 { $0[keyPath: path] } 閉包。因此,下面兩行是相同的程式碼。

let names = users.map { $0.name }
let names = users.map(\.name)

哪一種比較好?若只是單純擷取屬性,普遍認為 KeyPath 較好。{ $0.name } 需要經過「讀取閉包 → 理解 $0 是什麼 → 啊,原來是取出一個屬性」三個步驟;而 \.name 的語法本身就是「取出 name」。這是高階函式篇所說的意圖宣告的進一步壓縮。相反地,只要混入任何轉換邏輯($0.name.uppercased() + "님"),就應該使用閉包。KeyPath 專門用於擷取,不是轉換工具。

這種自動轉換不只適用於 map,而是適用於所有接收函式的位置:filter(\.isActive)compactMap(\.thumbnail)sorted(by:) 使用的 KeyPathComparator,以及 contains(where:)。高階函式的實用寫法遇上 KeyPath 後,還能再縮短一步。

分為唯讀、值型別寫入與參考型別寫入的 KeyPath 三層級示意圖
唯讀、值型別寫入、參考對象寫入,共有三個層級

三種 KeyPath — 唯讀,還是也能寫入?

KeyPath 具有層級。編譯器會根據路徑允許的存取方式建立不同型別。

KeyPath<Root, Value> — 唯讀。 這是通往 let 屬性或唯讀計算屬性的路徑。

WritableKeyPath<Root, Value> — 可讀寫。 這是通往 var 儲存屬性(或具有 setter 的計算屬性)的路徑,能透過路徑修改值型別的屬性。

ReferenceWritableKeyPath<Root, Value> — 寫入參考對象。 這是通往類別執行個體 var 屬性的路徑。即使以 let 持有參考,仍能修改內容物的值與參考語意,也反映在 KeyPath 型別中。

這項區分在實務上真正有意義的時刻,是撰寫「透過路徑修改值」的程式碼時。

func update<T, V>(_ items: inout [T], path: WritableKeyPath<T, V>, to value: V) {
    for i in items.indices {
        items[i][keyPath: path] = value
    }
}

update(&cells, path: \.isSelected, to: false)   // 取消全選⟧

傳入唯讀 KeyPath 會造成編譯錯誤。「這個函式會修改該屬性」的契約已寫入簽章,這與 throws 將失敗可能性刻進函式簽章,是相同的語法理念。

實務中的真正用途 — 分離設定與邏輯

map 縮寫只是 KeyPath 的入門,真正的價值在於將「要處理哪個屬性」設計成資料。

外部化排序依據。 建立表格排序 UI 時,不必為每個欄位撰寫排序函式,而是宣告 KeyPathComparator 陣列。例如 [KeyPathComparator(\.name), KeyPathComparator(\.date, order: .reverse)]。根據使用者選取的欄位,只替換 comparator 並傳給 items.sorted(using:),排序邏輯就能固定為一行,只有依據以資料形式變動。

表單繫結與驗證表格。 以 KeyPath 宣告「這個欄位對應 User 的這個屬性」,欄位巡覽、驗證與儲存就會成為資料表驅動(table-driven)程式碼。欄位增加時,邏輯不必增加。

SwiftUI 與 Observation 的基礎。 List(users, id: \.id) 的 id 參數是 KeyPath,Observation 框架追蹤「讀取了哪些屬性」同樣以 KeyPath 為基礎。凡是框架需要知道「你的型別中的哪個屬性」的地方,KeyPath 都是標準通用語言。

可以看出共通模式:固定一套邏輯,再以值注入要套用的屬性。就像泛型將型別變成參數,KeyPath 將屬性選擇變成參數。把它視為 Strategy pattern 的超輕量版本也不為過。

需要注意的是濫用。混入 \.self 或多層路徑的泛型 API,簽章會迅速變得難以理解。依照 Progressive Disclosure 的原則,當複雜度開始滲漏到使用端時,就應退回普通閉包或明確函式。

替換排序依據卡片的 sorted using 機械插圖
固定一套邏輯,只用卡片替換目標屬性

總結

  • KeyPath 是將屬性存取路徑變成值的型別。\User.nameKeyPath<User, String>,拼字錯誤與型別不一致都會在編譯期被捕捉。
  • map(\.name) 是 SE-0249 帶來的自動轉換。單純擷取使用 KeyPath,混入轉換時則使用閉包。
  • 共有三個層級:唯讀 KeyPath、寫入值型別的 WritableKeyPath,以及寫入參考對象的 ReferenceWritableKeyPath。會修改資料的函式要求 Writable,將契約刻進簽章。
  • 真正的價值在於將屬性選擇參數化。排序依據、表單繫結、指定 id 等需要「邏輯只有一套、只替換目標屬性」的場景,都適合使用這項標準工具。

中階系列 8 篇至此完結。接下來將進入 Swift Concurrency 深入系列。第一篇會說明 async/await 解決了回呼的哪些問題、如何解決,以及 suspension 這個概念的確切意義。


參考資料

延伸閱讀