威廉 德門特在他的著作《幾人留守幾人眠》中講述了這樣一個故事:他在課堂上嘗試讓他的學生記住課程中最重要的那一部分。他和他的學生講,據說普通的英國學生僅僅能記得黑斯廷斯戰役發生于1066年。德門特強調,如果有一個學生只記住了一點點,他(她)記住的便是1066這個年號。德門特繼續講,對于他的課堂上的學生,只有幾條核心的信息,其中還包括一條有趣的結論——“安眠藥最終會導致失眠”。他請求他的學生們一定要記住這些核心信息,即使把課堂中討論的所有其他的內容都忘光也可以,整個學期他都為學生反復重復這些核心信息。
在學期末,期末考試的最后一道題是“請寫下這一學期中讓你銘記一生的一件東西。”當德門特閱卷的時候,差點兒沒昏過去。幾乎所有的學生不約而同地寫下了“1066”。
因此,我“誠惶誠恐”地向各位講述C++面向對象編程中最為重要(沒有之一)的一條原則:公共繼承意味著“A是一個B”關系。這條原則一定要深深的印在腦子里。
如果你編寫了B類(Base,基類),并編寫了由其派生出的D類(Derived,派生類),那么你就告訴了C++編譯器(以及代碼的讀者),每一個D類型的對象同時也是B類型的,但是反過來不成立。你要表達的是:B表示比D更加一般化的內容,而D則表示比B更加具體化的內容。另外我們還強調如果一個B類型的對象可以在某處使用時,那么D類型的對象一定可以在此使用。這是因為D類型的每個對象一定是B類型的。反之,如果某一刻你需要一個D類型的對象,那么一個B類型的對象則不一定能滿足要求:每個D都是一個B,但反之不然。
C++嚴格按上述方式解釋公共繼承。請參見下面的示例:
class Person {...};
class Student: public Person {...};
我們從生活的經驗中可以得知:每個學生都是一個人,但是并不是每個人都是學生。上面的代碼精確的體現了這一層次結構。我們期望“人”的每一條屬性對“學生”都適用(比如人有出生日期,學生也有)。但是對學生能成立的屬性對于一般的人來說并不一定成立(比如一個學生被某所大學錄取了,但不是每個人都會去上大學)。人的概念比學生的概念更加寬泛,而學生是一類特殊的人。
在C++領域中,一切需要使用Person類型參數(或指向Person的指針或引用)的函數同樣能夠接受Student對象(或指向Student的指針或引用):
void eat(const Person& p); // 人人都會吃飯
void study(const Student& s); // 只有學生會學習
Person p; // p是一個人
Student s; // s是一個學生
eat(p); // 正確,p是一個人
eat(s); // 正確, s是一個學生,
// 同時一個學生是一個人
study(s); // 正確
study(p); // 錯誤!p不一定是學生
這一點僅僅在公共繼承的情況下成立。只有在Student是由Person公共派生而來時,C++才會按剛才描述的情景運行。私有繼承將引入一個全新的議題(我們在條目39中討論)。受保護的繼承我至今也沒有弄明白,暫且不談。
公共繼承和“A是一個B”的等價性聽上去很簡單,但是有時你的直覺會誤導你。比如說,“企鵝是鳥”這是千真萬確的,同時,“鳥會飛”也是不爭的事實。如果我們在C++中如此幼稚地表述這一情景,那么我們將得到:
class Bird {
public:
virtual void fly(); // 鳥會飛
...
};
class Penguin:public Bird { // 企鵝是鳥
...
};
瞬間我們陷入泥潭,因為這一層次結構中,企鵝竟然會飛!這顯然是荒謬的。那么問題出在哪里呢?
這種情況下,我們成為了一種不精確的語言——英語的受害者。當我們說“鳥類能夠飛行”時,我們的意思并不是說所有的鳥類都會飛。在一般情況下,只有擁有飛行能力的鳥類才能夠飛。假如我們的語言更加精確些,我們就能認識到世界上還存在著一些不會飛的鳥類,我們也就能構建出下面的層次結構,這樣的機構才更加貼近真實世界:
class Bird {
... // 不聲明任何飛行函數
};
class FlyingBird: public Bird {
public:
virtual void fly();
...
};
class Penguin: public Bird {
... // 不聲明任何飛行函數
};
這一層次結構比原先設計的更加忠實于我們所了解的世界。
到目前為止,上文的飛禽問題尚未徹底明了,因為在一些軟件系統中,區分鳥類是否可以飛行這項工作是沒有意義的,如果你的程序主要是關于鳥類的喙和翅膀,而與飛行沒有什么關系,那么原先的2個類的層次結構就可以滿足要求了。這里也很清晰的反映出了這一哲理:凡事并不存在一勞永逸的解決方案。對軟件系統而言,最好的設計一定會考慮到這個系統是用來做什么的,無論是現在還是未來,如果你的程序對飛行的問題一無所知,并且也不準備去了解,那么忽略飛行特性的設計方案很可能就是完美的。事實上,這樣做要比將兩者區分開的設計方案更好些,因為你正在模擬的世界中很可能不會存在這一機制。
對于解決上文中的“白馬非馬”問題,還存在另外一個思考方法。那就是為企鵝重新定義fly函數,從而讓其產生一個運行時錯誤:
void error(const std::string& msg); // 定義的內容在其他地方
class Penguin: public Bird {
public:
virtual void fly() { error("嘗試讓一只企鵝飛行!”);}
...
};
一定要認識到:這樣做不一定能達到預期效果。因為這并不是說“企鵝不會飛”,而是說“企鵝會飛,但是在它嘗試飛行時出錯了”。這一點很重要。
如何找出兩者的區別呢?我們從捕獲錯誤的時機入手:“企鵝不會飛”的指令可以由編譯器做出保證,但是對于“企鵝在嘗試飛行時出錯”這一規則的違背只能夠在運行時捕獲。
為了表達這一約束,“企鵝不能飛行——句號”,你要確認企鵝對象一定沒有飛行函數定義:
class Bird
... // 不聲明任何飛行函數
};
class Penguin: public Bird {
... // 不聲明任何飛行函數
};
現在,如果你嘗試讓一個企鵝飛行,那么編譯器將對你的侵犯行為做出抗議:
Penguin p;
p.fly(); // 錯誤!
如果你適應了“產生運行時錯誤”的方法,上文代碼的行為則與你所了解的大相徑庭。使用上文中的方法,編譯器不會對p.fly的調用做出任何反應。條目18中解釋了好的接口設計能夠防止非法代碼得到編譯。因此你最好使用在編譯室拒絕企鵝嘗試飛行的設計方案,而不是僅僅在運行時捕獲錯誤。
可能你承認你的鳥類學知識并不豐富,但對于初級幾何你還是有信心的吧,讓我們拿長方形和正方形再舉一個例子,這沒有什么復雜的吧?
好,請回答一個簡單的問題:Square(正方形)類是否應該公共繼承自Rectangle(長方形)類?

你會說:“當然可以了!正方形就是一個長方形,這地球人都知道。但反過來就不成立了。”這一點至少在學校里是正確的,但我們都已經不是小學生了,請考慮下面的代碼:
class Rectangle {
public:
virtual void setHeight(int newHeight);
virtual void setWidth(int newWidth);
virtual int height() const; // 返回當前值
virtual int width() const;
...
};
void makeBigger(Rectangle& r) // 增加r面積的函數
{
int oldHeight = r.height();
r.setWidth(r.width() + 10); // 為r的寬增加10
assert(r.height() == oldHeight); // 斷言r的高不變
}
顯然地,這里的判斷永遠不會失敗,makeBigger僅僅改變了r的寬,它的高始終沒有改變。
現在請觀察下面的代碼,其中使用了公共繼承,從而使得正方形得到與長方形一致的待遇:
class Square: public Rectangle {...};
Square s;
...
assert(s.width() == s.height()); // 這對所有的正方形都成立
makeBigger(s); // 根據繼承關系,s是一個長方形
// 因此我們可以增加它的面積
assert(s.width() == s.height()); // 對所有的正方形也應成立
第二次判斷同樣不應該出錯,這也是十分明顯的。因為正方形的定義要求長寬值永遠相等。
但是現在我們又遇到了一個問題,我們如何協調下面的斷言呢?
l 在調用makeBigger之前,s的高與寬相等;
l 在makeBigger內部,s的寬值該變了,但是高沒有;
l 從makerBigger返回后,s的高與寬又相等了。(請注意:s是通過引用傳入makeBigger中的,因此makeBigger改變的是s本身,而不是s的副本。)
歡迎來到公共繼承的美妙世界。在這里,你在其他領域(包括數學)所積累的經驗也許不會按部就班地奏效。這種情況下最基本的問題就是:一些對長方形可用的屬性(它的長、寬可以分別修改),對于正方形而言并不適用(它的長、寬必須保持一致)。但是,公共繼承要求對基類成立的一切對于派生類同樣應該能夠成立——一切的一切!這種情況下,長方形和正方形(以及條目38中集合、線性表)的實例都會遇到問題。因此,使用公共繼承來構建它們之間的關系顯然是錯誤的。編譯器會允許你這樣做,但是就像我們剛剛所看到的一樣,我們無法確保代碼是否能夠按要求運行。這件事每一位程序員一定深有體會(往往比其他行業的人要深得多),因為許多情況下代碼能夠通過編譯并不意味著它能夠正常運行。
在我們投入面向對象程序設計的懷抱時,多年積累的編程經驗難道成為了我們的絆腳石嗎?這一點你無需顧慮。舊有的知識依然是寶貴的,只是既然你已經把繼承的概念添加進你大腦中的設計方案庫中,你就應該以全新的眼光來開拓自己的感官世界,從而使你在面對包含繼承的程序時不會迷失方向。假如有人向你展示了一個幾頁長的程序,你也可以從企鵝繼承自鳥類、正方形繼承自長方形這些示例所包含的理念中,找出同樣有趣的東西。這有可能是完成工作的正確途徑,只是這個可能性并不大。
類間的關系并不僅限于“A是一個B”關系。另外還存在兩個內部類關系,它們是:“A擁有一個B”、“A是以B的形式實現的”。這些關系將在條目38和條目39中講解。由于人們往往會將上面兩種關系其中之一錯誤地構造成“A是一個B”,因此隨之帶來的C++設計錯誤比比皆是,所以你應該確保對于這些關系之間的區別有著充分的理解,這樣你才能在C++中分別對這些關系做出最優秀的構造。
時刻牢記
l 公共繼承意味著“A是一個B”的關系。對于基類成立的一切都應該適用于派生類,因為派生類的對象就是一個基類對象。