面試中的Singleton

引子

  “請寫一個Singleton。”面試官微笑着和我說。

  “這可真簡單。”我心裏想着,並在白板上寫下了下面的Singleton實現:

 
 1 class Singleton
 2 {
 3 public:
 4     static Singleton& Instance()
 5     {
 6         static Singleton singleton;
 7         return singleton;
 8     }
 9 
10 private:
11     Singleton() { };
12 };
 

  “那請你講解一下該實現的各組成。”面試官的臉上仍然帶着微笑。

  “首先要說的就是Singleton的構造函數。由於Singleton限制其類型實例有且只能有一個,因此我們應通過將構造函數設置爲非公有來保證其不會被用戶代碼隨意創建。而在類型實例訪問函數中,我們通過局部靜態變量達到實例僅有一個的要求。另外,通過該靜態變量,我們可以將該實例的創建延遲到實例訪問函數被調用時才執行,以提高程序的啓動速度。”

 

保護

  “說得不錯,而且更可貴的是你能注意到對構造函數進行保護。畢竟中間件代碼需要非常嚴謹才能防止用戶代碼的誤用。那麼,除了構造函數以外,我們還需要對哪些組成進行保護?”

  “還需要保護的有拷貝構造函數,析構函數以及賦值運算符。或許,我們還需要考慮取址運算符。這是因爲編譯器會在需要的時候爲這些成員創建一個默認的實現。”

  “那你能詳細說一下編譯器會在什麼情況下創建默認實現,以及創建這些默認實現的原因嗎?”面試官繼續問道。

  “在這些成員沒有被聲明的情況下,編譯器將使用一系列默認行爲:對實例的構造就是分配一部分內存,而不對該部分內存做任何事情;對實例的拷貝也僅僅是將原實例中的內存按位拷貝到新實例中;而賦值運算符也是對類型實例所擁有的各信息進行拷貝。而在某些情況下,這些默認行爲不再滿足條件,那麼編譯器將嘗試根據已有信息創建這些成員的默認實現。這些影響因素可以分爲幾種:類型所提供的相應成員,類型中的虛函數以及類型的虛基類。”

  “就以構造函數爲例,如果當前類型的成員或基類提供了由用戶定義的構造函數,那麼僅進行內存拷貝可能已經不是正確的行爲。這是因爲該成員的構造函數可能包含了成員初始化,成員函數調用等衆多執行邏輯。此時編譯器就需要爲這個類型生成一個默認構造函數,以執行對成員或基類構造函數的調用。另外,如果一個類型聲明瞭一個虛函數,那麼編譯器仍需要生成一個構造函數,以初始化指向該虛函數表的指針。如果一個類型的各個派生類中擁有一個虛基類,那麼編譯器同樣需要生成構造函數,以初始化該虛基類的位置。這些情況同樣需要在拷貝構造函數中考慮:如果一個類型的成員變量擁有一個拷貝構造函數,或者其基類擁有一個拷貝構造函數,位拷貝就不再滿足要求了,因爲拷貝構造函數內可能執行了某些並不是位拷貝的邏輯。同時如果一個類型聲明瞭虛函數,拷貝構造函數需要根據目標類型初始化虛函數表指針。如基類實例經過拷貝後,其虛函數表指針不應指向派生類的虛函數表。同理,如果一個類型的各個派生類中擁有一個虛派生,那麼編譯器也應爲其生成拷貝構造函數,以正確設置各個虛基類的偏移。”

  “當然,析構函數的情況則略爲簡單一些:只需要調用其成員的析構函數以及基類的析構函數即可,而不需要再考慮對虛基類偏移的設置及虛函數表指針的設置。”

  “在這些默認實現中,類型實例的各個原生類型成員並沒有得到初始化的機會。但是這一般被認爲是軟件開發人員的責任,而不是編譯器的責任。”說完這些,我長出一口氣,心裏也暗自慶幸曾經研究過該部分內容。

  “你剛纔提到需要考慮保護取址運算符,是嗎?我想知道。”

  “好的。首先要聲明的是,幾乎所有的人都會認爲對取址運算符的重載是邪惡的。甚至說,boost爲了防止該行爲所產生的錯誤更是提供了 addressof()函數。而另一方面,我們需要討論用戶爲什麼要用取址運算符。Singleton所返回的常常是一個引用,對引用進行取址將得到相應類型的指針。而從語法上來說,引用和指針的最大區別在於是否可以被delete關鍵字刪除以及是否可以爲NULL。但是Singleton返回一個引用也就表示其生存期由非用戶代碼所管理。因此使用取址運算符獲得指針後又用delete關鍵字刪除Singleton所返回的實例明顯是一個用戶錯誤。綜上所述,通過將取址運算符設置爲私有沒有多少意義。”

 

