今天講解一下在業界極為常見但也頗具爭議的 單例模式 (Singleton Pattern)。
「常見」,是因為它解決了一個很普遍的需求——「我需要一個在任何地方都能存取到的唯一物件」。而它之所以「具爭議」,正是因為它達成這個目標的方式,在大型專案中會引發許多後續問題。「在很多function會出現,但是很多時候function不會只解決一個問題」,這句話完美地觸及了兩個核心痛點:隱藏的依賴 (Hidden Dependencies) 和 違反單一職責原則 (Single Responsibility Principle)。
爭議的根源:由「隱藏依賴」引發的連鎖反應
這種「方便」的隨處可取,在大型專案中會導致嚴重的後果:
測試的夢魘,這是單例模式最被詬病的一點。好的程式碼應該是易於測試的。在單元測試中,我們希望能隔離被測試的單元,並用「模擬物件 (Mock Object)」來取代它的依賴。無法替換依賴: 在上面的例子中,我們要怎麼測試 ReportGenerator 但不真的去連資料庫呢?我們沒辦法!因為 DatabaseConnection.getInstance() 這行程式碼被寫死在裡面了,我們無法從外部把它換成一個假的測試用連線。
全域狀態干擾: 測試是有順序的。如果測試A修改了某個單例物件的狀態(例如 Logger.setLevel("DEBUG")),那麼這個狀態會殘留到測試B,可能導致測試B意外失敗。測試之間應該是完全獨立的,單例打破了這種獨立性。
確保一個類別在整個應用程式的生命週期中,只有一個實例 (Instance) 存在,並提供一個全域的、唯一的存取點來獲取這個實例。
簡單來說,無論你試圖創建多少次,你從頭到尾拿到的都會是同一個物件。
當你需要一個物件來協調整個系統的行為,且這個物件從邏輯上講只能有一個時,單例模式就非常有用。
一所學校只會有一位校長(在同一時間點)。所有處室(客戶端)要向校長匯報或取得決策時,他們不會自己「任命」一位新校長,而是會透過正常的管道(例如校長室)找到那位唯一的、已經存在的校長。這個校長室就是那個全域的存取點。
要實現單例模式,必須滿足三個關鍵點:
new
關鍵字隨意建立實例。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()
,建構子中的「讀取設定檔」操作也只執行了一次,並且最終兩個變數 config1
和 config2
指向的是完全相同的物件。
單例模式雖然常用,但也像一把雙面刃。
因為這些缺點,許多現代的開發框架(例如 Spring)會透過 依賴注入 (Dependency Injection, DI) 的方式來管理物件的生命週期,可以在達到類似單例效果的同時,避免上述的許多問題。