什麼是單例模式?
單例模式是一種創建型設計模式,它限制一個類只能有一個實例,並提供一個全局訪問點來獲取該實例。換句話說,無論你嘗試創建該物件多少次,你總是會得到同一個單一實例。當整個系統中需要精確地使用一個物件來協調操作時,這種模式非常有用。典型的實現方式是將類的構造函數設為私有,並暴露一個靜態方法或屬性來獲取唯一的實例。

現實世界類比
想像你身處一個巨大的機場,裡面有數百個登機門、成千上萬的旅客,以及無數的航班起降。儘管如此複雜,只有一座空中交通管制塔負責管理整個機場的運作。
所有航班、飛行員和航空公司都必須與這座單一塔台溝通,以獲得降落和起飛的許可,進行協調並避免災難。如果有多個獨立的塔台控制同一機場的不同部分,系統將陷入混亂,指令衝突且可能導致撞機。
這正是單例模式的工作原理——它確保存在一個中心實例(空中交通管制塔),該實例協調所有通信和指令,防止系統中的不一致和衝突。
它解決的問題

單例模式主要解決軟件設計中的兩個問題:
- 控制實例化(單一實例): 它確保一個類別在整個程式中只有一個實例。這在你需要單一的協調點或單一的共享資源時非常重要。例如,你可能只想要一個數據庫連接或一個打印機假脫機程式的實例,以防止衝突的存取。通過防止額外的實例化,單例模式避免了多個實例同時修改同一資源可能導致的不一致。
- 全球存取點:它提供了一個眾所周知的全球存取點來存取該實例,而不是使用全域變數。在沒有單例模式的情況下,人們可能會使用全域變數來共享一個物件(如全域配置或日誌記錄器)。但全域變數可能會被覆寫或造成命名空間污染。單例模式提供了一種受控的方式來提供全域存取,同時保護實例不被替換或重複。這意味著在程式碼的任何地方,都可以存取單例實例(通常是通過像
getInstance()這樣的靜態方法或類似方法),確保程式的所有部分都使用相同的物件。
透過解決這兩個問題,單例模式確保存在單一的資源管理器並且易於存取。這對於協調應用程式中需要共享資訊或資源的不同部分的行動至關重要。
單例模式的優勢
儘管圍繞著過度使用的爭議,單例模式在正確的上下文中確實具有一些明顯的優勢:
- 單例強制執行:它保證只有一個實例被創建。這避免了在管理共享資源時產生衝突,因為所有代碼都使用同一個實例。例如,如果你的應用程序使用單一的配置管理器或單一的緩存,單例模式確保所有模塊都引用那個對象(防止配置不匹配或緩存重複)。
- 全球訪問便利性:單例實例可以在全球範圍內訪問,這簡化了從程序任何位置與其的互動。您不需要不斷傳遞實例;代碼的任何部分都可以調用,例如
Singleton.getInstance()來檢索它。這可以使某些設計更為簡化(類似於應用程序如何全局訪問應用程序範圍的記錄器或數據庫連接器)。 - 懶初始化:單例模式可以實現為僅在首次需要時初始化實例(懶加載)。這樣可以提高啟動性能和資源使用效率。例如,如果單例對象創建成本較高(比如加載大型配置或連接數據庫),你可以將這部分成本延遲到實際需要時才支付。許多語言允許在首次使用時創建單例,而有些語言(如 Dart 和 Java)甚至會自然地懶初始化靜態字段,因此你不必在不需要時支付這部分成本。
- 受控存取/安全性:通過將存取集中於單一實例,您可以集中進行某些檢查或鎖定。在多執行緒情境中,正確實作的單例模式可以確保對共享資源的執行緒安全存取。相較於管理多個物件,您只需處理一個,因此協調執行緒存取(透過同步或鎖定)會更為簡便。(然而,實際是否達到執行緒安全取決於實作方式;該模式本身並不保證執行緒安全,除非設計時即考慮此點。)
- 避免全域命名空間污染:與真正的全域變數不同,單例模式通常位於其類別命名空間中,不會被其他變數意外覆寫。這意味著您可以維護一個更乾淨的全域命名空間。該模式將全域實例封裝在一個類別中,這比讓許多不相關的全域物件四處飄散更有組織性。
請記住,這些優勢在確實需要單例模式時才適用。它們可以使代碼更簡單、更高效當一個實例真正適合當前問題時。
缺點及如何減輕其影響
單例模式經常受到批評,尤其是在被過度使用時。以下是單例模式的關鍵缺點,以及可能的緩解措施的說明:
隱藏依賴(全域狀態)
單例模式將全局狀態引入應用程序中。由於任何代碼都可以從任何地方訪問單例實例,它實際上就像一個全局變量。這可能使得追踪程序中哪些部分使用或修改其狀態變得困難,從而可能導致不可預測的行為或錯誤。
緩解
為了避免意外,清楚地記錄單例模式的功能並考慮限制直接訪問。一些開發者只在介面背後使用單例模式;代碼的部分依賴於介面,而單例模式實現了它。這樣你可以在測試或需要時替換它。此外,盡量保持單例模式的狀態最小化,以減少全局狀態的複雜性。
緊密耦合
使用單例模式的組件會與該具體實例緊密耦合。由於單例是全局可訪問的,代碼的許多部分可能會直接調用Singleton.instance。這使得以後更改或替換該類變得困難,因為許多部分都依賴於它。這也違反了依賴注入的原則,因為類隱式地依賴於一個全局實例。
緩解
一個解決方案是依賴於抽象。例如,讓單例類實現一個接口,並讓消費者依賴於該接口而不是具體的單例。然後,您可以通過該接口提供單例實例,甚至可以根據測試或未來需求替換為不同的實現。通過針對接口或基類進行編碼,即使在運行時只有一個實現,您也能鬆散耦合。
困難的單元測試
單例模式可能會使單元測試變得更加困難。由於單例是全局的,測試可能會無意中使用真實的單例,而你可能更希望使用模擬或偽造的對象。此外,如果一個測試改變了單例的狀態,它可能會影響之後運行的其他測試(因為實例會持續存在)。
緩解
為了進行隔離測試,您可能允許單例被重置或在測試模式下注入不同的實例。另一種方法是避免在業務邏輯中直接調用單例;而是注入其接口(如上所述),以便您可以在測試中提供一個虛擬實現。一些依賴注入框架也可以在測試中覆蓋單例。至少,如果合適的話,確保您的單例類有一個方法來重置或替換其實例(以便在測試之間進行清理)。
生命週期與資源管理
單例通常存在於應用程式的整個生命週期。這意味著在許多情況下,它永遠不會被垃圾回收,直到應用程式關閉。如果它持有大量資源(文件、網絡連接、大塊內存),這些資源在進程結束之前不會被釋放,這可能會帶來問題。此外,如果你需要重新初始化或重新加載單例(例如,重新加載配置),要乾淨地實現可能會很複雜。
緩解
設計單例以在需要時釋放外部資源(例如,單例資料庫連接可以提供一個close(),應用程式在關閉時呼叫)。在某些情況下,考慮是否真的需要單例,或者是否可以使用範圍生命週期。像網頁伺服器或 Flutter 應用程式這樣的環境可能會重新啟動或熱重載模組;在這些情況下,要小心單例是否被適當地重新建立。
有限的擴展性
由於單例模式的實現方式(使用靜態實例),它們不容易擴展或繼承。通常,您無法以整個系統可以透明地使用子類別的方式來對單例進行子類別化——代碼被硬編碼為使用確切的單例類別。此外,在正常情況下,您無法在不修改單例類別本身的情況下為特殊情況創建子類別的第二個實例。這違反了開閉原則(類別在這方面不開放擴展)。
緩解
如果您預見到需要變體或子類,Singleton 可能不是合適的模式。一個可能的解決方案是使用註冊表或multiton(一個受控的實例映射)來允許通過鍵識別的幾個實例,而不是一個固定的實例(更多關於 multiton 的內容見下文)。或者,使用可以返回不同實現的工廠方法(例如,Singleton 的靜態訪問器可以根據配置返回子類的實例,儘管這增加了複雜性)。
違反單一職責原則(SRP)
一個經典的批評是,單例類別有兩個責任:一個是它們的主要邏輯,另一個是管理它們的唯一實例。換句話說,一個類別在完成其主要工作並確保只有一個實例存在時,承擔了兩個角色。這被認為是一種設計上的異味,因為一個類別理想情況下應該只有一個變更的理由。
緩解
解決這個問題最直接的方法是分離關注點——讓物件的創建和生命週期由外部管理。例如,使用工廠或建構器物件來管理單一實例。該工廠將處理「只有一個實例」的邏輯,而類別本身則專注於其主要工作。我們在下面的 SRP 部分會進一步討論這一點。如果使用依賴注入框架,你可以配置它將類別視為單例(單一實例),而無需類別本身實現該模式——再次分離職責。
總之,單例模式應謹慎使用。通過精心設計(如使用介面、依賴注入或外部化實例控制),可以緩解許多這些缺點。然而,如果過度使用或不當使用,單例模式確實可能導致難以維護且緊密耦合的代碼。在選擇單例解決方案之前,務必權衡這些利弊。
使用單例模式的限制
使用單例模式時,有一些重要的限制和考慮需要牢記在心:
- 每個應用程式(或進程)一個實例: 單例模式保證了在單一運行時內只有一個實例。如果你有多個進程或一個分散式系統,每個進程可能會有自己的單例。例如,在多進程應用程式(或在像 Flutter 這樣的環境中,每個隔離區都有自己的記憶體)中,你不會獲得跨進程共享的單一實例——每個進程只有一個。因此,「單一實例」僅限於一個程式或容器的範圍內。
- 執行緒安全:在多執行緒環境中,您必須確保單例模式的初始化是執行緒安全的。如果兩個執行緒同時嘗試創建實例,可能會意外地創建兩個實例。許多語言預設提供了使靜態初始化執行緒安全的方法(例如,Java 的類加載器,或 Dart 的懶初始化)。如果沒有,您需要實現鎖定或使用雙重檢查鎖定等技術。如果不這樣做,可能會破壞單例保證,並引入錯誤或性能問題(例如,如果所有執行緒在沒有適當設計的情況下都通過一個對象,可能會導致競爭)。在並發情況下,實現不佳的單例模式可能會成為瓶頸。
- 生命週期與應用程式生命週期綁定:如前所述,單例通常從創建開始存在直到程式終止。這意味著你通常無法在程式執行過程中銷毀並重新創建單例(至少在不添加特殊方法的情況下無法做到)。如果你的應用程式需要“重置”狀態(例如,軟重啟),單例可能會在你不需要的時候保留狀態。這種長生命週期也意味著,如果沒有妥善管理,它所持有的任何記憶體或資源實際上都是全域性的洩漏。在受限環境中,考慮提供一種明確釋放資源的方式,或者避免對需要卸載的重型物件使用單例。
- 記憶體與效能考量:單例模式可能會引入輕微的記憶體開銷(如果急切創建,即使在不主動使用時也存在)。然而,與創建許多相同物件的實例相比,它可能會節省記憶體。關鍵在於,如果單例持有大量數據,這些記憶體將永遠不會被釋放。如果創建成本高昂,單例通過不重複創建來節省時間。但如果它很少使用,保持其存活可能是浪費的。權衡始終保留它的成本與按需構建的成本。
- 實例數量的不靈活性:一旦你將某物實作為單例,如果後續需求變更,允許更多實例,這可能是一個非平凡的重新構建。程式碼庫可能充斥著假設單一實例的呼叫。這是設計中的一個約束;你將自己鎖定在一個實例上。確保這是概念上邏輯且固有的限制(例如「這個應該永遠只有一個」),而不僅僅是當時的便利。如果有任何疑問,考慮採取更靈活的方法(如使用實例註冊表或明確傳遞物件)。
- 不是單一職責原則的替代品:僅為了避免傳遞物件而使用單例模式可能是一個警示信號。如果你發現你將某個東西設為單例主要是因為「代碼的許多部分都需要它」,而不是因為它邏輯上必須是單一的,那麼你可能只是為了方便而使用這種模式,並創建了不必要的全局狀態。這種方便日後可能會變成維護的頭痛問題。始終確保單例模式的使用是由問題本身(例如單一配置、單一緩存)所合理化的,而不是僅僅為了在物件傳遞上偷懶。
本質上,Singleton 的約束在於你獲得了全域、長效、單例存取——你應該基於這一點來進行設計。考慮這如何適應你應用程式的生命週期,以及任何未來的場景是否可能需要更多的靈活性。
避免違反單一職責原則
Singleton 的一個顯著問題是它可能違反單一職責原則(SRP)。該類不僅完成其主要工作,還控制自己的實例化和生命週期(強制執行“僅一個實例”的規則)。理想情況下,一個類“不應該關心它是否是單例;它應該只關注其業務職責”。
如何避免這種違規? 關鍵在於將實例管理的關注點與類別的核心邏輯分離。以下是一些方法:
- 工廠或建造者: 與其讓類別在其程式碼中強制執行單例模式,不如使用一個獨立的工廠物件來創建或檢索單一實例。工廠會檢查實例是否存在,並根據情況返回現有實例或創建新實例。主類別可以擁有一個僅由工廠呼叫的內部構造函數。這樣一來,類別本身只負責一個職責(其業務邏輯),而工廠則負責限制實例化的過程。例如,一個
DatabaseConnection類別可能是普通的(並不知道自己是單例),而一個DatabaseConnectionFactory則確保只會創建並分發一個連接。 - 依賴注入容器:在現代應用程式中,依賴注入(DI)框架或服務定位器可以管理單例。您宣告某個服務或類別在應用程式中應具有單例範圍。然後,DI 容器確保只創建一個實例,並在需要的地方注入該實例。類別本身對此並不知情;它只是由容器正常實例化。這種方法清晰地分離了職責:容器管理物件的生命週期,而類別只需完成其工作。許多框架(如 Angular、Spring 等)允許將類別配置為單例,而無需類別自行實現該模式。
- 靜態單例管理器:在較簡單的情況下,您可以使用一個獨立的靜態輔助類來持有單例實例。例如,與其將獲取實例的邏輯寫在類內部,不如擁有一個獨立的管理器,甚至只是在另一個類中的一個靜態字段。然而,這基本上就是依賴注入容器更清晰形式化的內容。
通過採用這些方法,你遵循了單一職責原則(SRP),因為類別只有一個變更的理由:其領域邏輯的變更,而不是其單一實例管理方式的變更。正如一篇微軟文章所述,「如果你想限制某個類別的實例化能力,建立一個工廠…現在,創建的職責與業務實體的職責被分離開來。」。這讓你能在需要時獲得單一實例的好處,而不會讓類別承擔額外的責任。
總之,您仍然可以通過外部化單例的強制執行來實現類似單例的行為,同時保持單一職責原則。這使您的代碼更清晰,通常也更易於測試,因為您可以根據需要替換該創建邏輯(例如,在測試中替換不同的實例或稍後更改實例化策略)。
範例:Dart 中的單例模式
讓我們來示範如何在 Dart 中實現並使用單例模式。與其使用日誌記錄器,不如考慮一個資料庫連接管理器,我們希望在整個應用程式中只使用一個資料庫連接實例。
class DatabaseConnection {
// Private named constructor
DatabaseConnection._internal();
// The single instance, stored in a static field
static final DatabaseConnection _instance = DatabaseConnection._internal();
// Factory constructor that returns the static instance
factory DatabaseConnection() {
return _instance;
}
// Example method to simulate a database query
void query(String sql) {
print('Executing query: \\$sql');
}
}
解釋:
- 私有建構函式:
DatabaseConnection._internal()確保外部程式碼無法直接實例化這個類別。 - 靜態實例:
_instance是一個靜態欄位,用於保存該類的單一實例,確保只存在一個對象。 - 工廠構造函數:每次調用
factory DatabaseConnection()構造函數時,它都會返回單一實例,從而保持單例模式。
用法:
void main() {
var db1 = DatabaseConnection();
var db2 = DatabaseConnection();
db1.query("SELECT * FROM users");
print(db1 == db2); // true, both references point to the same instance
}
如果你執行這段程式,比較db1 == db2將會返回true,表示這兩個變數都引用了同一個單例實例。
這種模式確保應用程式的所有部分都使用相同的數據庫連接對象,防止了不必要的多重連接創建,並確保了資源管理的效率。然而,在實際應用中,應考慮額外的保護措施,如連接池。
通過使用這個單例模式,代碼的任何部分都可以調用 DatabaseConnection().query("SQL command") ,而無需擔心傳遞數據庫實例的問題。然而,在處理多線程環境或 Dart 中的隔離時應小心,因為每個隔離都有自己的內存空間。
結論
單例模式提供了一種確保類別只有一個實例並為該實例提供易於全局訪問的方法。它對於某些場景非常有用,例如資源或配置的集中管理。我們探討了它的定義、類比、它解決的問題以及其優缺點。雖然單例可以簡化對共享資源的訪問(並通過避免重複對象來減少內存佔用),但它也帶來了隱藏依賴、緊密耦合以及測試和擴展困難等缺點。現代最佳實踐敦促對單例保持謹慎:優先考慮清晰的依賴管理,並僅在真正需要一個實例時才使用單例。
如果使用單例模式,應該以乾淨、負責的方式來實現——例如,將單例的核心邏輯與實例化機制分開,以尊重像 SRP 這樣的設計原則。我們還討論了與單例相關的模式(單態、多例等)以及它們的比較。
總之,Singleton 是一種強大的模式,但應謹慎應用。將其保留在邏輯上需要唯一物件的場合,並注意其影響。如有疑問,考慮使用更具明確架構的替代方案來達成類似目標。但當你遇到那些罕見的單例問題時,Singleton 模式仍然是軟體設計工具箱中的一個方便工具。
你對單例模式有什麼看法?在專案中使用它時是否遇到過挑戰?在下方評論區分享你的想法和經驗吧!如果你覺得這篇文章有幫助,別忘了留下一些掌聲,與朋友分享,並關注以獲取更多關於軟體設計和架構的見解。你的支持是我們持續創作內容的動力!