重用

  “好的,現在我們換個話題。如果我現在有幾個類型都需要實現爲Singleton,那我應怎樣使用你所編寫的這段代碼呢?”

  剛剛還在洋洋自得的我恍然大悟:這個Singleton實現是無法重用的。沒辦法,只好一邊想一邊說:“一般來說,較爲流行的重用方法一共有三種:組合、派生以及模板。首先可以想到的是,對Singleton的重用僅僅是對Instance()函數的重用,因此通過從Singleton派生以繼承該函數的實現是一個很好的選擇。而Instance()函數如果能根據實際類型更改返回類型則更好了。因此奇異遞歸模板(CRTP,The Curiously Recurring Template Pattern)模式則是一個非常好的選擇。”於是我在白板上飛快地寫下了下面的代碼:

 1 template <typename T>
 2 class Singleton
 3 {
 4 public:
 5     static T& Instance()
 6     {
 7         static T s_Instance;
 8         return s_Instance;
 9     }
10 
11 protected:
12     Singleton(void) {}
13     ~Singleton(void) {}
14 
15 private:
16     Singleton(const Singleton& rhs) {}
17     Singleton& operator = (const Singleton& rhs) {}
18 };

  同時我也在白板上寫下了對該Singleton實現進行重用的方法:

1 class SingletonInstance : public Singleton<SingletonInstance>…

  “在需要重用該Singleton實現時,我們僅僅需要從Singleton派生並將Singleton的泛型參數設置爲該類型即可。”

 

生存期管理

  “我看你在實現中使用了靜態變量,那你是否能介紹一下上面Singleton實現中有關生存期的一些特徵嗎?畢竟生存期管理也是編程中的一個重要話題。”面試官提出了下一個問題。

  “嗯,讓我想一想。我認爲對Singleton的生存期特性的討論需要分爲兩個方面:Singleton內使用的靜態變量的生存期以及 Singleton外在用戶代碼中所表現的生存期。Singleton內使用的靜態變量是一個局部靜態變量,因此只有在Singleton的 Instance()函數被調用時其纔會被創建,從而擁有了延遲初始化(Lazy)的效果,提高了程序的啓動性能。同時該實例將生存至程序執行完畢。而就 Singleton的用戶代碼而言,其生存期貫穿於整個程序生命週期,從程序啓動開始直到程序執行完畢。當然,Singleton在生存期上的一個缺陷就是創建和析構時的不確定性。由於Singleton實例會在Instance()函數被訪問時被創建,因此在某處新添加的一處對Singleton的訪問將可能導致Singleton的生存期發生變化。如果其依賴於其它組成,如另一個Singleton,那麼對它們的生存期進行管理將成爲一個災難。甚至可以說,還不如不用Singleton,而使用明確的實例生存期管理。”

  “很好,你能提到程序初始化及關閉時單件的構造及析構順序的不確定可能導致致命的錯誤這一情況。可以說,這是通過局部靜態變量實現 Singleton的一個重要缺點。而對於你所提到的多個Singleton之間相互關聯所導致的生存期管理問題,你是否有解決該問題的方法呢?”

  我突然間意識到自己給自己出了一個難題:“有,我們可以將Singleton的實現更改爲使用全局靜態變量,並將這些全局靜態變量在文件中按照特定順序排序即可。”

  “但是這樣的話,靜態變量將使用eager initialization的方式完成初始化,可能會對性能影響較大。其實,我想聽你說的是,對於具有關聯的兩個Singleton,對它們進行使用的代碼常常侷限在同一區域內。該問題的一個解決方法常常是將對它們進行使用的管理邏輯實現爲Singleton,而在內部邏輯中對它們進行明確的生存期管理。但不用擔心,因爲這個答案也過於經驗之談。那麼下一個問題,你既然提到了全局靜態變量能解決這個問題,那是否可以講解一下全局靜態變量的生命週期是怎樣的呢?”

  “編譯器會在程序的main()函數執行之前插入一段代碼,用來初始化全局變量。當然,靜態變量也包含在內。該過程被稱爲靜態初始化。

  “嗯,很好。使用全局靜態變量實現Singleton的確會對性能造成一定影響。但是你是否注意到它也有一定的優點呢?”

  見我許久沒有回答,面試官主動幫我解了圍:“是線程安全性。由於在靜態初始化時用戶代碼還沒有來得及執行,因此其常常處於單線程環境下,從而保證了Singleton真的只有一個實例。當然,這並不是一個好的解決方法。所以,我們來談談Singleton的多線程實現吧。”

 

