Swift 與 Objective-C

[Swift 基礎 #3] Swift 屬性總整理:儲存、計算、lazy、didSet 的 4 個選擇準則

這是 Swift 基礎系列第 3 篇。本文不羅列語法,而是以選擇準則整理五種屬性。核心問題只有一個:這個值會被儲存、被計算,以及何時建立?

閱讀 7 分鐘
[Swift 基礎 #3] Swift 屬性總整理:儲存、計算、lazy、didSet 的 4 個選擇準則 封面圖

var name = ""。這是 Swift 中最常見的一行,但能放在這裡的選項比想像中更多:儲存屬性、計算屬性、lazy、willSet 與 didSet,以及型別屬性。即使學會了所有語法,真正問到「這個值該用 lazy 還是計算屬性?」時,仍常常抓不到判斷標準。

這是 Swift 基礎系列第 3 篇。本文不羅列語法,而是以選擇準則整理五種屬性。核心問題只有一個:這個值會被儲存、被計算,以及何時建立?

在因為需要共用狀態而選擇型別屬性之前,也一起確認 Swift 單例的測試成本,就能更清楚看出 static letstatic var 的界線。

儲存 vs. 計算——記憶體中的值與當下產生的值

屬性的第一個分岔是儲存還是計算。

儲存屬性(stored property)是實際佔據執行個體空間的值。寫成 var name = "Kim" 時,執行個體記憶體會配置 name 的空間。struct 的大小由儲存屬性的總和決定。

計算屬性(computed property)沒有儲存空間。每次存取時執行程式碼產生值,實際上就像函式。

struct Rectangle {
    var width: Double   // 儲存
    var height: Double  // 儲存

    var area: Double {  // 計算——沒有儲存空間
        width * height
    }
}

你也可以把 area 做成儲存屬性。但如此一來,每次 width 改變都要更新 area,兩者不同步就會變成錯誤。這導出第一個準則:由其他值推導出的值不要儲存,改用計算。維持單一真實來源,就能從根本消除同步錯誤。

在計算屬性加上 set 就能寫入。儲存 celsius、將 fahrenheit 設為計算屬性,對 fahrenheit 賦值時就反向計算並更新 celsius。此時真正儲存的值仍只有一個,其餘都是觀看它的視窗。

也說明一下何時該用計算屬性取代函式。Apple 的 API 設計準則慣例是:計算成本低(通常接近 O(1))、沒有副作用,且概念上是「物件的屬性」時用屬性;計算昂貴或有副作用時用方法。這就是 array.count 是屬性、array.sorted() 是方法的原因。

lazy——第一次使用時建立的儲存屬性

lazy 是第三種選項。它是儲存屬性,但不是在建立執行個體時初始化,而是在第一次存取時才初始化。

class ImageProcessor {
    lazy var filters: [Filter] = loadExpensiveFilters()
}

常見用途有兩種。第一,初始化成本高但可能不會使用的值;每次建立執行個體都準備沉重資源很浪費。第二,需要參照 self 的屬性。一般儲存屬性的預設值會在 init 結束前計算,因此不能使用 self;lazy 將初始化延後到第一次存取,所以允許參照 self。這也是立即執行閉包(= { ... }())常與 lazy 搭配的原因。

有兩點要注意。lazy 必須是 var。因為它的值會從類似 nil 的未初始化狀態變成實際值,所以不能是 let。此外它不是執行緒安全的。多個執行緒同時第一次存取時,初始化可能執行兩次。多執行緒環境中若必須只建立一次,就需要其他機制。

它與計算屬性的差異也很明確:lazy 計算一次並儲存,之後回傳該值;計算屬性則每次重新計算。「昂貴但不變的值」用 lazy,「便宜但必須永遠最新的值」用計算屬性。

儲存是架上箱子,計算是接單後製作的工廠
儲存是架上箱子,計算是接單後製作的工廠

willSet 與 didSet——回應值的變更

儲存屬性可以加上變更觀察器。在值變更前(willSet)與變更後(didSet)執行程式碼。

class ProgressBar {
    var progress: Double = 0 {
        didSet {
            // oldValue會自動提供
            guard progress != oldValue else { return }
            updateUI()
        }
    }
}

典型用途是在值變更時自動處理後續工作,例如 UI 更新、記錄,以及值驗證(超出範圍就復原)。不用自行建立 setter,也能保留賦值語法並加入附帶動作。

實務上有兩個容易混淆的規則。第一,在 init 中賦值時不會呼叫觀察器。初始化是「設定」而不是「變更」,因此 didSet 中的 UI 更新不會套用到初始值。第二,修改值型別的屬性時,包含它的屬性之 didSet 也會被呼叫。如 person.name = "Lee",即使只修改 struct 內部,也會呼叫 person 的 didSet。值型別章節提到的「修改就是替換成新值」語意,在這裡同樣成立。

要小心濫用 didSet。若其中堆積太多邏輯,就很難追蹤一行賦值究竟做了什麼。改一個值卻送出網路請求,便違反最小驚訝原則。didSet 只放輕量同步,沉重邏輯移到明確的方法中會更安全。

型別屬性——繫結在型別而非執行個體上的值

加上 static 後,屬性會繫結到型別本身,而不是執行個體。

struct APIConfig {
    static let baseURL = URL(string: "https://api.example.com")!
    static var requestCount = 0
}

無論建立多少執行個體,型別屬性都只有一份。它適合常數集合(設定值、共用格式化器),標準函式庫也常在 Int.maxDouble.pi 這類位置使用。

有一點值得知道:static let 保證具備執行緒安全的延遲初始化。第一次存取時只初始化一次,並且能安全處理並行存取。lazy var 沒有這項保證,static 卻有。這也是單例的 static let shared 能在沒有額外鎖定機制下成立的依據。不過可變的 static var 狀態實際上就是全域變數,會破壞測試隔離,也可能成為資料競爭來源。單例為何被稱為反模式會在另一篇文章討論;這裡只留下「static var 是最後手段」這項準則。

只要問四個問題就能做出選擇
只要問四個問題就能做出選擇

選擇流程圖——用四個問題整理

把五種類型濃縮成一句話問題,就是以下這樣。

  1. 是否由其他值推導而來? → 計算屬性。只儲存一個真實來源。
  2. 初始化成本高,或需要 self 嗎? → lazy var。但要小心多執行緒的第一次存取。
  3. 是否有跟隨值變更的動作? → 儲存屬性 + didSet。但只放輕量工作。
  4. 是否不依賴執行個體、只需要一份? → static。若是 let,還能獲得安全的延遲初始化。
  5. 如果都不是 → 一般儲存屬性。多數情況下,這就是答案。

SwiftUI 的 @State、@Published 等屬性包裝器(property wrapper),最終也是建立在這套屬性系統上的語法。了解儲存、計算與觀察器的原理,就能看出包裝器其實是「包住儲存屬性,自動化 didSet 類行為」。直接建立屬性包裝器會在中級系列介紹。

直接執行確認的結果

我在 Apple Swift 6.3.3 中,於同一個物件記錄 lazy 初始化次數、didSet 的前後值,以及計算屬性的結果。輸出顯示讀取 lazy 屬性兩次,並將儲存屬性從 0 改為 7。

properties=lazy-builds:1,didSet:0->7,computed:14

正因這個小型執行結果,如果快取真的必須只建立一次,我不會只相信 lazy 這個語法。只要可能並行存取,就測試初始化次數,確認是否需要額外同步。相反地,didSet 只放像上述輸出那樣觀察狀態變化的輕量動作,可能失敗的 I/O 則移到明確的方法。

總結

  • 屬性的第一個準則是儲存還是計算。由其他值推導出的值使用計算,維持單一真實來源。
  • lazy 是第一次存取時初始化的儲存屬性,可解決昂貴初始化與 self 參照問題。代價是必須使用 var,且不具執行緒安全性。
  • willSet/didSet 用來回應值的變更,但在 init 中不會呼叫;若放入沉重邏輯,也會難以追蹤。
  • static let 是保證執行緒安全延遲初始化的型別屬性;static var 是全域狀態,應作為最後手段。

下一篇是基礎第 4 篇:guard。我們會深入解析在 Optional 文章中短暫介紹的提早離開,整理 Swift 社群為何與縮排展開戰爭。

推薦閱讀

來源與驗證