iT邦幫忙

2025 iThome 鐵人賽

DAY 14
0
自我挑戰組

一條龍的軟體開發到維護,從校園工讀到職場工程師系列 第 14

Day14-Code design我遇到比較常見的patten/單例模式 (Singleton Pattern)

  • 分享至 

  • xImage
  •  

今天講解一下在業界極為常見但也頗具爭議的 單例模式 (Singleton Pattern)。

「常見」,是因為它解決了一個很普遍的需求——「我需要一個在任何地方都能存取到的唯一物件」。而它之所以「具爭議」,正是因為它達成這個目標的方式,在大型專案中會引發許多後續問題。「在很多function會出現,但是很多時候function不會只解決一個問題」,這句話完美地觸及了兩個核心痛點:隱藏的依賴 (Hidden Dependencies) 和 違反單一職責原則 (Single Responsibility Principle)。

爭議的根源:由「隱藏依賴」引發的連鎖反應
這種「方便」的隨處可取,在大型專案中會導致嚴重的後果:

測試的夢魘,這是單例模式最被詬病的一點。好的程式碼應該是易於測試的。在單元測試中,我們希望能隔離被測試的單元,並用「模擬物件 (Mock Object)」來取代它的依賴。無法替換依賴: 在上面的例子中,我們要怎麼測試 ReportGenerator 但不真的去連資料庫呢?我們沒辦法!因為 DatabaseConnection.getInstance() 這行程式碼被寫死在裡面了,我們無法從外部把它換成一個假的測試用連線。

全域狀態干擾: 測試是有順序的。如果測試A修改了某個單例物件的狀態(例如 Logger.setLevel("DEBUG")),那麼這個狀態會殘留到測試B,可能導致測試B意外失敗。測試之間應該是完全獨立的,單例打破了這種獨立性。

確保一個類別在整個應用程式的生命週期中,只有一個實例 (Instance) 存在,並提供一個全域的、唯一的存取點來獲取這個實例。

簡單來說,無論你試圖創建多少次,你從頭到尾拿到的都會是同一個物件。

解決什麼問題?

當你需要一個物件來協調整個系統的行為,且這個物件從邏輯上講只能有一個時,單例模式就非常有用。

  1. 資源共享與控制: 對於某些需要共享的資源,例如資料庫連線池、執行緒池,或是對外部硬體的控制(如印表機),我們希望由單一的物件來統一管理,避免資源使用上的衝突和浪費。
  2. 全域狀態管理: 當需要一個地方來儲存全域的設定資訊或狀態時,例如應用程式的設定檔管理器 (Configuration Manager) 或日誌記錄器 (Logger)。所有程式碼都需要讀取同一份設定或將日誌寫入同一個地方。

生活比喻:學校的校長

一所學校只會有一位校長(在同一時間點)。所有處室(客戶端)要向校長匯報或取得決策時,他們不會自己「任命」一位新校長,而是會透過正常的管道(例如校長室)找到那位唯一的、已經存在的校長。這個校長室就是那個全域的存取點。


程式碼實作 (Java 範例)

要實現單例模式,必須滿足三個關鍵點:

  1. 私有的建構子 (Private Constructor): 為了防止外部程式碼透過 new 關鍵字隨意建立實例。
  2. 私有的靜態實例變數 (Private Static Instance): 類別內部自己持有那個唯一的實例。
  3. 公開的靜態存取方法 (Public Static getInstance() Method): 提供外界唯一能取得該實例的方法。

我們用一個「應用程式設定管理器」的例子來實作。

// Singleton: ConfigurationManager
public class ConfigurationManager {

    // 2. 類別內部自己持有唯一的、私有的靜態實例
    // `volatile` 關鍵字確保多執行緒環境下的可見性
    private static volatile ConfigurationManager instance;

    private String serverUrl;