多線程

  “首先請你寫一個線程安全的Singleton實現。”

  我拿起筆,在白板上寫下早已爛熟於心的多線程安全實現:

 1 template <typename T>
 2 class Singleton
 3 {
 4 public:
 5     static T& Instance()
 6     {
 7         if (m_pInstance == NULL)
 8         {
 9             Lock lock;
10             if (m_pInstance == NULL)
11             {
12                 m_pInstance = new T();
13                 atexit(Destroy);
14             }
15             return *m_pInstance;
16         }
17         return *m_pInstance;
18     }
19 
20 protected:
21     Singleton(void) {}
22     ~Singleton(void) {}
23 
24 private:
25     Singleton(const Singleton& rhs) {}
26     Singleton& operator = (const Singleton& rhs) {}
27 
28     void Destroy()
29     {
30         if (m_pInstance != NULL)
31             delete m_pInstance;
32         m_pInstance = NULL;
33     }
34 
35     static T* volatile m_pInstance;
36 };
37 
38 template <typename T>
39 T* Singleton<T>::m_pInstance = NULL;

  “寫得很精彩。那你是否能逐行講解一下你寫的這個Singleton實現呢?”

  “好的。首先,我使用了一個指針記錄創建的Singleton實例,而不再是局部靜態變量。這是因爲局部靜態變量可能在多線程環境下出現問題。”

  “我想插一句話,爲什麼局部靜態變量會在多線程環境下出現問題?”

  “這是由局部靜態變量的實際實現所決定的。爲了能滿足局部靜態變量只被初始化一次的需求,很多編譯器會通過一個全局的標誌位記錄該靜態變量是否已經被初始化的信息。那麼,對靜態變量進行初始化的僞碼就變成下面這個樣子:”。

1 bool flag = false;
2 if (!flag)
3 {
4     flag = true;
5     staticVar = initStatic();
6 }

  “那麼在第一個線程執行完對flag的檢查並進入if分支後,第二個線程將可能被啓動,從而也進入if分支。這樣,兩個線程都將執行對靜態變量的初始化。因此在這裏,我使用了指針,並在對指針進行賦值之前使用鎖保證在同一時間內只能有一個線程對指針進行初始化。同時基於性能的考慮,我們需要在每次訪問實例之前檢查指針是否已經經過初始化,以避免每次對Singleton的訪問都需要請求對鎖的控制權。”

  “同時,”我嚥了口口水繼續說,“因爲new運算符的調用分爲分配內存、調用構造函數以及爲指針賦值三步,就像下面的構造函數調用:”

1 SingletonInstance pInstance = new SingletonInstance();

  “這行代碼會轉化爲以下形式:”

