Swift 與 Objective-C

【Swift 中階 #7】屬性包裝器原理:@State 並非魔法的原因

屬性包裝器不是 SwiftUI 語法,而是 Swift 5.1 透過 SE-0258 引入的語言功能。本文整理編譯器如何翻譯 @Clamped、wrappedValue 與 projectedValue($) 的真相,以及避免濫用的界線。

閱讀 6 分鐘
【Swift 中階 #7】屬性包裝器原理:@State 並非魔法的原因 封面圖

使用 SwiftUI 的開發者每天會輸入數十次 @State 和 @Published。但如果問這個小老鼠到底做了什麼,答案往往不一致。「這不是 SwiftUI 語法嗎?」是常見的誤解。不是。屬性包裝器(property wrapper)是 Swift Evolution 提案 SE-0258 在 Swift 5.1 引入的語言功能,而 SwiftUI 只是它最知名的使用者。

這是中階系列第 7 篇。我們會親手實作屬性包裝器的行為來理解其原理,並整理 wrappedValue、projectedValue($) 的真相,以及避免濫用的判斷標準。

問題意識 — 每個屬性都重複撰寫包裝邏輯

在屬性篇中,我們看過使用 didSet 驗證值的模式。但如果多個屬性都需要相同的驗證呢?如果音量、亮度和進度都要限制在 0…1,就會複製三份 didSet。

var volume: Double = 0.5 {
    didSet { volume = min(max(volume, 0), 1) }
}
var brightness: Double = 0.5 {
    didSet { brightness = min(max(brightness, 0), 1) }
}
// 相同的程式碼不斷出現...

同一套邏輯卻要針對每個屬性重新撰寫,這就是泛型篇提過「重複還是安全」問題的屬性版本。屬性包裝器能為這套包裝邏輯命名並重複使用。

@propertyWrapper
struct Clamped {
    private var value: Double = 0
    var wrappedValue: Double {
        get { value }
        set { value = min(max(newValue, 0), 1) }
    }
}

struct Player {
    @Clamped var volume: Double
    @Clamped var brightness: Double
}

即使指定 player.volume = 1.5,實際儲存的仍是 1.0。驗證邏輯只存在於 Clamped 這一處。

運作原理 — 小老鼠如何進行翻譯

要確認 @Clamped 並非魔法,只要查看編譯器的翻譯結果即可。@Clamped var volume: Double大致會展開如下。

private var _volume = Clamped()          // 實際儲存:包裝器實例
var volume: Double {                     // 我們使用的名稱:計算屬性
    get { _volume.wrappedValue }
    set { _volume.wrappedValue = newValue }
}

關鍵在於兩行。真正儲存的是加上底線的包裝器實例,而我們存取的名稱則是通往包裝器 wrappedValue 的計算屬性。屬性篇中建立的儲存屬性與計算屬性區分,在這裡原封不動地成為基礎。包裝器說到底就是把「儲存屬性+計算屬性+重複邏輯」封裝成單一型別,再透過小老鼠語法套用。

理解這項翻譯後,包裝器的限制也會變得自然。加上包裝器的屬性其實是計算屬性,因此再加上 didSet 時行為容易混淆;以及過去不能套用於區域變數或計算屬性的限制(Swift 5.5 起允許區域變數),全都取決於「帶底線的儲存空間會在哪裡建立」。

@Clamped 宣告被翻譯為帶底線儲存空間與計算屬性的編譯器示意圖
@Clamped var volume 會被翻譯為帶底線的儲存空間與計算屬性

projectedValue — 美元符號的真相

在 SwiftUI 中,你可能看過像 $text 這樣加上美元符號的寫法。這也是包裝器的功能。只要在包裝器型別中宣告名為 projectedValue 的屬性,編譯器就會提供稱為 $이름 的第三條通道。

  • volume → wrappedValue(值本身)
  • _volume → 包裝器實例(只能在宣告型別內存取)
  • $volume → projectedValue(包裝器額外提供的內容)

