軟體設計

Swift Singleton Pattern:shared 被稱為反模式的真正原因

進行 Swift 開發時,經常會遇到只用一行 static let shared 建立的 Singleton。

閱讀 4 分鐘
Swift Singleton Pattern:shared 被稱為反模式的真正原因 封面圖

進行 Swift 開發時,經常會遇到只用一行 static let shared 建立的 Singleton。

剛開始製作 iOS App 時,很容易把網路管理器、使用者工作階段,甚至快取管理全部做成 Singleton。

因為很方便。從任何地方呼叫Manager.shared就可以了。

但專案變大後,奇怪的事情開始發生。測試做不下去。明明只修正了一個畫面,錯誤卻在完全無關的地方爆發。

Singleton 會被稱為反模式,當然有它的理由。

本文將從實務角度整理 Swift Singleton Pattern 的確切定義、過度使用時為何會被視為反模式,以及哪些情況仍然適合使用。

先說結論,Singleton 本身並不壞,問題在於「讓狀態可以從全域任何地方被修改」的方式。因此,毫無節制地濫用 shared 會讓測試受阻、隱藏相依性,甚至引發並行處理問題。

Swift Singleton Pattern,到底是什麼?

Singleton 是一種設計模式,用來保證整個 App 中只會存在一個執行個體。

Swift 可以非常簡短地建立它。這既是魅力,也是陷阱。

final class NetworkManager {
    static let shared = NetworkManager()  // App 中只有一個
    private init() {}                     // 禁止外部建立
    func request(_ url: URL) { /* ... */ }
}

static let由 Swift 以執行緒安全的方式只初始化一次。因此,不需要額外的 lock,建立執行個體本身也是安全的。

透過private init()阻止外部建立同樣是關鍵。少了它,就只是全域物件,而不是 Singleton。

只看到這裡會覺得非常乾淨。問題從它可以在任何地方被呼叫開始。


為什麼 shared 執行個體會被稱為反模式?

以下按照我實際遇到的順序,說明最常被指出的三個理由。

第一,測試會變成地獄。

如果在程式碼中直接呼叫NetworkManager.shared,測試時就沒有辦法把它替換成 mock 物件。

請求可能直接送到真實伺服器,或測試彼此共用狀態,因此只要順序改變,結果也會不同。

第二,相依性會被隱藏。

如果某個函式在內部偷偷使用UserSession.shared,只看函式簽章就無法知道它需要什麼。

協作時這真的很可怕,因為必須把整份程式碼打開,才能看見相依關係。

第三,全域可變狀態造成的並行處理問題。

如果 Singleton 內含屬性,而多個執行緒同時讀寫,就會發生資料競爭。

執行個體建立安全,不代表其中的狀態變更也安全。混淆這兩件事就會遇到當機。

以前到處堆 shared 的程式碼,現在回頭看真讓人捏一把冷汗
以前到處堆 shared 的程式碼,現在回頭看真讓人捏一把冷汗

總結如下。

Singleton 被嫌棄的真正原因,不是「只存在一個」,而是「可以從任何地方取用,也可以從任何地方修改」。


那麼 Singleton 絕對不能使用嗎?

不是。我現在仍會在特定情況下使用。

有些東西本來就自然只該存在一個。例如UserDefaults.standardFileManager.defaultURLSession.shared,Apple 也會在標準函式庫中使用 Singleton。

可以用以下方式訂定判斷標準。

  • 幾乎不變更狀態,主要是讀取 → 可以使用 Singleton
  • 整個 App 確實只能有一個 → 考慮 Singleton
  • 測試時需要不同的行為 → 建議使用相依性注入

重點是「不要在程式碼中直接呼叫 shared,而是從外部注入」。

以 shared 為預設值,測試時注入替身的架構
以 shared 為預設值,測試時注入替身的架構

即使使用相同的 Singleton,改成這樣之後也能大幅提升可測試性。

protocol Networking { func request(_ url: URL) }
extension NetworkManager: Networking {}

final class FeedViewModel {
    private let network: Networking
    init(network: Networking = .shared) {  // 預設值是 Singleton,測試時注入 mock 
        self.network = network
    }
}

平常使用預設的 Singleton 很方便,測試時則能放入替身物件,因此更有彈性。

不是捨棄 Singleton,而是只改變呼叫方式。


我在實務上遵守的 3 個規則

最後分享我在工作中為自己訂下的標準。

  1. 不要建立會在多個地方修改可變狀態的 Singleton。需要狀態時,明確指定擁有者。
  2. 不要在函式中直接呼叫shared,而要透過建構子或參數注入。
  3. 有並行存取的 Singleton,請使用 actor,或以序列佇列保護。

只要遵守這三點,幾乎不會陷入「專案因 Singleton 而腐敗」的情況。

Singleton 不是錯誤的工具,而是因為太容易使用,所以很容易被濫用。

在因為方便就到處散播shared之前,請先問自己一次:「之後能測試這個嗎?」這個問題會拯救 6 個月後的自己。

延伸閱讀