1. 程式人生 > >虛擬函式是否應該被宣告僅為private/protected?

虛擬函式是否應該被宣告僅為private/protected?

問題匯入
我想對於大家來說,虛擬函式並不能算是個陌生的概念吧。至於怎麼樣使用它,大部分人都會告訴我:通過在子類中重寫(override)基類中的虛擬函式,就可以達到OO中的一個重要特性——多型(polymorphism)。不錯,虛擬函式的作用也正是如此。但如果我要你說一說虛擬函式被宣告為public和被宣告為private/protected之間的區別的話,你又是否還能象先前一樣肯定地告訴我答案呢?

其實在一開始,我和大家一樣,都喜歡把虛擬函式宣告為public(我並沒有做太多的調查就說了這些,因為我身邊的程式設計師們幾乎都是這樣做的)。這樣做的好處很明顯:我們可以輕而易舉地在客戶端(client,相對於server,server指的是我們所使用的繼承體系架構,client指的就是呼叫該體系中方法/函式的外部程式碼)呼叫它,而不是通過利用那些煩人的using宣告,或是強加給類的friend關係來滿足編譯器的access需求。OK,這是一個很不錯的做法,簡單、並且還能達到我們的要求。

但根據OO三大特性中的另一個特性——封裝(encapsulation)來說(另一就是繼承),需要我們將介面(interface)與實作(implementation)分開,即向外表現為一個通用的介面,而把實作上的細節封裝在模組內不讓client端知曉。介面與實作的分離,使得我們得以設計出耦合度更低、擴充套件性更好的系統來,並且還可以從這樣的系統中提取出更多的可重用(reusable)的設計。

對於OO來說,封裝是它的頭等大事,享有最高的權利,其他的設計如果和它有著衝突,則以符合它的設計為準。這樣,問題就出來了,萬一我們所希望出現的多型正好是具體的實作細節並且我們不希望把它暴露給client端的話,那我們應該怎麼樣改動我們的設計以使得它能夠適應封裝的需求呢?

可行的解決辦法
幸好,C++中不但支援public的虛擬函式,也有著private/protected虛擬函式。(在此我不想對於public和private/protected之間的區別多說。)前者是我們常用的形式,我也不多說,我們在此主要關心的是private/protected的虛擬函式。

你可能會有疑惑,既然虛擬函式被宣告為private(protected不算,因為子類可以直接訪問基類的protected成員),那子類中怎麼還能對它進行重寫呢?在此,你的疑慮是多餘的,C++標準(也稱ISO 14882)告訴我們,虛擬函式的重寫與它的具體儲存許可權沒有任何關係,即便是宣告為private的虛擬函式,在子類中我們也同樣可以重寫它。因此,碰到上面所說的問題,我們就可以得到如下的設計:

class Base {
public:
            void do_something()
            {
                        //......
                        really_do_something();
                        //......
            }
private:
            virtual void really_do_something()
            {
                        //do the polymorphism code here
            }
};

class Derived: public Base {
private:
            void really_do_something()
            {
                        //do the polymorphism code here
            }
};

如果我們需要從上面的設計中得到實際上的多型行為,只要象下面一樣呼叫do_something就可以了:

//client code
Base&    b;                 //or Base* pb;
b.do_something();    //or pb->do_something();

這樣我們就得以解決了在開始處提出的那個問題。

問題引申
那就這樣完結了嗎?沒有。相反,至此我們才開始進行我們今天的討論。首先讓我們來看看多型的實現:

  void Base::do_something()
            {
                        //......
                        really_do_something();
                        //......
            }

我們可以發現,在呼叫真正對多型有貢獻的really_do_something()之前及呼叫後,我們還可以在其中新增我們自身的程式碼(如一些控制程式碼等),這樣我們“好像”就可以輕而易舉地實現了Bertrand Meyers所提出的“Design By Contract”(DBC)[1]了:

    void Base::do_something()
            {
                        //our precondition code here
                        really_do_something();
                        //our postcondition code here
            }

