模板形式的單例類)
前言單例模式要解決的問題是某個類型在整個進程中只允許存在一個實例并提供一個全局訪問點。把這件事寫成模板是為了讓Config、Logger、ConnectionPool這類類型復用同一套“唯一實例 線程安全初始化 禁止拷貝”的邏輯而不是每個類各抄一遍。圍繞單例有不少誤解。有人說“單例必須用雙重檢查鎖定double-checked locking”但在 C11 之后函數(shù)內(nèi)靜態(tài)變量的初始化本身由標準保證線程安全絕大多數(shù)場景不需要手寫雙重檢查而手寫版本若忘了把指針聲明為std::atomic反而制造數(shù)據(jù)競爭屬于未定義行為undefined behavior, UB。有人以為“用了std::unique_ptr就自動線程安全”智能指針只負責所有權與釋放不負責首次創(chuàng)建的同步。還有人把std::make_uniqueT()直接寫進單例模板結果因為構造函數(shù)是私有的而編譯失敗——std::make_unique不是這個類的友元。本文講三件事單例在 C11 之后的線程安全初始化依據(jù)如何用std::unique_ptr/std::shared_ptr寫出模板形式以及析構順序、私有構造訪問這些容易翻車的地方。代碼以 C17 為基準涉及 C17 的inline static會注明本機沒有編譯環(huán)境未做編譯驗證。一、單例的三條硬要求第一構造入口必須受控。構造函數(shù)應為私有或受保護拷貝構造與拷貝賦值應刪除避免出現(xiàn)第二個實例或意外復制。第二首次訪問必須線程安全。多個線程同時第一次調用instance()時只能構造一次且其他線程要能看到完整構造后的對象。第三生命周期要明確。單例什么時候銷毀、銷毀時能否安全訪問其他全局對象必須有約定。很多“單例崩潰”不是創(chuàng)建的問題而是程序退出時靜態(tài)對象析構順序的問題。C11 標準規(guī)定函數(shù)內(nèi)的靜態(tài)局部變量在控制流第一次經(jīng)過其聲明時初始化如果初始化正在被另一個線程執(zhí)行其他線程會阻塞等待初始化完成后才繼續(xù)。這條保證通常被稱為“magic statics”。它讓下面這種寫法成為最簡單的線程安全懶漢式// 適用于 C17static T instance() {static T obj;return obj;}前提是編譯器實現(xiàn)了 C11 的這條規(guī)則。極老的工具鏈例如早期的 MSVC可能提供開關或行為不一致使用前應以工具鏈文檔為準。對現(xiàn)代 GCC、Clang、MSVC 而言這是標準要求。二、用 unique_ptr 寫模板單例把上面的局部靜態(tài)對象換成std::unique_ptrT就得到“智能指針持有”的版本。它的好處是所有權表達明確單例對象由模板內(nèi)部持有外部只能拿到引用同時可以替換刪除器便于測試時注入自定義銷毀邏輯。// 適用于 C17#include memorytemplate typename Tclass Singleton {public:Singleton(const Singleton) delete;Singleton operator(const Singleton) delete;static T instance() {// C11 起局部靜態(tài)的初始化是線程安全的static std::unique_ptrT holder(new T());return *holder;}protected:Singleton() default;~Singleton() default;};使用方這樣寫// 適用于 C17class Config : public SingletonConfig {friend class SingletonConfig; // 允許模板訪問私有構造public:int port() const { return port_; }private:Config() : port_(8080) {}int port_ 0;};instance()里的new T()出現(xiàn)在SingletonT的成員函數(shù)中而SingletonT是T的友元所以能調用私有構造函數(shù)。這里不能用std::make_uniqueT()它內(nèi)部執(zhí)行new T(...)的函數(shù)不是友元訪問檢查會失敗。要么像上面這樣在友元上下文里直接new要么在T里提供一個公有的靜態(tài)工廠要么把構造函數(shù)改為 public 并靠約定約束。析構方面std::unique_ptrT的默認刪除器會執(zhí)行delete ptr需要T的析構函數(shù)可訪問。上面的Config沒有顯式寫析構隱式析構是公有的所以沒問題。如果你把~Config()設為私有就必須讓std::default_deleteConfig成為友元或者給unique_ptr換一個在友元上下文里定義的自定義刪除器。如果希望單例可以被重置測試里常用可以把持有者改成類的靜態(tài)成員并用std::call_once保證只創(chuàng)建一次。C17 的inline static可以讓靜態(tài)成員的定義留在頭文件里避免為每個實例化類型寫一遍類外定義// 適用于 C17#include memory#include mutextemplate typename Tclass ResettableSingleton {public:static T instance() {std::call_once(flag_, ResettableSingleton::create);return *holder_;}static void reset() {std::lock_guardstd::mutex lock(mutex_);holder_.reset();flag_ std::once_flag{}; // 允許下次重新創(chuàng)建}ResettableSingleton(const ResettableSingleton) delete;ResettableSingleton operator(const ResettableSingleton) delete;protected:ResettableSingleton() default;~ResettableSingleton() default;private:static void create() { holder_.reset(new T()); }inline static std::unique_ptrT holder_;inline static std::once_flag flag_;inline static std::mutex mutex_;};reset()只應在沒有其他線程使用單例時調用否則正在使用對象的線程會持有懸垂引用。這類接口適合測試不適合運行期隨意調用。三、shared_ptr 版本與觀察者有些設計希望單例的“所有權”可以被共享例如插件在初始化階段拿到shared_ptr并在自己的生命周期內(nèi)持有。寫法是把持有者換成std::shared_ptrT// 適用于 C17#include memorytemplate typename Tclass SharedSingleton {public:static std::shared_ptrT instance() {static std::shared_ptrT holder(new T());return holder;}SharedSingleton(const SharedSingleton) delete;SharedSingleton operator(const SharedSingleton) delete;protected:SharedSingleton() default;~SharedSingleton() default;};每次調用返回一個新的shared_ptr副本引用計數(shù)增加靜態(tài)holder在程序退出時釋放自己那一份若外部還持有對象會活得更久。這時的“單例”語義已經(jīng)變成“進程內(nèi)唯一的共享對象”而不是“嚴格與進程同生命周期”。std::weak_ptr在這里的用途是觀察而非持有外部保存weak_ptr需要時lock()可以知道單例是否已經(jīng)銷毀。但只要有靜態(tài)holder存在它就不會提前銷毀所以weak_ptr更多用于測試或可重置的設計。使用shared_ptr要留意循環(huán)引用如果單例對象內(nèi)部又持有指向自己的shared_ptr引用計數(shù)永遠不歸零。需要打破環(huán)時把其中一條邊改成weak_ptr。四、析構順序與“單例依賴單例”函數(shù)內(nèi)靜態(tài)對象在程序退出時按構造的逆序銷毀。如果Logger的析構函數(shù)里調用Config::instance()而Config的單例已經(jīng)先被銷毀那么訪問的就是一個生命周期已經(jīng)結束的對象屬于 UB。這類問題在單進程測試時不一定暴露在關閉路徑復雜時很難排查。規(guī)避方式有三類。其一讓單例的析構函數(shù)不做任何依賴其他單例的事只釋放自身資源。其二對確實需要“退出時按順序清理”的對象不要依賴靜態(tài)析構改為顯式提供shutdown()在main返回前按確定順序調用。其三把單例改為由main或應用容器持有通過引用傳遞徹底避開全局析構順序。單例還常被濫用為“全局變量容器”。它隱藏了依賴關系讓單元測試難以替換實現(xiàn)也讓初始化順序變得隱式。如果一個類型并非在語義上必須唯一優(yōu)先通過構造函數(shù)參數(shù)傳入只有日志器、進程級配置這類確實唯一的設施才值得單例。常見坑點? 手寫雙重檢查鎖定卻用普通指針static T* p; if (!p) { lock; if (!p) p new T; }。? 普通指針的讀寫產(chǎn)生數(shù)據(jù)競爭是 UB用函數(shù)內(nèi)靜態(tài)對象或把指針改為std::atomicT*并配合正確的內(nèi)存序。? 在模板單例里寫std::make_uniqueT()而T的構造函數(shù)是私有的。?make_unique不是友元在友元上下文里寫new T()或提供公有工廠。? 只刪除拷貝構造不刪除拷貝賦值。? 兩個都 delete否則仍可能賦值出第二個邏輯實例。? 把模板靜態(tài)成員的定義放進.cpp。? 模板在每個實例化點都需要定義靜態(tài)成員應放在頭文件C17 起可用inline static。? 單例 A 的析構函數(shù)里調用單例 B。? 靜態(tài)析構順序不可控析構函數(shù)只做自身清理跨對象清理顯式編排。?instance()返回T*調用方順手delete。? 返回T或返回shared_ptr明確所有權不要讓外部拿到可刪除的裸指針。? 繼承SingletonT后忘記在T里聲明friend class SingletonT;。? 私有構造無法被模板調用友元聲明是模板單例成立的前提。? 把單例當全局變量用任何地方都能改狀態(tài)。? 限定接口、把可變狀態(tài)收進成員函數(shù)并加同步能用依賴注入就不要用單例??偨Y方案創(chuàng)建時機線程安全來源適用場景局部靜態(tài)對象static T obj;首次調用C11 靜態(tài)初始化規(guī)則首選簡單且可靠局部靜態(tài)unique_ptrT首次調用同上需要自定義刪除器或顯式所有權call_once 靜態(tài)成員首次調用std::call_once需要reset或延遲參數(shù)局部靜態(tài)shared_ptrT首次調用同上外部需要共享所有權餓漢式全局對象程序啟動靜態(tài)初始化啟動期就要用、無依賴模板單例的骨架很短刪除拷貝、私有構造、一個返回引用的靜態(tài)函數(shù)、一個智能指針持有者。真正決定它是否可靠的是三個細節(jié)創(chuàng)建是否只發(fā)生一次、私有構造是否對模板開放、銷毀時是否還在訪問別的全局對象。把這三件事處理清楚比記住某種“標準寫法”更有用。