之前在這篇文章中簡單的介紹了一下單例模式的作用和應用C++中單例模式詳解_c++單例模式的作用-CSDN博客,今天我將在在本文梳理單例模式從C++98到C++11及以后的演變過程,探討其不同實現方式的優劣,并介紹在現代C++中的最佳實踐。
什么是單例模式?
簡單來說,單例模式(Singleton Pattern)是一種設計模式,它能保證一個類在整個程序運行期間,只有一個實例存在 。
這種唯一性的保證在特定場景下至關重要。例如,對于一個數據庫連接管理器 Manager,如果系統中存在多個實例,不同模塊可能會通過不同實例進行操作,從而引發數據狀態不一致或資源競爭的問題 。通過將 Manager 設計為單例,所有模塊都通過唯一的訪問點來與數據庫交互,這不僅能保證數據和狀態的統一,還能有效規避資源浪費 。
總結而言,單例模式主要具備兩大價值:
- ? ? ? ? · 控制實例數量:節約系統資源,避免因多重實例化導致的狀態沖突 。
- ? ? ? ? · 提供全局訪問點:為不同模塊提供一個統一的、可協調的訪問接口 。
因此,該模式廣泛應用于配置管理、日志系統、設備驅動、數據庫連接池等需要全局唯一實例的場景中 。
單例模式的幾種寫法
方式一:局部靜態變量(最簡潔的現代寫法)
//通過靜態成員變量實現單例
//懶漢式
class Single2
{
private:Single2(){}Single2(const Single2 &) = delete;Single2 &operator=(const Single2 &) = delete;public:static Single2 &GetInst(){static Single2 single;return single;}
};
它的核心原理就是利用了函數局部靜態變量的特性:它只會被初始化一次 。無論你調用 GetInst() 多少次,single 這個靜態實例只會在第一次調用時被創建。
調用代碼:
void test_single2(){//多線程情況下可能存在問題cout << "s1 addr is " << &Single2::GetInst() << endl;cout << "s2 addr is " << &Single2::GetInst() << endl;}
程序輸出:
s1 addr is 0x7f8a1b402a10
s2 addr is 0x7f8a1b402a10
可以看到,兩次獲取到的實例地址是完全一樣的。
需要注意的是,在 C++98 的年代,這種寫法在多線程環境下是不安全的,可能會因為并發導致創建出多個實例 。但是隨著 C++11 標準的到來,編譯器對這里做了優化,保證了局部靜態變量的初始化是線程安全的 。所以,在 C++11 及之后的版本,這已成為實現單例最受推崇的方式之一,兼具簡潔與安全。
方式二:靜態成員變量指針(餓漢式)
這種方式定義一個靜態的類指針,并在程序啟動時就立刻進行初始化,因此被稱為“餓漢式”。
由于實例在主線程啟動、其他業務線程開始前就已完成初始化,它自然地避免了多線程環境下的競爭問題。
//餓漢式
class Single2Hungry{
private:Single2Hungry(){}Single2Hungry(const Single2Hungry &) = delete;Single2Hungry &operator=(const Single2Hungry &) = delete;public:static Single2Hungry *GetInst(){if (single == nullptr){single = new Single2Hungry();}return single;}private:static Single2Hungry *single;
};
初始化和調用:
//餓漢式初始化,在.cpp文件中
Single2Hungry *Single2Hungry::single = Single2Hungry::GetInst();void thread_func_s2(int i){cout << "this is thread " << i << endl;cout << "inst is " << Single2Hungry::GetInst() << endl;
}void test_single2hungry(){cout << "s1 addr is " << Single2Hungry::GetInst() << endl;cout << "s2 addr is " << Single2Hungry::GetInst() << endl;for (int i = 0; i < 3; i++){thread tid(thread_func_s2, i);tid.join();}
}int main(){test_single2hungry();
}
程序輸出:
s1 addr is 0x7fb3d6c00f00
s2 addr is 0x7fb3d6c00f00
this is thread 0
inst is 0x7fb3d6c00f00
this is thread 1
inst is 0x7fb3d6c00f00
this is thread 2
inst is 0x7fb3d6c00f00
餓漢式的優點是實現簡單且線程安全。但其缺點也很明顯:無論后續是否使用,實例在程序啟動時都會被創建,可能造成不必要的資源開銷。此外,通過裸指針 new 創建的實例,其內存釋放時機難以管理,在復雜的多線程程序中極易引發內存泄漏或重復釋放的嚴重問題。
方式三:靜態成員變量指針(懶漢式與雙重檢查鎖定)
與“餓漢”相對的就是“懶漢”,即只在第一次需要用的時候才去創建實例 。這能節省資源,但直接寫在多線程下是有問題的。為解決其在多線程下的安全問題,一種名為雙重檢查鎖定(Double-Checked Locking)的優化技巧應運而生。
//懶漢式指針,帶雙重檢查鎖定
class SinglePointer{
private:SinglePointer(){}SinglePointer(const SinglePointer &) = delete;SinglePointer &operator=(const SinglePointer &) = delete;public:static SinglePointer *GetInst(){// 第一次檢查if (single != nullptr){return single;}s_mutex.lock();// 第二次檢查if (single != nullptr){s_mutex.unlock();return single;}single = new SinglePointer();s_mutex.unlock();return single;}private:static SinglePointer *single;static mutex s_mutex;
};//在.cpp文件中定義
SinglePointer *SinglePointer::single = nullptr;
std::mutex SinglePointer::s_mutex;
調用代碼:
void thread_func_lazy(int i){cout << "this is lazy thread " << i << endl;cout << "inst is " << SinglePointer::GetInst() << endl;
}void test_singlelazy(){for (int i = 0; i < 3; i++){thread tid(thread_func_lazy, i);tid.join();}
}
程序輸出:
this is lazy thread 0
inst is 0x7f9e8a00bc00
this is lazy thread 1
inst is 0x7f9e8a00bc00
this is lazy thread 2
inst is 0x7f9e8a00bc00
該模式試圖通過減少鎖的持有時間來提升性能。然而,這種實現在C++中是存在嚴重缺陷的。new 操作并非原子性,它大致包含三個步驟:
- ????????1. 分配內存;
- ????????2. 調用構造函數;
- ????????3. 賦值給指針 。
- ????????2. 調用構造函數;
編譯器和處理器出于優化目的,可能對指令進行重排,導致第3步先于第2步完成 。若此時另一線程訪問,它會獲取一個非空但指向未完全構造對象的指針,進而引發未定義行為 。
?C++11的現代解決方案:once_flag 與智能指針
為了安全地實現懶漢式加載,C++11 提供了 std::once_flag 和 std::call_once。call_once 能確保一個函數(或 lambda 表達式)在多線程環境下只被成功調用一次 。
// Singleton.h
#include <mutex>
#include <iostream>class SingletonOnceFlag{
public:static SingletonOnceFlag* getInstance(){static std::once_flag flag;std::call_once(flag, []{_instance = new SingletonOnceFlag();});return _instance;}void PrintAddress() {std::cout << _instance << std::endl;}~SingletonOnceFlag() {std::cout << "this is singleton destruct" << std::endl;}private:SingletonOnceFlag() = default;SingletonOnceFlag(const SingletonOnceFlag&) = delete;SingletonOnceFlag& operator=(const SingletonOnceFlag& st) = delete;static SingletonOnceFlag* _instance;
};// Singleton.cpp#include "Singleton.h"SingletonOnceFlag *SingletonOnceFlag::_instance = nullptr;
這樣就完美解決了線程安全問題,但內存管理的問題依然存在。此時,std::shared_ptr 智能指針成為了理想的解決方案,它能實現所有權的共享和內存的自動回收。
智能指針版本:
// Singleton.h (智能指針版)#include <memory>class SingletonOnceFlag{
public:static std::shared_ptr<SingletonOnceFlag> getInstance(){static std::once_flag flag;std::call_once(flag, []{// 注意這里不能用 make_shared,因為構造函數是私有的_instance = std::shared_ptr<SingletonOnceFlag>(new SingletonOnceFlag());});return _instance;}//... 其他部分相同private://...static std::shared_ptr<SingletonOnceFlag> _instance;
};// Singleton.cpp (智能指針版)#include "Singleton.h"std::shared_ptr<SingletonOnceFlag> SingletonOnceFlag::_instance = nullptr;
測試代碼:
#include "Singleton.h"
#include <thread>
#include <mutex>int main() {std::mutex mtx;std::thread t1([&](){auto inst = SingletonOnceFlag::getInstance();std::lock_guard<std::mutex> lock(mtx);inst->PrintAddress();});std::thread t2([&](){auto inst = SingletonOnceFlag::getInstance();std::lock_guard<std::mutex> lock(mtx);inst->PrintAddress();});t1.join();t2.join();return 0;
}
程序輸出 (析構函數被正確調用):
0x7fde7b408c20
0x7fde7b408c20
this is singleton destruct
進階玩法:私有析構與自定義刪除器
有些大佬追求極致的封裝,他們會把析構函數也設為private,防止外部不小心 delete 掉單例實例 。但這樣 shared_ptr 默認的刪除器就無法調用析構了。解決辦法:我們可以給 shared_ptr 指定一個自定義的刪除器(Deleter),通常是一個函數對象(仿函數)。這個刪除器類被聲明為單例類的友元(friend),這樣它就有了調用私有析構函數的權限。
// Singleton.h
class SingleAutoSafe; // 前置聲明// 輔助刪除器
class SafeDeletor{
public:void operator()(SingleAutoSafe *sf){std::cout << "this is safe deleter operator()" << std::endl;delete sf;}
};class SingleAutoSafe{
public:static std::shared_ptr<SingleAutoSafe> getInstance(){static std::once_flag flag;std::call_once(flag, []{_instance = std::shared_ptr<SingleAutoSafe>(new SingleAutoSafe(), SafeDeletor());});return _instance;}// 聲明友元類,讓 SafeDeletor 可以訪問私有成員friend class SafeDeletor;
private:SingleAutoSafe() = default;// 析構函數現在是私有的了~SingleAutoSafe() {std::cout << "this is singleton destruct" << std::endl;}// ...static std::shared_ptr<SingleAutoSafe> _instance;};
程序輸出:
0x7f8c0a509d30
0x7f8c0a509d30
this is safe deleter operator()
可以看到,程序結束時,shared_ptr 調用了我們的 SafeDeletor,從而安全地銷毀了實例。這種方式提供了最強的封裝性。
終極方案:基于CRTP的通用單例模板
在大型項目中,為每個需要單例的類重復編寫樣板代碼是低效的。更優雅的方案是定義一個通用的單例模板基類。任何類只需繼承該基類,便能自動獲得單例特性。這通常通過奇異遞歸模板模式實現,即派生類將自身作為模板參數傳遞給基類。
單例基類實現:
// Singleton.h
#include <memory>
#include <mutex>template <typename T>
class Singleton {
protected:Singleton() = default;Singleton(const Singleton<T>&) = delete;Singleton& operator=(const Singleton<T>& st) = delete;virtual ~Singleton() {std::cout << "this is singleton destruct" << std::endl;}static std::shared_ptr<T> _instance;public:static std::shared_ptr<T> GetInstance() {static std::once_flag s_flag;std::call_once(s_flag, []() {// new T 這里能成功,因為子類將基類設為了友元_instance = std::shared_ptr<T>(new T);});return _instance;}void PrintAddress() {std::cout << _instance.get() << std::endl;}
};template <typename T>
std::shared_ptr<T> Singleton<T>::_instance = nullptr;
使用這個模板基類:
現在,如果我們想讓一個網絡管理類 SingleNet 成為單例,只需要這樣做:
// SingleNet.h
#include "Singleton.h"// CRTP: SingleNet 繼承了以自己為模板參數的 Singleton
class SingleNet : public Singleton<SingleNet>{// 將基類模板實例化后設為友元,這樣基類的 GetInstance 才能 new 出 SingleNetfriend class Singleton<SingleNet>;private:SingleNet() = default;~SingleNet() {std::cout << "SingleNet destruct " << std::endl;}
};
測試代碼:
// main.cpp
int main() {std::thread t1([&](){SingleNet::GetInstance()->PrintAddress();});std::thread t2([&](){SingleNet::GetInstance()->PrintAddress();});t1.join();t2.join();return 0;}
程序輸出:
0x7f9a2d409f40
0x7f9a2d409f40
SingleNet destruct
this is singleton destruct
我們幾乎沒寫任何單例相關的邏輯,只通過一次繼承和一句友元聲明,就讓 SingleNet 變成了一個線程安全的、自動回收內存的單例類。這就是泛型編程的強大之處。
總結
本文介紹了單例模式從傳統到現代的多種實現方式。可總結為:
- 日常開發:對于C++11及以上版本,局部靜態變量法是實現單例的首選,它兼具代碼簡潔性與線程安全性。
- 深入理解:了解餓漢式、懶漢式及雙重檢查鎖定的歷史與缺陷,對于理解并發編程中的陷阱至關重要。
- 企業級實踐:在大型項目中,基于智能指針和 CRTP 的通用單例模板是最佳實踐,它能提供類型安全、自動內存管理和最高的代碼復用性。