然後,讓我們在去看看Template Method這個Pattern[2],發現所謂的Template Method也主要就是通過這種方式來進行的。於是,我們是否可以這麼想呢:將所有的虛擬函式都宣告為private/protected,然後再使用一個public的非虛擬函式呼叫它,這樣,我們不就得到了上面所列出的所有好處嗎?

詳細分析
簡單看來,好像那麼做真的是好處大大的,既不會造成效率上的損失(通過將該public的非虛擬函式inline化,簡單的函式轉呼叫的開銷就可以被消除掉),又能夠獲得上述所有的好處。何樂而不為呢?
實際上來看,有不少程式設計師也正是這麼做的(Herb Sutter所調查的結果表明,這裡面甚至還包括那些實作標準函式庫的程式設計師們,當然,他們所考慮到的使用這種技巧的理由不會僅僅是我下面所給出的其他人的理由^_^)。有的人甚至還認為,虛擬函式就應該被宣告為private/protected(當然,虛擬的解構函式不能夠算在其中之列,否則就會有大亂子了)。
但讓我們再仔細地考慮一下,想想一些比較極端的例子。假設我們有一個類,它擁有的虛擬函式的個數非常之多(就算它10000個吧),那即使大多數情況下只是簡單的函式轉呼叫動作,我們是否還應該為它的每一個虛擬函式都提供一個公開的非虛擬的介面呢?這時,為你的程式提供一個介面類(即沒有任何成員變數,所有的方法都是純虛擬函式的類)是一個不錯的解決方案。
還有,因為這樣做的結果將會是:基類中的那個public的非虛擬介面函式必須能夠適合所有的子類的情況,這樣,我們將所有的責任都推倒基類上去了,這不能算是一個好的設計方法。假設我們有了一個繼承體系極深的架構,在對基類進行了多次繼承後,我們突然發現,新的子類已經無法適應原有的那個介面了。於是,為了繼續執行我們的虛擬函式private化,我們就將不得不把基類的程式碼給翻出來並改正它。幸運點的是,基類的程式碼是我們可以得到的,這樣我們最起碼還是有機會改正的(雖然有的時候,我們已經無法看懂基類中的程式碼了);糟糕的是,我們的基類是通過我們使用的一個函式庫中得到的,而該函式庫的程式碼我們無法獲得,這個時候我們該怎麼辦呢?由此可見,如果在設計可能會被進行深度繼承的類繼承體系架構時,要想繼續使用private的虛擬函式的話,對於設計基類的要求就將會變的非常之高(因為在以後,基類的任何小小改動造成的後果傳遞到了繼承的低端時都將被顯著的放大),而讓設計人員去猜測以後所有的可能使用情況是件不現實的事情,這樣也就容易產生脆弱的、需要被頻繁改動的設計。請記住一點:FBC(Fragile Base Class)是一件可怕的事情,在我們的程式中應當避免出現這種情況。
另外,在你決定把你程式中的虛擬函式改為private/protected前,你有沒有一個很好的理由呢?如果你只是說:“哦,我不知道,不過這樣做可能會在以後的某天產生作用”。不錯,時刻讓自己的程式保持可擴充套件性是很好的一件事情,但那都是基於你可以預見未來的擴充套件之上的(這種預見主要來自於你對於該領域的深刻認識或是你平時的經驗)。在沒有任何理由的情況下,僅僅靠著一句“它以後可能會有用”就往自己的程式中新增進去某種特性聽起來好像很炫,但實際上它可能對你的程式有百害而無一利。在我們現有的各種Framework中,有著很多類似的“以後可能會有用”的特性,結果最終都被證明為沒有被使用到,這不能不說是對於開發工作的一種浪費。因此,還是讓我們記住在XP[3]中所說的YNGNI(You Never Going to Need It),對於現階段沒有用到的特性,還是不要提供為好。不過,如果你能夠預見到以後的擴充套件的話,還是請你為它留下一個可擴充套件的便利。
此外,基於編譯器的角度來看,當你一旦改動了基類,那麼所需要重新編譯的就不僅僅是基類本身了,所有從該基類繼承下來的派生類也都將被重新編譯。這樣,我們就不得不又浪費掉大量的編譯時間了。尤其是當我們決定大量使用inline的方式來轉呼叫時,所需的時間就更加多了(因為inline函式在編譯時會被擴充套件成實際的呼叫程式碼)。這也可以算是一種語法上的FBC問題。此外,當你決定向你的繼承體系中增加一個函式,並改變了基類介面的行為,你就有可能破壞了整個繼承體系,並使得外部的client端程式碼也受到了衝擊。這種情況可以算是一種語義上的FBC問題。請記住:穩定的程式碼永遠不要建立在不穩定的程式碼基礎之上。
現在,再讓我們回到Template Method上面來看。什麼時候該使用TM呢?從Design Patterns中得到它的意圖為:定義一個操作中的演算法的骨架,而將一些步驟延遲到子類中。Template Method使得子類可以不改變一個演算法的結構即可重定義該演算法的某些特定步驟。這和我們所談論的虛擬函式是不是應該為private/protected完全是不相干的,雖說在實現TM時我們會用到private/protected的虛擬函式,但並不是所有的private/protected virtual都為TM。
最後,完全使用private/protected virtual還有一個問題就是:OO中所提倡的彈性。我們知道,OO中的彈性通常都是由繼承中的多型提供的,但有時我們也會使用組合中的委託。實際上已經有很多的Patterns都是這麼做的了,如:Proxy, Adapter, Bridge, Decorator等。如果一味地追求private/protected virtual,勢必使得我們只能在程式中使用繼承了,為了一棵樹而放棄一片森林的事情,我想大家也都不願意做吧。