1 SingletonInstance pHeap = __new(sizeof(SingletonInstance));
2 pHeap->SingletonInstance::SingletonInstance();
3 SingletonInstance pInstance = pHeap;

  “這樣轉換是因爲在C++標準中規定,如果內存分配失敗,或者構造函數沒有成功執行, new運算符所返回的將是空。一般情況下,編譯器不會輕易調整這三步的執行順序,但是在滿足特定條件時,如構造函數不會拋出異常等,編譯器可能出於優化的目的將第一步和第三步合併爲同一步:”

1 SingletonInstance pInstance = __new(sizeof(SingletonInstance));
2 pInstance->SingletonInstance::SingletonInstance();

  “這樣就可能導致其中一個線程在完成了內存分配後就被切換到另一線程,而另一線程對Singleton的再次訪問將由於pInstance已經賦值而越過if分支,從而返回一個不完整的對象。因此,我在這個實現中爲靜態成員指針添加了volatile關鍵字。該關鍵字的實際意義是由其修飾的變量可能會被意想不到地改變,因此每次對其所修飾的變量進行操作都需要從內存中取得它的實際值。它可以用來阻止編譯器對指令順序的調整。只是由於該關鍵字所提供的禁止重排代碼是假定在單線程環境下的,因此並不能禁止多線程環境下的指令重排。”

  “最後來說說我對atexit()關鍵字的使用。在通過new關鍵字創建類型實例的時候,我們同時通過atexit()函數註冊了釋放該實例的函數,從而保證了這些實例能夠在程序退出前正確地析構。該函數的特性也能保證後被創建的實例首先被析構。其實,對靜態類型實例進行析構的過程與前面所提到的在main()函數執行之前插入靜態初始化邏輯相對應。”

 

引用還是指針

  “既然你在實現中使用了指針,爲什麼仍然在Instance()函數中返回引用呢?”面試官又拋出了新的問題。

  “這是因爲Singleton返回的實例的生存期是由Singleton本身所決定的,而不是用戶代碼。我們知道,指針和引用在語法上的最大區別就是指針可以爲NULL,並可以通過delete運算符刪除指針所指的實例,而引用則不可以。由該語法區別引申出的語義區別之一就是這些實例的生存期意義:通過引用所返回的實例,生存期由非用戶代碼管理,而通過指針返回的實例,其可能在某個時間點沒有被創建,或是可以被刪除的。但是這兩條 Singleton都不滿足,因此在這裏,我使用指針,而不是引用。”

  “指針和引用除了你提到的這些之外,還有其它的區別嗎?”

  “有的。指針和引用的區別主要存在於幾個方面。從低層次向高層次上來說,分爲編譯器實現上的,語法上的以及語義上的區別。就編譯器的實現來說,聲明一個引用並沒有爲引用分配內存,而僅僅是爲該變量賦予了一個別名。而聲明一個指針則分配了內存。這種實現上的差異就導致了語法上的衆多區別:對引用進行更改將導致其原本指向的實例被賦值,而對指針進行更改將導致其指向另一個實例;引用將永遠指向一個類型實例,從而導致其不能爲NULL,並由於該限制而導致了衆多語法上的區別,如dynamic_cast對引用和指針在無法成功進行轉化時的行爲不一致。而就語義而言,前面所提到的生存期語義是一個區別,同時一個返回引用的函數常常保證其返回結果有效。一般來說,語義區別的根源常常是語法上的區別,因此上面的語義區別僅僅是列舉了一些例子,而真正語義上的差別常常需要考慮它們的語境。”

  “你在前面說到了你的多線程內部實現使用了指針,而返回類型是引用。在編寫過程中,你是否考慮了實例構造不成功的情況,如new運算符運行失敗?”

  “是的。在和其它人進行討論的過程中,大家對於這種問題有各自的理解。首先,對一個實例的構造將可能在兩處拋出異常:new運算符的執行以及構造函數拋出的異常。對於new運算符,我想說的是幾點。對於某些操作系統,例如Windows,其常常使用虛擬地址,因此其運行常常不受物理內存實際大小的限制。而對於構造函數中拋出的異常,我們有兩種策略可以選擇:在構造函數內對異常進行處理,以及在構造函數之外對異常進行處理。在構造函數內對異常進行處理可以保證類型實例處於一個有效的狀態,但一般不是我們想要的實例狀態。這樣一個實例會導致後面對它的使用更爲繁瑣,例如需要更多的處理邏輯或再次導致程序執行異常。反過來,在構造函數之外對異常進行處理常常是更好的選擇,因爲軟件開發人員可以根據產生異常時所構造的實例的狀態將一定範圍內的各個變量更改爲合法的狀態。舉例來說,我們在一個函數中嘗試創建一對相互關聯的類型實例,那麼在一個實例的構造函數拋出了異常時,我們不應該在構造函數裏對該實例的狀態進行維護,因爲前一個實例的構造是按照後一個實例會正常創建來進行的。相對來說,放棄後一個實例,並將前一個實例刪除是一個比較好的選擇。”

  我在白板上比劃了一下,繼續說到:“我們知道,異常有兩個非常明顯的缺陷:效率,以及對代碼的污染。在太小的粒度中使用異常,就會導致異常使用次數的增加,對於效率以及代碼的整潔型都是傷害。同樣地,對拷貝構造函數等組成常常需要使用類似的原則。”

  “反過來說,Singleton的使用也可以保持着這種原則。Singleton僅僅是一個包裝好的全局實例,對其的創建如果一旦不成功,在較高層次上保持正常狀態同樣是一個較好的選擇。”

 

