這一篇,我們討論C++中靜態和多型的關係。我們都知道,C++並不是一門“動態”語言,雖然它提供了同樣強大於其它動態語言的多型性,但很多時候,我們之所以選擇C++,看重中正是其“靜態”所帶來的High Performance。所謂靜態,通常是指,在程式執行的過程,是“靜止”不變,固定的(特別是記憶體地址),當然“多型”就是與之對立的概念。這一篇我們並不討論靜態(成員)變數或靜態(成員)函式有什麼作用,而是討論“靜態”的行為,對比“多型”。我們這裡所說的靜態,是指:compiler time,即編譯時繫結、早繫結、靜態聯編;而“多型”就是真正的runtime繫結、晚繫結、動態聯編。
很奇妙,這一組對立的概念,卻可以在C++中和平共存,時而協同工作。
老規矩,還是一小段程式碼提出問題,當一個虛成員函式(多型性)在其子類中被宣告為靜態成員函式時(或相反過來),會發生什麼?
1、當虛擬函式遭遇靜態函式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
#include <iostream> using namespace std; class Base { public: virtual void foo(void){ cout << "Base::foo()" << endl; } }; class Derived : public Base { public: void foo(void){ cout << "Derived::foo()" << endl; } } ; class DerivedAgain : public Derived { public: static void foo(void){ cout << "DerivedAgain::foo()"<< endl; } } ; int main(int argc, char** argv) { DerivedAgain da; Base* pB = &da; da.foo(); pB->foo(); return 0; } |
上述程式碼執行結果是什麼?等等,你確定上述程式碼能通過編譯?在筆者Ubuntu 12.04 + gcc 4.6.3的機器上,上述程式碼編譯不能通過。顯示如下資訊:
1 2 |
stawithvir.cpp:19:17: error: ‘static void DerivedAgain::foo()’ cannot be declared stawithvir.cpp:13:10: error: since ‘virtual void Derived::foo()’ declared in base class |
很明顯,編譯不能通過的原因,是在DerivedAgain類中將虛擬函式宣告為static,編譯器拒絕此“靜態”與“多型”的和平共處。此時理由很簡單,static成員函式,是類級共享的,不屬於任何物件,也不會傳入this指標,不能訪問非靜態成員;然而,虛擬函式的要求與此正相反,需要繫結物件(this指標),進而獲得虛表,然後進行呼叫。如此矛盾的行為,編譯器情何以堪,因為選擇報錯來表達其不滿。我們可以暫時記住結論:不能將虛擬函式宣告為靜態的。
接下來你可能會問,編譯都不能通過的東西,對錯不是明擺著的嗎?為什麼還要拿來討論,這是因為,在某些編譯器上(可以在VC6,VC2008等嘗試),該程式碼能編譯通過,並輸出結果,不可思議?不過這些編譯器同時也給出了一個警告(參與MSDN warning c4526),指出靜態函式不能用做虛擬函式進行呼叫。雖然通過了編譯,但思想與上述Gcc是一致的。
1 2 3 |
//輸出結果 DerivedAgain::foo() Derived::foo() |
da.foo()輸出DerivedAgain::foo()沒有疑問(通過物件呼叫方法,無論是否虛方法,本來就不會產生動態繫結,即無虛特性);而pB->foo()輸出Derived::foo()則需要解釋一下,因為pB是指標呼叫虛方法,產生“多型”,動態繫結時發現pB指向的物件型別為DerivedAgain,於是去查詢DerivedAgain物件虛表中foo()的地址,但此時發現DerivedAgain的虛表中foo()的地址其實是Derived::foo(),因為DerivedAgain中的foo已經被宣告為static,不會更新此函式在虛表中的地址(實際上,由於DerivedAgain沒有宣告任何新的虛擬函式,它物件的虛表同Derived物件是完全一樣的,如果有興趣,可以通過彙編檢視),所以輸出的是Derived::foo(),也從一個側面證明了:在繼承鏈中,使用最”新”的虛擬函式版本。
至此,這個問題已經解釋清楚,再次記住結論:靜態成員函式,不能同時也是虛擬函式。
2、過載(overload)並非真正的多型,其本質是靜態行為
筆者曾不止一次的看到,許多書籍、資料,在談到C++多型性的時候,經常把“過載”(overload)歸入多型行為中。這種說法看似也沒什麼不正確,實際上我認為十分不妥。雖然過載,通過區分特徵標的不同(注意,同函式名而引數不同、或同函式名但是否是const成員函式,都是過載依據),而使相同函式名的方法呼叫產生了不同的行為,確實體現了“多型”的思想,但過載的本質是靜態繫結,是編譯期就能確定呼叫哪個方法,而非動態繫結,所以不是真正的多型。所以,頭腦要清醒,即如果兩個(或多個)方法之間的關係是“過載”(overload),那麼就不會有真正的多型行為產生。
3、何時產生真正的多型?
討論過載之後,就要談到,何時產生真正的多型行為,即動態繫結呢?筆者歸納三個必要條件如下:
(1)方法是虛的;
(2)有覆蓋(override)產生;
(3)通過指標或引用呼叫相應的虛方法,而非通過物件呼叫;通過物件呼叫方法,無論方法是否是虛方法,均是靜態聯編行為。
條件(1)(2)很明顯,如果方法是虛的也沒有覆蓋,何來“多”的“態”?而條件(3)容易被新手忽視,因為通過物件呼叫,物件的型別已經確知,所以靜態繫結,不會再產生多型。而通過指標或引用呼叫相應虛方法,由於在編譯期不能確定指標或引用指向的具體型別,所以只能動態聯編,從而產生多型。
4、不正確的程式碼將阻止多型行為
好了,接下來我們看一小段程式碼,來自《C++ Primer Plus》:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class Base { public: virtual void foo(void) {...} ... }; class Derived : public Base { public: void foo(void) {...} ... }; //版本1 void show1(const Base& b) { b.foo(); } //版本2 void show2(Base b) { b.foo(); } int main(int argc, char** argv) { Derived d; show1(d); show2(d); return 0; } |
上述程式碼有什麼問題?我們看到,兩個版本的show函式唯一不同之處,就是版本1按引用傳遞物件,版本2按值傳遞物件。在main函式中,新建了一個Derived物件並傳給版本1函式,由於版本1中的引數b是引用型別,OK,沒有問題,b.foo()將按照b實際指向的物件呼叫,即可以正確呼叫Derived::foo();而版本2引數b是物件型別(b是Base(const Base&)拷貝構造建立的一個Base物件,自動向上的強制型別轉換使得基類拷貝建構函式可以引用一個子類物件),根據上述第3點,則b.foo()將按物件型別(Base)呼叫到Base::foo(),不產生多型行為。即,由於按值傳遞,在此處阻止了動態繫結,阻止了多型行為。
說到這裡的話,又是老生常談的問題,即除非必須要這樣做,否則不要按值方式傳遞引數,而應選擇指標或引用,關於這個問題,本系列後面還會再談。