「額外提供的內容」取決於包裝器設計者。SwiftUI 的 @State 透過 $ 提供 Binding(讀寫通道),而 Combine 的 @Published 則提供 Publisher(變更串流)。同樣的 $ 語法卻會產生不同的物件,原因在於這是各包裝器的設計決定,而不是語言規則。自行建立包裝器時也一樣。若在上面的 Clamped 加入能回報「值是否曾被截斷」的 projectedValue,就能透過 $volume 這個 API 確認「剛才的指派是否超出範圍」。

實戰配方 — 透過 UserDefaults 包裝器學習設計

實務上最廣泛使用的自製包裝器模式之一,就是存取 UserDefaults。我們一邊確認原理,一邊動手建立。

@propertyWrapper
struct UserDefault<T> {
    let key: String
    let defaultValue: T

    var wrappedValue: T {
        get { UserDefaults.standard.object(forKey: key) as? T ?? defaultValue }
        set { UserDefaults.standard.set(newValue, forKey: key) }
    }
}

enum Settings {
    @UserDefault(key: "hasSeenOnboarding", defaultValue: false)
    static var hasSeenOnboarding: Bool
}

鍵字串拼寫錯誤、轉型與預設值處理等重複邏輯會收進包裝器,使用端只剩下 Settings.hasSeenOnboarding = true 這一行。這個範例的重點還包括:泛型 <T> 與 init 參數(key、defaultValue)同樣會套用到包裝器。@ 符號後的括號就是呼叫包裝器的 init。

這些場景就是包裝器的主場:變更儲存位置(UserDefaults、鑰匙圈)、包裝存取(執行緒鎖、記錄)、整理值(限制範圍、去除空白)。共同點是,它們都是「與值的意義無關、屬於儲存與存取的技術性關注點」。從關注點分離的角度來看,包裝器是將技術性關注點從屬性宣告中分離出來的工具。

濫用界線 — 藏在 @ 後面的複雜度

包裝器的風險正是來自它的優點:任意程式碼都可能藏在一行指派後面。這項功能與最小驚奇原則正面衝突。

我建議用三項標準劃定界線。第一,包裝器內只做可預期的事。整理值或變更儲存位置沒有問題,但若把網路請求或畫面切換等沉重的副作用藏在指派後面,就會變成除錯地獄。第二,只使用團隊熟悉的包裝器。@State 這類生態系標準,或團隊程式碼庫中有文件說明的包裝器,都是資產;但如果每個檔案都出現新的 @,程式碼審查就會變成尋找包裝器定義的遊戲。第三,只使用一次的邏輯就留在 didSet 中。包裝器的目的在於重複使用,因此等第二個使用場景出現時再提升抽象層級才是正確做法。這正是 YAGNI(You Aren’t Gonna Need It — 在需要之前不要建立)的原則。

最後補充一個最新方向。隨著 Swift 與 SwiftUI 轉向以巨集為基礎,Observation 框架中的 @Observable 就是這類不是包裝器而是巨集的 @。有了 @ 不代表它一定是屬性包裝器。巨集是什麼,以及它與包裝器有何不同,預計在進階系列中介紹。

一座有名字、底線與美元符號三扇門的屬性保險箱插圖
名字、底線與美元符號:一個屬性會出現三扇門

總結

  • 屬性包裝器並非 SwiftUI 專屬功能,而是 Swift 5.1 的語言功能(SE-0258),可將每個屬性重複出現的包裝邏輯封裝成型別以便重複使用。
  • 其原理是轉譯。@Wrapper var x 會展開成「底線包裝器實例(儲存)+名為 x 的計算屬性(通道)」。
  • $x 是稱為 projectedValue 的第三個通道,而要提供什麼內容則由包裝器設計者決定(@State 提供 Binding,@Published 提供 Publisher)。
  • 它適合用於變更儲存位置、包裝存取、調整值等技術性關注點;沉重的副作用與一次性邏輯不應放進包裝器。

下一篇是中階系列的最後一篇:KeyPath。本文將整理以反斜線語法\.name將屬性視為值的原理,以及 map(.name)得以實現的背景。


參考資料

延伸閱讀