Anti-Patten

  “既然你提到了Singleton僅僅是一個包裝好的全局變量,那你能說說它和全局變量的相同與不同麼?”

  “單件可以說是全局變量的替代品。其擁有全局變量的衆多特點:全局可見且貫穿應用程序的整個生命週期。除此之外,單件模式還擁有一些全局變量所不具有的性質:同一類型的對象實例只能有一個,同時適當的實現還擁有延遲初始化(Lazy)的功能,可以避免耗時的全局變量初始化所導致的啓動速度不佳等問題。要說明的是,Singleton的最主要目的並不是作爲一個全局變量使用,而是保證類型實例有且僅有一個。它所具有的全局訪問特性僅僅是它的一個副作用。但正是這個副作用使它更類似於包裝好的全局變量,從而允許各部分代碼對其直接進行操作。軟件開發人員需要通過仔細地閱讀各部分對其進行操作的代碼才能瞭解其真正的使用方式,而不能通過接口得到組件依賴性等信息。如果Singleton記錄了程序的運行狀態,那麼該狀態將是一個全局狀態。各個組件對其進行操作的調用時序將變得十分重要,從而使各個組件之間存在着一種隱式的依賴。”

  “從語法上來講,首先Singleton模式實際上將類型功能與類型實例個數限制的代碼混合在了一起,違反了SRP。其次Singleton模式在Instance()函數中將創建一個確定的類型,從而禁止了通過多態提供另一種實現的可能。”

  “但是從系統的角度來講,對Singleton的使用則是無法避免的:假設一個系統擁有成百上千個服務,那麼對它們的傳遞將會成爲系統的一個災難。從微軟所提供的衆多類庫上來看,其常常提供一種方式獲得服務的函數,如GetService()等。另外一個可以減輕Singleton模式所帶來不良影響的方法則是爲Singleton模式提供無狀態或狀態關聯很小的實現。”

  “也就是說,Singleton本身並不是一個非常差的模式,對其使用的關鍵在於何時使用它並正確的使用它。”

  面試官擡起手腕看了看時間:“好了,時間已經到了。你的C++功底已經很好了。我相信,我們會在不久的將來成爲同事。”

 

筆者注:這本是Writing Patterns Line by Line的一篇文章,但最後想想,寫模式的人太多了,我還是省省吧。。。

頭一次寫小品文,不知道效果是不是好。因爲這種文章的特點是知識點分散,而且隱藏在文章的每一句話中。。。好處就是寫起來輕鬆,呵呵。。。

發佈了17 篇原創文章 · 獲贊 4 · 訪問量 6萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章