Performanced C++ 經驗規則(5):再談過載、覆蓋和隱藏
在C++中,無論在類作用域內還是外,兩個(或多個)同名的函式,可能且僅可能是以下三種關係:過載(Overload)、覆蓋(Override)和隱藏(Hide),因為同名,區分這些關係則是根據引數是否相同、是否帶有const成員函式性質、是否有virtual關鍵字修飾以及是否在同一作用域來判斷。在第四條中,我們曾提到了一些關於過載、覆蓋的概念,但只是一帶而過,也沒有提到隱藏,這一篇我們將詳細討論。
1、首先說的是過載,有一個前提必須要弄清楚的是,如果不在類作用域內進行討論,兩個(或多個)同名函式之間的關係只可能是過載或隱藏,這裡先說過載。考慮以下事實:
int foo(char c){...}
void foo(int x){...}
這兩個函式之間的關係是過載(overload),即相同函式名但引數不同,並注意返回型別是否相同並不會對過載產生任何影響。
也就是說,如果僅僅是返回型別不相同,而函式名和引數都完全相同的兩個函式,不能構成過載,編譯器會告知”ambiguous”(二義性)等詞以表達其不滿:
//Can't be compiled!
int fooo(char c){...}
void fooo(char c){...}
char c = 'A';
fooo(c); // Which one? ambiguous
在第四條中,已經講述過,過載是編譯期繫結的靜態行為,不是真正的多型性,那麼,編譯器是根據什麼來進行靜態繫結呢?又是如何確定兩個(或多個)函式之間的關係是過載呢?
有以下判定依據:
(1)相同的範圍:即作用域,這裡指在同一個類中,或同一個名字空間,即C++的函式過載不支援跨越作用域進行(讀者可再次對比Java在這問題上的神奇處理,既上次Java給我們提供了未卜先知的動態繫結能力後,Java超一流的意識和大局觀再次給Java程式設計師提供了跨類過載的能力,如有興趣可詳細閱讀《Thinking in Java》的相關章節,其實對於學好C++來講,去學一下Java是很有幫助的,它會告訴你,同樣或類似的問題,為什麼Java要做這樣的改進),這也是區別過載和隱藏的最重要依據。
關於“C++不能支援跨類過載”,稍後筆者會給出程式碼來例證這一點。
(2)函式名字相同(基本前提)
(3)函式引數不同(基本前提,否則在同一作用域內有兩個或多個同名同參數的函式,將產生ambiguous,另外注意,對於成員函式,是否是const成員函式,即函式宣告之後是否帶有const標誌, 可理解為“引數不同“),第(2)和第(3)點統稱“函式特徵標”不同
(4)virtual關鍵字可有可無不產生影響(因為第(1)點已經指出,這是在同一個類中)
即“相同的範圍,特徵標不同(當然同名是肯定的),發生過載“。
2、覆蓋(override),真正的多型行為,通過虛擬函式來實現,所以,編譯器根據以下依據來進行判定兩個(注意只可能是兩個,即使在繼承鏈中,也只是最近兩個為一組)函式之間的關係是覆蓋:
(1)不同的範圍:即使用域,兩個函式分別位於基類和派生類中
(2)函式名字相同(基本前提)
(3)函式引數也相同(基本前提),第(2)和第(3)點統稱“函式特徵標”相同
(4)基類函式必須用virtual關鍵字修飾
即“不同的範圍,特徵標相同,且基類有virtual宣告,發生覆蓋“。
3、隱藏(Hide),即:
(1)如果派生類函式與基類函式同名,但引數不同(特徵標不同),此時,無論是否有virtual關鍵字,基類的所有同名函式都將被隱藏,而不會過載,因為不在同一個類中;
(2)如果派生類函式與基類函式同名,且引數也相同(特徵標相同),但基類函式沒有用virtual關鍵字宣告,則基類的所有同名函式都將被隱藏,而不會覆蓋,因為沒有宣告為虛擬函式。
即“不同的範圍,特徵標不同(當然同名是肯定的),發生隱藏”,或“不同的範圍,特徵標相同,但基類沒有virtual宣告,發生隱藏“。
可見有兩種產生隱藏的情況,分別對應不能滿足過載和覆蓋條件的情況。
另外必須要注意的是,在類外討論時,也可能發生隱藏,如在名字空間中,如下述程式碼所示:
#include <iostream>
using namespace std;
void foo(void) { cout << "global foo()" << endl; }
int foo(int x) { cout << "global foo(int)" << endl; return x; }
namespace a
{
void foo(void) { cout << "a::foo()" << endl; }
void callFoo(void)
{ foo();
// foo(10); Can't be compiled! }
}
int main(int argc, char** argv)
{
foo();
a::callFoo();
return 0;
}
輸出結果:
global foo()
a::foo()
注意,名字空間a中的foo隱藏了其它作用域(這裡是全域性作用域)中的所有foo名稱,foo(10)不能通過編譯,因為全域性作用域中的int foo(int)版本也已經被a::foo()隱藏了,除非使用::foo(10)顯式進行呼叫。
這也告訴我們,無論何時,都使用完整名稱修飾(作用域解析符呼叫函式,或指標、物件呼叫成員函式)是一種好的程式設計習慣。
好了,上面零零散散說了太多理論的東西,我們需要一段實際的程式碼,來驗證上述所有的結論:
#include <iostream>
using namespace std;
class Other
{
void* p;
};
class Base
{
public:
int iBase;
Base():iBase(10){}
virtual void f(int x = 20){ cout << "Base::f()--" << x << endl; }
virtual void g(float f) { cout << "Base::g(float)--" << f << endl; }
void g(Other& o) { cout << "Base::g(Other&)" << endl; }
void g(Other& o) const { cout << "Base::g(Other&) const" << endl;}
};
class Derived : public Base
{
public:
int iDerived;
Derived():iDerived(100){}
void f(int x = 200){ cout << "Derived::f()--" << x << endl; }
virtual void g(int x) { cout << "Derived::g(int)--" << x << endl; }
};
int main(int argc, char** argv)
{
Base* pBase = NULL;
Derived* pDerived = NULL;
Base b;
Derived d;
pBase = &b;
pDerived = &d;
Base* pBD = &d;
const Base* pC = &d;
const Base* const pCCP = &d;
Base* const pCP = &d;
int x = 5;
Other o;
float f = 3.1415926;
b.f();
pBase->f();
d.f();
pDerived->f();
pBD->f();
b.g(x);
b.g(o);
d.g(x);
d.g(f);
// Can't be compiled!
// d.g(o);
pBD->g(x);
pBD->g(f);
pC->g(o);
pCCP->g(o);
pCP->g(o);
return 0;
}
在筆者Ubuntu 12.04 + gcc 4.6.3執行結果:
Base::f()--20 //b.f(),通過物件呼叫,無虛特性,靜態繫結
Base::f()--20 //基類指標指向基類物件,雖然是動態繫結,但沒有使用到覆蓋
Derived::f()--200 //d.f,通過物件呼叫,無虛特性,靜態繫結
Derived::f()--200 //子類指標指向子類物件,雖然是動態繫結,但沒有使用到覆蓋
Derived::f()--20 //基類指標指向子類物件,動態繫結,子類f()覆蓋基類版本。但函式引數預設值,是靜態聯編行為,pBD的型別是基類指標,所以使用了基類的引數預設值,注意此處!
Base::g(float)--5 //通過物件呼叫,int被提升為float
Base::g(Other&) //沒什麼問題,基類中三個g函式之間的關係是過載
Derived::g(int)--5 //沒什麼問題
Derived::g(int)--3 //注意基類的g(float)已經被隱藏!所以傳入的float引數呼叫的卻是子類的g(int)方法!
Base::g(float)--5 //注意!pBD是基類指標,雖然它指向了子類物件,但基類中的所有g函式版本它是可見的!所以pBD->g(5)呼叫到了g(float)!雖然產生了動態聯編也發生了隱藏,但子類物件的虛表中,仍可以找到g(float)的地址,即基類版本!
Base::g(float)--3.14159 //原理同上
//d.g(o)
//注意此處!再注意程式碼中被註釋了的一行,d.g(o)不能通過編譯,因為d是子類物件,在子類中,基類中定義的三個g函式版本都被隱藏了,編譯時不可見!不會過載
Base::g(Other&) const //pC是指向const物件的指標,將呼叫const版本的g函式
Base::g(Other&) const //pCCP是指向const物件的const指標,也呼叫const版本的g函式
Base::g(Other&) //pCP是指向非cosnt物件的const指標,由於不指向const物件,呼叫非const版本的g函式
上述結果,是否和預想的是否又有些出入呢?問題主要集中於結果的第5、12、13和15行。
第5行輸出結果證明:當函式引數有預設值,又發生多型行為時,函式引數預設值是靜態行為,在編譯時就已經確定,將使用基類版本的函式引數預設值而不是子類的。
而第12、13、15行輸出結果則說明,儘管已經證明我們之前說的隱藏是正確的(因為d.g(o)不可以通過編譯,確實發生了隱藏),但卻可以利用基類指標指向派生類物件後,來繞開這種限制!也就是說,編譯器根據引數匹配函式原型的時候,是在編譯時根據指標的型別,或物件的型別來確定,指標型別是基類,那麼基類中的g函式版本就是可見的;指標型別是子類,由於發生了隱藏,基類中的g函式版本就是不可見的。而到動態繫結時,基類指標指向了子類物件,在子類物件的虛擬函式表中,就可以找到基類中g虛擬函式的地址。
寫到這裡,不知道讀者是否已經明白,這些繞來繞去的關係。在實際程式碼運用中,可能並不會寫出含有這麼多“陷阱”的測試程式碼,我們只要弄清楚過載、覆蓋和隱藏的具體特徵,並頭腦清醒地知道,我現在需要的是哪一種功能(通常也不會需要隱藏),就能寫出清析的程式碼。上面的程式碼其實是一個糟糕的例子,因為在這個例子中,過載、覆蓋、隱藏並存,我們編寫程式碼,就是要儘可能防止這種含混不清的情況發生。
記住一個原則:每一個方法,功能和職責儘可能單一,否則,嘗試將它拆分成為多個方法。有興趣一起交流學習c/c++程式設計的小夥伴可以加一下小編主頁的交流群466572167,裡面有很多資源分享,可以幫助大家進步更快!