    // 1. 將建構子宣告為 private,防止外部直接 new
    private ConfigurationManager() {
        // 模擬讀取設定檔的耗時操作
        System.out.println("正在讀取設定檔...");
        this.serverUrl = "https://api.example.com";
        System.out.println("設定檔讀取完畢!");
    }

    // 3. 提供一個公開的靜態方法,作為取得實例的唯一入口
    public static ConfigurationManager getInstance() {
        // Double-Checked Locking (雙重檢查鎖定),確保執行緒安全且有效率
        if (instance == null) {
            synchronized (ConfigurationManager.class) {
                if (instance == null) {
                    instance = new ConfigurationManager();
                }
            }
        }
        return instance;
    }

    // --- 其他業務方法 ---
    public String getServerUrl() {
        return this.serverUrl;
    }
}

客戶端如何使用

客戶端程式碼無法 new 這個物件,只能透過 getInstance() 來取得它。

public class Main {
    public static void main(String[] args) {
        System.out.println("--- 程式開始 ---");

        // 第一次獲取實例
        ConfigurationManager config1 = ConfigurationManager.getInstance();
        System.out.println("Config 1 的 Server URL: " + config1.getServerUrl());

        // 第二次獲取實例
        ConfigurationManager config2 = ConfigurationManager.getInstance();
        System.out.println("Config 2 的 Server URL: " + config2.getServerUrl());

        // 驗證兩次獲取的實例是否為同一個
        if (config1 == config2) {
            System.out.println("\n驗證成功:config1 和 config2 指向同一個記憶體物件。");
            System.out.println("物件雜湊碼 (HashCode): " + config1.hashCode());
        } else {
            System.out.println("\n驗證失敗:config1 和 config2 是不同的物件!");
        }

        System.out.println("--- 程式結束 ---");
    }
}

執行結果:

--- 程式開始 ---
正在讀取設定檔...            <-- "讀取設定檔" 的訊息只會出現一次
設定檔讀取完畢!
Config 1 的 Server URL: https://api.example.com
Config 2 的 Server URL: https://api.example.com

驗證成功:config1 和 config2 指向同一個記憶體物件。
物件雜湊碼 (HashCode): 1252169911
--- 程式結束 ---

從結果可以看出,即使我們呼叫了兩次 getInstance(),建構子中的「讀取設定檔」操作也只執行了一次,並且最終兩個變數 config1config2 指向的是完全相同的物件。


單例模式的優點與缺點

單例模式雖然常用,但也像一把雙面刃。

優點 (Pros)

  • 確保唯一實例: 嚴格控制實例數量,符合特定業務邏輯。
  • 節省資源: 對於需要頻繁建立和銷毀的物件,單例可以避免性能開銷。
  • 全域存取: 提供了一個方便的全域存取點。

缺點 (Cons)

  • 違反單一職責原則: 一個類別既要負責自身的業務邏輯,又要負責管理自己的生命週期(保證自己是單例),職責過重。
  • 測試困難: 由於單例引入了全域狀態,使得單元測試變得非常困難。測試之間會互相影響,也很難模擬 (Mock) 一個單例物件。
  • 降低擴充性: 單例的耦合性很高,任何使用它的程式碼都直接依賴這個具體的類別,而不是介面。
  • 執行緒安全問題: 在多執行緒環境下,需要特別處理才能保證單例的唯一性,例如範例中的「雙重檢查鎖定」,這會增加程式碼的複雜度。

因為這些缺點,許多現代的開發框架(例如 Spring)會透過 依賴注入 (Dependency Injection, DI) 的方式來管理物件的生命週期,可以在達到類似單例效果的同時,避免上述的許多問題。


上一篇
Day13-Code design我遇到比較常見的patten/代理人模式 (Proxy Pattern)
下一篇
Day15-Code design我遇到比較常見的patten/裝飾者模式 (Decorator Pattern)
系列文
一條龍的軟體開發到維護,從校園工讀到職場工程師15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言