結論
說了半天,我也該收工了:-)現在開始進行我觀點的歸納:
一般說來,把虛擬函式宣告為private/protected是一個很不錯的設計方法[4],但如果一旦把它作為一個唯一的Sliver Bullet來使用的話,就會產生許許多多的問題。在這篇文章中我也只是大概的談了其中的部分,還有其他的一部分內容由於現今還沒有完全整理好,也就不多說了。希望能夠在下次再把它完善掉。

參考資料
1、Object-Oriented Software Construction,Second Edition, Bertrand Meyer,清華大學出版社出版(影印版)
2、設計模式可複用面向物件軟體的基礎, GoF, 李紅軍等譯,機械工業出版社出版
3、Conversations: Virtually Yours, Herb Sutter & Jim Hyslop, CUJ
以及網路上相關的資料

後記
寫該文的最初衝動來源於newsgroup: comp.lang.c++.moderated上面的一個討論:Virtual methods should only be private or protected?在觀看了Kevlin Henney,Herb Sutter以及James Kanze等幾位大師的精彩言論後,總想把自己的感受寫下來。在一開始,我倒是寫了很多,但沒有完全寫完。近來由於比較忙的情況,因此也就慢慢地把此事差點給忘記了。不是蟲蟲催著我要稿的話,我想也不知道要到什麼時候我才能把它給寫完:-(,即便是現在,由於很久沒有複習這些資料,很多的東西也沒能寫進去,如果大家覺得意猶未盡的話,可以直接到newsgroup中找到該thread,裡面有著完整的討論內容。

[1] 《Object-Oriented Software Construction》 Chapter 11:Design by Contract: building reliable software,國內有該書的影印版出售。
[2] 《Design Patterns: Elements Of Reusable Object-Oriented Software》,國內有該書的中文翻譯版售
[3] Extreme Programming,一種輕量級的軟體開發方式,注重開發中的靈活性,測試及其他……可以從下面網站上得到有關它的更多資訊:www.extremeprogramming.org
[4] 可以參見於Herb Sutter和Jim Hyslop發表的Conversations: Virtually Yours一文,在CUJ站點上可以找到這篇文章,此外,在csdn中也有過它的譯文。