設計模式之單例模式(一)-CSDN博客
目錄
1.背景
2.分析
2.1.違背面向對象設計原則,導致職責混亂
2.2.全局狀態泛濫,引發依賴與耦合災難
2.3.多線程場景下風險放大,性能與穩定性受損
2.4.測試與維護難度指數級上升
2.5.違背 “最小知識原則”,代碼可重用性極低
3.總結
1.背景
最新在做一個老項目的維護,里面對類的調用全部都是用的單例模式,我抽取了其中某一個類CTargetShowWidget,它的單實例模型關鍵部分代碼如下:
//TargetShowWidget.h
class CTargetShowWidget : public QWidget
{
private:CTargetShowWidget(QWidget *parent = nullptr);public:~CTargetShowWidget();static CTargetShowWidget* getInstance();private:static CTargetShowWidget* m_pTargetShowWidget;
};//TargetShowWidget.cpp
CTargetShowWidget* CTargetShowWidget::m_pTargetShowWidget = NULL;CTargetShowWidget* CTargetShowWidget::getInstance()
{if (!m_pTargetShowWidget){m_pTargetShowWidget = new CTargetShowWidget(NULL);}return m_pTargetShowWidget;
}
初看好像沒有什么問題,邏輯都是對的;但是細細品味,里面有幾個關鍵的問題我們來一一說明。
2.分析
在程序中所有類都使用單實例模式(單例模式)會帶來一系列設計和工程上的問題,違背面向對象設計的核心原則,嚴重影響代碼的可維護性、擴展性和健壯性。
2.1.違背面向對象設計原則,導致職責混亂
1.單一職責原則被破壞
????????單例模式的核心是 “管理實例生命周期”+“實現業務邏輯”,二者強耦合在一個類中。當所有類都采用單例時,每個類不僅要處理自身業務,還要承擔 “全局唯一實例” 的管理邏輯(如線程安全、實例銷毀等),導致類的職責膨脹,代碼復雜度飆升。
- 例:一個負責 “日志記錄” 的單例類,本應專注于日志寫入,卻需要額外處理實例創建的線程同步邏輯,代碼可讀性和維護性下降。
2.開閉原則(擴展性)受損
????????單例模式通常通過 “私有化構造函數” 限制實例創建,這使得類難以被繼承(子類無法調用父類私有構造函數),也無法在不修改原代碼的前提下擴展功能(如創建單例的子類實例)。當所有類都被單例 “鎖定” 時,后續需求變更(如需要多實例、子類擴展)會被迫修改底層代碼,違反 “對擴展開放,對修改關閉” 的原則。
2.2.全局狀態泛濫,引發依賴與耦合災難
1.強耦合形成 “全局依賴網”
????????單例本質是 “全局變量的封裝”,所有類的實例都是全局可訪問的(通過靜態接口獲取),這會導致程序中充滿隱性依賴—— 類 A 直接調用類 B 的單例實例,而類 B 可能又依賴類 C 的單例,形成復雜的 “全局依賴網”。
- 問題:依賴關系難以梳理,修改某個單例的接口或生命周期(如延遲初始化改為餓漢式),可能引發整個系統的連鎖反應,調試時難以定位依賴源頭。
2.內存與資源浪費,生命周期不可控
????????單例的實例通常在程序啟動后長期存在(除非程序退出),即使某些類的功能在特定場景下才會使用。當所有類都是單例時,即使程序只用到 10% 的功能,也需要提前創建或保持 100% 的單例實例,導致內存占用過高,尤其對資源敏感的場景(如嵌入式、移動端)影響顯著。
- 例:一個僅在用戶點擊 “設置” 時才用到的 “配置管理單例”,卻在程序啟動時就被創建,閑置內存直到程序結束。
3.全局狀態破壞 “數據封裝”
????????面向對象的核心是 “封裝數據,暴露接口”,但單例的全局實例允許任何模塊直接調用其方法、修改其狀態,導致數據一致性難以保證(如多個模塊同時修改單例的成員變量,引發競態條件)。這種 “無邊界的訪問” 讓程序變成 “不可控的全局狀態機”,調試時難以追蹤狀態變化的源頭。
4.內存泄露
? ? ? ? ?如上面的代碼肯定會出現內存泄露的。尤其是在動態庫(如 Linux 下的 .so、Windows 下的 .dll)中寫這樣的類,因為動態庫可以動態加載。當用dlopen加載動態庫,然后調用CTargetShowWidget::getInstance()獲取指針,用 dlclose卸載動態庫時,CTargetShowWidget::getInstance()中new的內存是不會自動釋放的,如果程序中不停的加載和卸載此動態庫,加載過程中一直不停的分配內存,而卸載時候沒有釋放,這些內存會一直被進程占用,導致泄漏。
2.3.多線程場景下風險放大,性能與穩定性受損
1.線程安全成本激增
????????為保證單例在多線程環境下的唯一性,通常需要加鎖(如 C++ 的std::mutex
、Java 的synchronized
)或使用原子操作。當所有類都是單例時,每個單例都可能成為線程競爭的 “鎖熱點”—— 尤其是頻繁被調用的單例,加鎖 / 解鎖操作會帶來顯著的性能損耗,甚至引發死鎖(若多個單例的鎖順序不一致)。
- 對比:非單例的普通類可通過 “局部變量” 或 “依賴注入” 在線程內獨立使用,避免全局鎖競爭。
2.銷毀順序與資源釋放問題
????????單例的生命周期與程序一致,但其成員變量(如動態分配的內存、文件句柄、網絡連接等)需要在程序退出時正確釋放。當存在多個相互依賴的單例時,它們的銷毀順序無法保證(如單例 A 依賴單例 B 的數據,但若 B 先被銷毀,A 銷毀時可能訪問到無效數據),導致崩潰或資源泄漏。
這種問題在 C++ 等沒有自動垃圾回收的語言中尤為突出,即使在 Java 中,靜態單例的銷毀順序也難以控制。
2.4.測試與維護難度指數級上升
1.單元測試無法隔離,結果不可靠
單例的全局狀態會導致測試用例之間互相污染 —— 例如:
- 測試用例 1 修改了單例 A 的狀態,測試用例 2 執行時依賴 A 的 “初始狀態”,卻拿到了被修改后的狀態,導致測試失敗。
- 無法模擬 “不同場景下的實例狀態”,因為單例只有一個實例,難以注入測試數據(如模擬異常狀態的單例)。
解決方式通常需要 “重置單例狀態” 的額外接口,但這會進一步破壞封裝性,且增加代碼復雜度。
2.依賴注入失效,代碼靈活性喪失
????????現代開發中,依賴注入(DI)是解耦的核心手段(如通過構造函數注入依賴的實例),但單例模式通過 “靜態方法” 自我創建,無法被外部依賴替換(如單元測試時用 mock 對象替代真實單例)。當所有類都是單例時,程序完全喪失 “依賴替換” 的能力,只能硬編碼依賴關系,難以適應需求變化(如切換底層實現、對接不同接口)。
2.5.違背 “最小知識原則”,代碼可重用性極低
????????單例模式的 “全局訪問” 特性,使得類與 “全局上下文” 強綁定 —— 一個單例類無法在另一個程序或模塊中復用,除非接受其 “全局唯一” 的約束。而面向對象設計的目標之一是 “高內聚、低耦合”,讓類可以像 “積木” 一樣被復用,單例的全局化設計徹底破壞了這一點。
- 例:一個用于 “網絡請求” 的單例類,若被設計為依賴全局的 “配置單例”,則無法在不包含該配置單例的項目中獨立使用。
3.總結
????????單例模式本身是一種 “慎用的設計模式”,僅適用于明確需要全局唯一實例、且生命周期與程序一致的場景(如日志管理器、配置管理器)。當所有類都使用單例時,會將單例的缺點(全局狀態、耦合、測試困難等)放大到極致,導致程序退化為 “面向過程的全局變量堆砌”,違背面向對象設計的核心思想。
? ? ? ? 優化建議:
1.優先使用 “普通類 + 依賴注入”:通過函數參數、上下文對象(如容器)傳遞實例,讓依賴關系顯性化,而非依賴全局訪問;
2.僅在必要時使用單例:嚴格限制單例的適用場景(如真正需要 “全局唯一且生命周期與程序一致” 的場景,如日志器、全局配置),且每個系統中單例數量應控制在個位數;
3.用 “作用域唯一性” 替代 “全局唯一性”:若僅需在某個作用域(如線程內、函數內)保證唯一,可通過局部靜態變量、線程本地存儲(TLS)等更輕量的方式實現,避免全局化。
????????總之,設計模式是解決特定問題的工具,而非通用方案 —— 濫用單例模式的本質,是用 “便捷性” 犧牲 “可維護性”,最終會讓程序付出更高的技術債務。