面試中的Singleton

發表於2016-01-05

引子

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

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

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

“首先要說的就是Singleton的建構函式。由於Singleton限制其型別例項有且只能有一個,因此我們應通過將建構函式設定為非公有來保證其不會被使用者程式碼隨意建立。而在型別例項訪問函式中,我們通過區域性靜態變數達到例項僅有一個的要求。另外,通過該靜態變數,我們可以將該例項的建立延遲到例項訪問函式被呼叫時才執行,以提高程式的啟動速度。”

保護

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

“還需要保護的有拷貝建構函式,解構函式以及賦值運算子。或許,我們還需要考慮取址運算子。這是因為編譯器會在需要的時候為這些成員建立一個預設的實現。”

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

“在這些成員沒有被宣告的情況下,編譯器將使用一系列預設行為:對例項的構造就是分配一部分記憶體,而不對該部分記憶體做任何事情;對例項的拷貝也僅僅是將原例項中的記憶體按位拷貝到新例項中;而賦值運算子也是對型別例項所擁有的各資訊進行拷貝。而在某些情況下,這些預設行為不再滿足條件,那麼編譯器將嘗試根據已有資訊建立這些成員的預設實現。這些影響因素可以分為幾種:型別所提供的相應成員,型別中的虛擬函式以及型別的虛基類。”

“就以建構函式為例,如果當前型別的成員或基類提供了由使用者定義的建構函式,那麼僅進行記憶體拷貝可能已經不是正確的行為。這是因為該成員的建構函式可能包含了成員初始化,成員函式呼叫等眾多執行邏輯。此時編譯器就需要為這個型別生成一個預設建構函式,以執行對成員或基類建構函式的呼叫。另外,如果一個型別宣告瞭一個虛擬函式,那麼編譯器仍需要生成一個建構函式,以初始化指向該虛擬函式表的指標。如果一個型別的各個派生類中擁有一個虛基類,那麼編譯器同樣需要生成建構函式,以初始化該虛基類的位置。這些情況同樣需要在拷貝建構函式中考慮:如果一個型別的成員變數擁有一個拷貝建構函式,或者其基類擁有一個拷貝建構函式,位拷貝就不再滿足要求了,因為拷貝建構函式內可能執行了某些並不是位拷貝的邏輯。同時如果一個型別宣告瞭虛擬函式,拷貝建構函式需要根據目標型別初始化虛擬函式表指標。如基類例項經過拷貝後,其虛擬函式表指標不應指向派生類的虛擬函式表。同理,如果一個型別的各個派生類中擁有一個虛派生,那麼編譯器也應為其生成拷貝建構函式,以正確設定各個虛基類的偏移。”

“當然,解構函式的情況則略為簡單一些:只需要呼叫其成員的解構函式以及基類的解構函式即可,而不需要再考慮對虛基類偏移的設定及虛擬函式表指標的設定。”

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

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

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

重用

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

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

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

“在需要重用該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實現。”

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

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

“好的。首先,我使用了一個指標記錄建立的Singleton例項,而不再是區域性靜態變數。這是因為區域性靜態變數可能在多執行緒環境下出現問題。”

“我想插一句話,為什麼區域性靜態變數會在多執行緒環境下出現問題?”

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

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

“同時,”我嚥了口口水繼續說,“因為new運算子的呼叫分為分配記憶體、呼叫建構函式以及為指標賦值三步,就像下面的建構函式呼叫:”

“這行程式碼會轉化為以下形式:”

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

“這樣就可能導致其中一個執行緒在完成了記憶體分配後就被切換到另一執行緒,而另一執行緒對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的一篇文章,但最後想想,寫模式的人太多了,我還是省省吧。。。

下一篇迴歸WPF,環境剛好。可能中間穿插些別的內容,比如HTML5,JS,安全等等。

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

相關文章