引子html
class Singleton { public: static Singleton Instance() { static Singleton singleton; return singleton; } private: Singleton() { }; };
「那請你講解一下該實現的各組成。」面試官的臉上仍然帶着微笑。java
「首先要說的就是Singleton的構造函數。因爲Singleton限制其類型實例有且只能有一個,所以咱們應經過將構造函數設置爲非公有來保證其不會被用戶代碼隨意建立。而在類型實例訪問函數中,咱們經過局部靜態變量達到實例僅有一個的要求。另外,經過該靜態變量,咱們能夠將該實例的建立延遲到實例訪問函數被調用時才執行,以提升程序的啓動速度。」面試
保護編程
「說得不錯,並且更難得的是你能注意到對構造函數進行保護。畢竟中間件代碼須要很是嚴謹才能防止用戶代碼的誤用。那麼,除了構造函數之外,咱們還須要對哪些組成進行保護?」安全
「還須要保護的有拷貝構造函數,析構函數以及賦值運算符。或許,咱們還須要考慮取址運算符。這是由於編譯器會在須要的時候爲這些成員建立一個默認的實現。」多線程
「那你能詳細說一下編譯器會在什麼狀況下建立默認實現,以及建立這些默認實現的緣由嗎?」面試官繼續問道。函數
「在這些成員沒有被聲明的狀況下,編譯器將使用一系列默認行爲:對實例的構造就是分配一部份內存,而不對該部份內存作任何事情;對實例的拷貝也僅僅是將原實例中的內存按位拷貝到新實例中;而賦值運算符也是對類型實例所擁有的各信息進行拷貝。而在某些狀況下,這些默認行爲再也不知足條件,那麼編譯器將嘗試根據已有信息建立這些成員的默認實現。這些影響因素能夠分爲幾種:類型所提供的相應成員,類型中的虛函數以及類型的虛基類。」性能
「就以構造函數爲例,若是當前類型的成員或基類提供了由用戶定義的構造函數,那麼僅進行內存拷貝可能已經不是正確的行爲。這是由於該成員的構造函數可能bao含了成員初始化,成員函數調用等衆多執行邏輯。此時編譯器就須要爲這個類型生成一個默認構造函數,以執行對成員或基類構造函數的調用。另外,若是一個類型聲明瞭一個虛函數,那麼編譯器仍須要生成一個構造函數,以初始化指向該虛函數表的指針。若是一個類型的各個派生類中擁有一個虛基類,那麼編譯器一樣須要生成構造函數,以初始化該虛基類的位置。這些狀況一樣須要在拷貝構造函數中考慮:若是一個類型的成員變量擁有一個拷貝構造函數,或者其基類擁有一個拷貝構造函數,位拷貝就再也不知足要求了,由於拷貝構造函數內可能執行了某些並非位拷貝的邏輯。同時若是一個類型聲明瞭虛函數,拷貝構造函數須要根據目標類型初始化虛函數表指針。如基類實例通過拷貝後,其虛函數表指針不該指向派生類的虛函數表。同理,若是一個類型的各個派生類中擁有一個虛派生,那麼編譯器也應爲其生成拷貝構造函數,以正確設置各個虛基類的偏移。」優化
「固然,析構函數的狀況則略爲簡單一些:只須要調用其成員的析構函數以及基類的析構函數便可,而不須要再考慮對虛基類偏移的設置及虛函數表指針的設置。」spa
「在這些默認實現中,類型實例的各個原生類型成員並無獲得初始化的機會。可是這通常被認爲是軟件開發人員的責任,而不是編譯器的責任。」說完這些,我長出一口氣,內心也暗自慶幸曾經研究過該部份內容。
「你剛纔提到須要考慮保護取址運算符,是嗎?我想知道。」
「好的。首先要聲明的是,幾乎全部的人都會認爲對取址運算符的重載是邪惡的。甚至說,boost爲了防止該行爲所產生的錯誤更是提供了addressof()函數。而另外一方面,咱們須要討論用戶爲何要用取址運算符。Singleton所返回的經常是一個引用,對引用進行取址將獲得相應類型的指針。而從語法上來講,引用和指針的最大區別在因而否能夠被delete關鍵字刪除以及是否能夠爲NULL。可是Singleton返回一個引用也就表示其生存期由非用戶代碼所管理。所以使用取址運算符得到指針後又用delete關鍵字刪除Singleton所返回的實例明顯是一個用戶錯誤。綜上所述,經過將取址運算符設置爲私有沒有多少意義。」
重用
「好的,如今咱們換個話題。若是我如今有幾個類型都須要實現爲Singleton,那我應怎樣使用你所編寫的這段代碼呢?」
剛剛還在洋洋自得的我恍然大悟:這個Singleton實現是沒法重用的。沒辦法,只好一邊想一邊說:「通常來講,較爲流行的重用方法一共有三種:組合、派生以及模板。首先能夠想到的是,對Singleton的重用僅僅是對Instance()函數的重用,所以經過從Singleton派生以繼承該函數的實現是一個很好的選擇。而Instance()函數若是能根據實際類型更改返回類型則更好了。所以奇異遞歸模板(CRTP,The Curiously Recurring Template Pattern)模式則是一個很是好的選擇。」因而我在白板上飛快地寫下了下面的代碼:
[cpp] view plain copy print?
template <typename T>
class Singleton
{
public:
static T& Instance()
{
static T s_Instance;
return s_Instance;
}
protected:
Singleton(void) {}
~Singleton(void) {}
private:
Singleton(const Singleton& rhs) {}
Singleton& operator = (const Singleton& rhs) {}
};
同時我也在白板上寫下了對該Singleton實現進行重用的方法:
[cpp] view plain copy print?
class SingletonInstance : public Singleton<SingletonInstance>…
「在須要重用該Singleton實現時,咱們僅僅須要從Singleton派生並將Singleton的泛型參數設置爲該類型便可。」
生存期管理
「我看你在實現中使用了靜態變量,那你是否能介紹一下上面Singleton實現中有關生存期的一些特徵嗎?畢竟生存期管理也是編程中的一個重要話題。」面試官提出了下一個問ti。
「嗯,讓我想想。我認爲對Singleton的生存期特性的討論須要分爲兩個方面:Singleton內使用的靜態變量的生存期以及Singleton外在用戶代碼中所表現的生存期。Singleton內使用的靜態變量是一個局部靜態變量,所以只有在Singleton的Instance()函數被調用時其纔會被建立,從而擁有了延遲初始化(Lazy)的效果,提升了程序的啓動性能。同時該實例將生存至程序執行完畢。而就Singleton的用戶代碼而言,其生存期貫穿於整個程序生命週期,從程序啓動開始直到程序執行完畢。固然,Singleton在生存期上的一個缺陷就是建立和析構時的不肯定性。因爲Singleton實例會在Instance()函數被訪問時被建立,所以在某處新添加的一處對Singleton的訪問將可能致使Singleton的生存期發生變化。若是其依賴於其它組成,如另外一個Singleton,那麼對它們的生存期進行管理將成爲一個災難。甚至能夠說,還不如不用Singleton,而使用明確的實例生存期管理。」
「很好,你能提到程序初始化及關閉時單件的構造及析構順序的不肯定可能致使致命的錯誤這一狀況。能夠說,這是經過局部靜態變量實現Singleton的一個重要缺點。而對於你所提到的多個Singleton之間相互關聯所致使的生存期管理問ti,你是否有解決該問ti的方法呢?」
我忽然間意識到本身給本身出了一個難ti:「有,咱們能夠將Singleton的實現更改成使用全局靜態變量,並將這些全局靜態變量在文件中按照特定順序排序便可。」
「可是這樣的話,靜態變量將使用eager initialization的方式完成初始化,可能會對性能影響較大。其實,我想聽你說的是,對於具備關聯的兩個Singleton,對它們進行使用的代碼經常侷限在同一區域內。該問ti的一個解決方法經常是將對它們進行使用的管理邏輯實現爲Singleton,而在內部邏輯中對它們進行明確的生存期管理。但不用擔憂,由於這個da案也過於經驗之談。那麼下一個問ti,你既然提到了全局靜態變量能解決這個問ti,那是否能夠講解一下全局靜態變量的生命週期是怎樣的呢?」
「編譯器會在程序的main()函數執行以前插入一段代碼,用來初始化全局變量。固然,靜態變量也bao含在內。該過程被稱爲靜態初始化。」
「嗯,很好。使用全局靜態變量實現Singleton的確會對性能形成必定影響。可是你是否注意到它也有必定的優勢呢?」
見我許久沒有回da,面試官主動幫我解了圍:「是線程安全性。因爲在靜態初始化時用戶代碼尚未來得及執行,所以其經常處於單線程環境下,從而保證了Singleton真的只有一個實例。固然,這並非一個好的解決方法。因此,咱們來談談Singleton的多線程實現吧。」
多線程
「首先請你寫一個線程安全的Singleton實現。」
我拿起筆,在白板上寫下早已爛熟於心的多線程安全實現:
[cpp] view plain copy print?
template <typename T>
class Singleton
{
public:
static T& Instance()
{
if (m_pInstance == NULL)
{
Lock lock;
if (m_pInstance == NULL)
{
m_pInstance = new T();
atexit(Destroy);
}
return *m_pInstance;
}
return *m_pInstance;
}
protected:
Singleton(void) {}
~Singleton(void) {}
private:
Singleton(const Singleton& rhs) {}
Singleton& operator = (const Singleton& rhs) {}
void Destroy()
{
if (m_pInstance != NULL)
delete m_pInstance;
m_pInstance = NULL;
}
static T* volatile m_pInstance;
};
template <typename T>
T* Singleton<T>::m_pInstance = NULL;
「寫得很精彩。那你是否能逐行講解一下你寫的這個Singleton實現呢?」
「好的。首先,我使用了一個指針記錄建立的Singleton實例,而再也不是局部靜態變量。這是由於局部靜態變量可能在多線程環境下出現問ti。」
「我想插一句話,爲何局部靜態變量會在多線程環境下出現問題?」
「這是由局部靜態變量的實際實現所決定的。爲了能知足局部靜態變量只被初始化一次的需求,不少編譯器會經過一個全局的標誌位記錄該靜態變量是否已經被初始化的信息。那麼,對靜態變量進行初始化的僞碼就變成下面這個樣子:」。
[cpp] view plain copy print?
bool flag = false;
if (!flag)
{
flag = true;
staticVar = initStatic();
}
「那麼在第一個線程執行完對flag的檢查並進入if分支後,第二個線程將可能被啓動,從而也進入if分支。這樣,兩個線程都將執行對靜態變量的初始化。所以在這裏,我使用了指針,並在對指針進行賦值以前使用鎖保證在同一時間內只能有一個線程對指針進行初始化。同時基於性能的考慮,咱們須要在每次訪問實例以前檢查指針是否已經通過初始化,以免每次對Singleton的訪問都須要請求對鎖的控制權。」
「同時,」我嚥了口口水繼續說,「由於new運算符的調用分爲分配內存、調用構造函數以及爲指針賦值三步,就像下面的構造函數調用:」
[cpp] view plain copy print?
SingletonInstance pInstance = new SingletonInstance();
「這行代碼會轉化爲如下形式:」
[cpp] view plain copy print?
SingletonInstance pHeap = __new(sizeof(SingletonInstance));
pHeap->SingletonInstance::SingletonInstance();
SingletonInstance pInstance = pHeap;
「這樣轉換是由於在C++標準中規定,若是內存分配失敗,或者構造函數沒有成功執行, new運算符所返回的將是空。通常狀況下,編譯器不會輕易調整這三步的執行順序,可是在知足特定條件時,如構造函數不會拋出異常等,編譯器可能出於優化的目的將第一步和第三步合併爲同一步:」
[html] view plain copy print?
SingletonInstance pInstance = __new(sizeof(SingletonInstance));
pInstance->SingletonInstance::SingletonInstance();
「這樣就可能致使其中一個線程在完成了內存分配後就被切換到另外一線程,而另外一線程對Singleton的再次訪問將因爲pInstance已經賦值而越過if分支,從而返回一個不完整的對象。所以,我在這個實現中爲靜態成員指針添加了volatile關鍵字。該關鍵字的實際意義是由其修飾的變量可能會被意想不到地改變,所以每次對其所修飾的變量進行操做都須要從內存中取得它的實際值。它能夠用來阻止編譯器對指令順序的調整。只是因爲該關鍵字所提供的禁止重排代碼是假定在單線程環境下的,所以並不能禁止多線程環境下的指令重排。」
「最後來講說我對atexit()關鍵字的使用。在經過new關鍵字建立類型實例的時候,咱們同時經過atexit()函數註冊了釋放該實例的函數,從而保證了這些實例可以在程序退出前正確地析構。該函數的特性也能保證後被建立的實例首先被析構。其實,對靜態類型實例進行析構的過程與前面所提到的在main()函數執行以前插入靜態初始化邏輯相對應。」
引用仍是指針
「既然你在實現中使用了指針,爲何仍然在Instance()函數中返回引用呢?」面試官又拋出了新的問ti。
「這是由於Singleton返回的實例的生存期是由Singleton自己所決定的,而不是用戶代碼。咱們知道,指針和引用在語法上的最大區別就是指針能夠爲NULL,並能夠經過delete運算符刪除指針所指的實例,而引用則不能夠。由該語法區別引伸出的語義區別之一就是這些實例的生存期意義:經過引用所返回的實例,生存期由非用戶代碼管理,而經過指針返回的實例,其可能在某個時間點沒有被建立,或是能夠被刪除的。可是這兩條Singleton都不知足,所以在這裏,我使用指針,而不是引用。」
「指針和引用除了你提到的這些以外,還有其它的區別嗎?」
「有的。指針和引用的區別主要存在於幾個方面。從低層次向高層次上來講,分爲編譯器實現上的,語法上的以及語義上的區別。就編譯器的實現來講,聲明一個引用並無爲引用分配內存,而僅僅是爲該變量賦予了一個別名。而聲明一個指針則分配了內存。這種實現上的差別就致使了語法上的衆多區別:對引用進行更改將致使其本來指向的實例被賦值,而對指針進行更改將致使其指向另外一個實例;引用將永遠指向一個類型實例,從而致使其不能爲NULL,並因爲該限制而致使了衆多語法上的區別,如dynamic_cast對引用和指針在沒法成功進行轉化時的行爲不一致。而就語義而言,前面所提到的生存期語義是一個區別,同時一個返回引用的函數經常保證其返回結果有效。通常來講,語義區別的根源經常是語法上的區別,所以上面的語義區別僅僅是列舉了一些例子,而真正語義上的差異經常須要kao慮它們的語境。」
「你在前面說到了你的多線程內部實現使用了指針,而返回類型是引用。在編寫過程當中,你是否kao慮了實例構造不成功的狀況,如new運算符運行失敗?」
「是的。在和其它人進行討論的過程當中,你們對於這種問題有各自的理解。首先,對一個實例的構造將可能在兩處拋出異常:new運算符的執行以及構造函數拋出的異常。對於new運算符,我想說的是幾點。對於某些操做系統,例如Windows,其經常使用虛擬地址,所以其運行經常不受物理內存實際大小的限制。而對於構造函數中拋出的異常,咱們有兩種策略能夠選擇:在構造函數內對異常進行處理,以及在構造函數以外對異常進行處理。在構造函數內對異常進行處理能夠保證類型實例處於一個有效的狀態,但通常不是咱們想要的實例狀態。這樣一個實例會致使後面對它的使用更爲繁瑣,例如須要更多的處理邏輯或再次致使程序執行異常。反過來,在構造函數以外對異常進行處理經常是更好的選擇,由於軟件開發人員能夠根據產生異常時所構造的實例的狀態將必定範圍內的各個變量更改成合法的狀態。舉例來講,咱們在一個函數中嘗試建立一對相互關聯的類型實例,那麼在一個實例的構造函數拋出了異常時,咱們不該該在構造函數裏對該實例的狀態進行維護,由於前一個實例的構造是按照後一個實例會正常建立來進行的。相對來講,放棄後一個實例,並將前一個實例刪除是一個比較好的選擇。」
我在白板上比劃了一下,繼續說到:「咱們知道,異常有兩個很是明顯的缺陷:效率,以及對代碼的污染。在過小的粒度中使用異常,就會致使異常使用次數的增長,對於效率以及代碼的整潔型都是傷害。一樣地,對kao貝構造函數等組成經常須要使用相似的原則。」
「反過來講,Singleton的使用也能夠保持着這種原則。Singleton僅僅是一個bao裝好的全局實例,對其的建立若是一旦不成功,在較高層次上保持正常狀態一樣是一個較好的選擇。」
Anti-Patten
「既然你提到了Singleton僅僅是一個bao裝好的全局變量,那你能說說它和全局變量的相同與不一樣麼?」
「單件能夠說是全局變量的替代品。其擁有全局變量的衆多特色:全局可見且貫穿應用程序的整個生命週期。除此以外,單件模式還擁有一些全局變量所不具備的性質:同一類型的對象實例只能有一個,同時適當的實現還擁有延遲初始化(Lazy)的功能,能夠避免耗時的全局變量初始化所致使的啓動速度不佳等問題。要說明的是,Singleton的最主要目的並非做爲一個全局變量使用,而是保證類型實例有且僅有一個。它所具備的全局訪問特性僅僅是它的一個反作用。但正是這個反作用使它更相似於bao裝好的全局變量,從而容許各部分代碼對其直接進行操做。軟件開發人員須要經過仔細地閱讀各部分對其進行操做的代碼才能瞭解其真正的使用方式,而不能經過接口獲得組件依賴性等信息。若是Singleton記錄了程序的運行狀態,那麼該狀態將是一個全局狀態。各個組件對其進行操做的調用時序將變得十分重要,從而使各個組件之間存在着一種隱式的依賴。」
「從語法上來說,首先Singleton模式實際上將類型功能與類型實例個數限制的代碼混合在了一塊兒,違反了SRP。其次Singleton模式在Instance()函數中將建立一個肯定的類型,從而禁止了經過多態提供另外一種實現的可能。」
「可是從系統的角度來說,對Singleton的使用則是沒法避免的:假設一個系統擁有成百上千個服務,那麼對它們的傳遞將會成爲系統的一個災難。從微軟所提供的衆多類庫上來看,其經常提供一種方式得到服務的函數,如GetService()等。另一個能夠減輕Singleton模式所帶來不良影響的方法則是爲Singleton模式提供無狀態或狀態關聯很小的實現。」
「也就是說,Singleton自己並非一個很是差的模式,對其使用的關鍵在於什麼時候使用它並正確的使用它。」
面試官擡起手腕看了看時間:「好了,時間已經到了。你的C++功底已經很好了。我相信,咱們會在不久的未來成爲同事。」