# [21] 繼承 — 適當的繼承和可置換性
## FAQs in section [21]:
* [21.1] 我應該隱藏基類的公有成員函數嗎?
* [21.2] `Derived* —> Base*` 可以很好地工作;?為什么?`Derived** —> Base**`?不行?
* [21.3] parking-lot-of-Car(停車場)是一種?parking-lot-of-Vehicle(交通工具停泊場)嗎?
* [21.4] `Derived`數組是一種?`Base`數組嗎?
* [21.5] 派生類數組(array-of-`Derived`)“不是一種”基類數組(array-of-`Base`)是否意味著數組不好?
* [21.6] `Circle`(圓)是一種 `Ellipse`(橢圓)嗎?
* [21.7] 對于“圓是/不是一種橢圓”這個兩難問題,有其它說法嗎?
* [21.8] 但我是數學博士,我相信圓是一種橢圓!這是否意味著Marshall?Cline是傻瓜?或者C++是傻瓜?或者OO是傻瓜?
* [21.9] 也許橢圓應該從圓繼承?
* [21.10] 但我的問題與圓和橢圓無關,這種無聊的例子對我有什么好處?
## 21.1 我應該隱藏基類的公有成員函數嗎?
不要,不要,不要這樣做。永遠不要!
試圖隱藏(消除、廢除、私有化)繼承而來的公有成員函數是非常常見的設計錯誤。通常這產生于漿糊腦袋。
(注意:?本?FAQ?的論述僅與公有繼承(`public` inheritance)有關;?私有和保護繼承并不相同)
## 21.2 `Derived* —> Base*` 可以很好地工作;?為什么?`Derived** —> Base**`?不行?
由于`Derived`對象是一種`Base`對象,C++允許`Derived*`?轉換成?`Base*`。然而,將?`Derived**`轉換成 `Base**` 將產生錯誤。盡管這個錯誤不是顯而易見的,這未嘗不是件好事。例如,如果你能夠將`Car**`轉換成?`Vehicle**`(譯注:Vehicle意為交通工具),并且如果你能同樣的將`NuclearSubmarine**`(譯注:NuclearSubmarine意為核潛艇)?轉換成`Vehicle**`,那么你可能給這兩個指針賦值,并最終使?`Car*`?指針指向?`NuclearSubmarine`:
```
?class?Vehicle?{
?public:
???virtual?~Vehicle()?{?}
???virtual?void?startEngine()?=?0;
?};
?class?Car?:?public?Vehicle?{
?public:
???virtual?void?startEngine();
???virtual?void?openGasCap();
?};
?class?NuclearSubmarine?:?public?Vehicle?{
?public:
???virtual?void?startEngine();
???virtual?void?fireNuclearMissle();
?};
?int?main()
?{
???Car???car;
???Car*??carPtr?=?&car;
???Car**?carPtrPtr?=?&carPtr;
???Vehicle**?vehiclePtrPtr?=?carPtrPtr;??//?這在C++中是一個錯誤
???NuclearSubmarine??sub;
???NuclearSubmarine*?subPtr?=?⊂
???*vehiclePtrPtr?=?subPtr;
???//?最后這行將導致carPtr指向?sub?!
???carPtr->openGasCap();??//?這將調用 fireNuclearMissle()! (譯注:也就是發射核彈)
?}
```
換句話說,如果從`Derived**`?到`Base**`的轉換是合法的,那么`Base**`將可能被解除引用(易變的?`Base*`),并且?`Base*`可能被指向不同的派生類對象,這將導致嚴重的國家安全問題(天知道如果你調用了`NuclearSubmarine`(核潛艇)對象的?`openGasCap()`成員函數會發生什么!!而你卻認為這是一個`Car`對象!!——試一下以上的代碼,看看會發生什么——大多數的編譯器會調用`NuclearSubmarine::fireNuclearMissle()`!
(注意:?本?FAQ?的論述僅與公有繼承(`public` inheritance)有關;?私有和保護繼承并不相同)
## 21.3 parking-lot-of-Car(停車場)是一種?parking-lot-of-Vehicle(交通工具停泊場)嗎?
不。
我知道這聽起來很奇怪,但這是事實。你可以將這看作為以上?FAQ的直接結論,或者你可以這樣來理解:如果這個“是一種”關系成立的話,那么就可以將?parking-lot-of-Vehicle 類型的指針指向一個?parking-lot-of-Car。但是,parking-lot-of-Vehicle 有 `addNewVehicleToParkingLot(Vehicle&)`成員函數用來向停泊場添加任何 `Vehicle`(交通工具)對象。這樣將允許你在?parking-lot-of-Car(停車場)停泊一個`NuclearSubmarine`(核潛艇)。當然,當某人認為從?parking-lot-of-Car 刪除一個`Car`對象,而實際是一個`NuclearSubmarine`時,他會非常驚訝。
用另一種方法闡述這個事實:一種事物的容器不是一種任何事物的容器。也許很難接受,但這是事實。
你可以不喜歡它,但必須接受它。
我們在OO/C++訓練課程使用的最后一個例子:“一袋蘋果不是一袋水果”。如果一袋蘋果能夠被傳遞給一袋水果的話,就可以把香蕉放入袋中,即使它被認為里面只能放蘋果!
(注意:?本?FAQ?的論述僅與公有繼承(`public` inheritance)有關;?私有和保護繼承并不相同)
## 21.4 `Derived`數組是一種?`Base`數組嗎?
不。
這是以上FAQ的結論。不幸的是它會把你帶入困境,考慮一下這個:
```
?class?Base?{
?public:
???virtual?void?f();?????????????//?1
?};
?class?Derived?:?public?Base?{
?public:
???//?...
?private:
???int?i_;???????????????????????//?2
?};
?void?userCode(Base*?arrayOfBase)
?{
???arrayOfBase[1].f();???????????//?3
?}
?int?main()
?{
???Derived?arrayOfDerived[10];???//?4
???userCode(arrayOfDerived);?????//?5
?}
```
編譯器會認為這是完美的類型安全。編號?5的這一行將?`Derived*`?轉換為?`Base*`。但實際上這樣做是可怕的:由于?`Derived`比`Base`?大,在編號3的這一行的指針運算是錯誤的:當編譯器計算?`arrayOfBase1]`的地址時使用?`sizeof(Base)`,而數組其實是一個`Derived`數組,這意味著在編號3的這一行的所計算的地址(以及之后的成員函數 `f()` 的調用)并不在任何對象的起始位置!而在`Derived`對象的中間。假設你的編譯器使用通常的方法尋找[虛函數,那么將導致第一個`Derived`對象的?`int?i_`?被重新解釋,將它看作指向虛函數表的指針,跟隨著這個“指針”(意味著我們正在訪問一個隨機的內存位置),并將內存中那個位置的前幾個字節解釋為?C++成員函數的地址,然后將它們(隨機的內存地址)裝載到指令寄存器并開始從那個內存區產生機器指令。發生這樣情況的幾率相當高。
根本問題是?C++無法區別指向事物的指針和指向事物數組的指針。自然的,C++是從C繼承了這一特征。
注意:如果我們使用類似數組(array-like)的類(例如,標準庫中的`std::vector<Derived>`)來代替原始的數組,這個問題將會被作為編譯時錯誤找出而不是運行時的災難。
(注意:?本?FAQ?的論述僅與公有繼承(`public` inheritance)有關;?私有和保護繼承并不相同)
## 21.5 派生類數組(array-of-`Derived)“不是一種”基類數組(array-of-`Base)是否意味著數組不好?
是的,數組很差勁。(開個玩笑)。
真誠的來說,數組和指針非常接近,并且指針很難處理。但是如果我們完全掌握了為什么從設計角度來看,以上FAQ所說的會是一個問題(例如,如果你真的知道為什么事物的容器不是一種任何事物的容器),并且你認為將維護你的代碼的其他人都完全掌握這些OO的設計事實的話,那么你可以自由使用數組。但是如果你象大多數人一樣的話,你應該使用諸如標準庫的`std::vector<T>`這樣的模板容器類而不是原始的數組。
(注意:?本?FAQ?的論述僅與公有繼承(`public` inheritance)有關;?私有和保護繼承并不相同)
## 21.6 `Circle`(圓)是一種 `Ellipse`(橢圓)嗎?
如果橢圓允許改變圓率,則不是。
例如,假設橢圓有一個`setSize(x,y)`成員函數,并且這個成員函數允許橢圓的?`width()`是`x`,`height()`?是`y`。在這種情況下,圓無法是一種橢圓。很簡單,如果橢圓能做某些圓不能做的事,則圓不是一種橢圓。
據此推出圓和橢圓的兩種(合法的)關系:
* 使圓類和橢圓類完全無關
* 使圓和橢圓都從一個基類派生,該基類是“不能執行不對稱`setSize()`運算的橢圓”
在第一種情況下,橢圓可以從`AsymmetricShape`(不對稱圖形)類派生,`setSize(x,y)`可以在`AsymmetricShape`類中聲明。而圓可以從有`setSize(size)`成員函數的`SymmetricShape`(對稱圖形)類派生。
在第二種情況下,`Oval`(卵形)類可以只有`setSize(size)`來同時設置 `width()`和`height()`的大小。橢圓和圓都繼承自`Oval`。橢圓(但不是圓)可以增加`setSize(x,y)`運算(但如果`setSize()`成員函數名稱重復,當心隱藏規則)
(注意:?本?FAQ?的論述僅與公有繼承(`public` inheritance)有關;?私有和保護繼承并不相同)
(注意:?`setSize(x,y)`并不是神圣的。依賴于你的目標,防止用戶改變橢圓的尺寸也是可以的。在某些情況下,橢圓沒有`setSize(x,y)`方法是有效的設計選擇。然而這個系列的討論是當你想為一個已存在的類建立一個派生類并且基類含有一個“無法接受”的方法時,該如何做。當然理想情形是在基類不存在時就發現這個問題。但生活并不總是理想的……)
## 21.7 對于“圓是/不是一種橢圓”這個兩難問題,有其它說法嗎?
如果你主張所有橢圓是可以被壓成不對稱的,并且你主張圓是一種橢圓,并且你主張圓不能被壓成不對稱的。無疑你必須調整(實際上是撤回)你的主張之一。由此,你要么去掉`Ellipse::setSize(x,y)`,去掉圓和橢圓的繼承關系,要么承認你的 `Circle`s(圓)不必是正圓。
這里有兩個OO/C++編程新手通常會陷入的陷阱。他們會試圖用代碼的技巧來彌補設計的缺陷(他們會重定義`Circle::setSize(x,y)`來拋出異常,調用`abort()`,取兩個參數的平均數,或者什么都不做)。不幸的是,由于用戶期望?`width()?==?x`并且?`height()?==?y`,所以這些技巧會使用戶驚訝。而讓用戶驚訝是不允許的。
如果保持“圓是一種橢圓”的繼承關系對你來說非常重要,那么你只能削弱橢圓的`setSize(x,y)`所做的承諾。例如,你可以改變承諾為,“該城圓函數可以把?`width()`設置為`x`并且/或把 `height()`設置為`y`,或不做什么事情”。不幸的是由于用戶沒有任何意義的行為可以倚靠,這樣會沖淡契約。因此整個層次都變得沒有價值(如果某人問你到對象能做什么,而你只能聳聳肩膀的話,你很難說服他取使用這個對象)
(注意:?本?FAQ?的論述僅與公有繼承(`public` inheritance)有關;?私有和保護繼承并不相同)
(注意:?`setSize(x,y)`并不是神圣的。依賴于你的目標,防止用戶改變橢圓的尺寸也是可以的。在某些情況下,橢圓沒有`setSize(x,y)`方法是有效的設計選擇。然而這個系列的討論是當你想為一個已存在的類建立一個派生類并且基類含有一個“無法接受”的方法時,該如何做。當然理想情形是在基類不存在時就發現這個問題。但生活并不總是理想的……)
## 21.8 但我是數學博士,我相信圓是一種橢圓!這是否意味著Marshall?Cline是傻瓜?或者C++是傻瓜?或者OO是傻瓜?
事實上,這并不意味著這些。而是意味著你的直覺是錯誤的。
看,我收到并回復了大量的關于這個主題的熱情的e-mail。我已經給各地上千個軟件專家講授了數百次。我知道它違背了你的直覺。但相信我,你的直覺是錯誤的。
真正的問題是你的直覺中的“是一種(kind?of)”的概念不符合OO中的適當的繼承(學術上稱為“子類型(subtyping)”)概念。派生類對象最起碼必須是可以取代基類對象的。在圓/橢圓的情況下,`setSize(x,y)`成員函數違背了這個可置換性。
你有三個選擇:[1]從`Ellipse`(橢圓)類中刪除?`setSize(x,y)`成員函數(從而廢棄調用`setSize(x,y)`成員函數的已存在代碼),[2]允許`Circle`(圓)的高和寬不同(一個不對稱的圓),或者[3]去掉繼承關系。抱歉,但沒有其他選擇。有人提過另一個選項,讓圓和橢圓都從第三個通用基類派生,但這只不過是以上選項[3]的變種罷了。
換一種說法就是,你要么使基類弱一些(在這里就是說你不能為橢圓的高和寬設置不同的值),要么使派生類強一些(在這里就是使圓同時具有對稱的和不對稱的能力)。當這些都無法令人滿意(就如圓/橢圓例子),通常就簡單的消除繼承關系。如果繼承關系必須存在,你只能從基類中刪除變形成員函數(`setHeight(y)`,`setWidth(x),`?和`setSize(x,y)`)
(注意:?本?FAQ?的論述僅與公有繼承(`public` inheritance)有關;?私有和保護繼承并不相同)
(注意:?`setSize(x,y)`并不是神圣的。依賴于你的目標,防止用戶改變橢圓的尺寸也是可以的。在某些情況下,橢圓沒有`setSize(x,y)`方法是有效的設計選擇。然而這個系列的討論是當你想為一個已存在的類建立一個派生類并且基類含有一個“無法接受”的方法時,該如何做。當然理想情形是在基類不存在時就發現這個問題。但生活并不總是理想的……)
## 21.9 也許橢圓應該從圓繼承?
如果圓是基類,橢圓是派生類的話,那么你會面臨許多新的問題。例如,假設圓有`radius()`方法(譯注:設置半徑的成員函數)。那么橢圓也會有`radius()`方法,但那沒有意義:一個橢圓(可能不對稱)的半徑是什么意思?
如果你克服這個障礙(也就是使得`Ellipse::radius()`返回主軸和輔軸的平均值或其它辦法),那么`radius()`和 `area()`(譯注:得到面積的成員函數)之間的關聯就會有問題。比如,假設圓有`area()`方法返回的是3.14159乘以`radius()`返回值的平方。而`Ellipse::area()`將不會返回橢圓的真實面積,否則你必須記住讓`radius()`返回符合上述公式的某個值。
即使你克服了這個問題(也就是使得`Ellipse::radius()`返回了橢圓的面積除以pi的平方根),你還要應付`circumference()`方法(譯注:計算周長的成員函數)。比如,假設圓有`circumference()`方法返回2乘以pi乘以`radius()`的返回值。現在你的麻煩是:對于橢圓沒有辦法兩碗水端平了:橢圓類不得不在面積,或者周長,或者兩者的計算上撒謊。(譯注:對于橢圓,面積和周長的計算無法同時得到正確答案,因為它們都使用了`radius()`的返回值,而它們對于`radius()`的返回值的要求卻不相同,`radius()`無法同時滿足它們的需要)
底線:只要派生類遵守基類的承諾,你就可以使用繼承。而不能僅僅因為你感覺上象繼承或僅僅因為你想使得代碼被重用就使用繼承。只有在(a)派生類的方法能遵守基類所做的所有承諾,并且(b)用戶不會被你搞糊涂,并且(c)使用繼承能明顯獲得實在的時間上的,金錢上的或風險上的改進時,才應該使用繼承。
## 21.10 但我的問題與圓和橢圓無關,這種無聊的例子對我有什么好處?
啊,有點小誤會。你認為圓/橢圓例子是無聊的,但實際上,你的問題和它是同性質的。
我不在意你的繼承問題是什么,但所有(是的,所有)不良的繼承都可以歸結為“圓不是一種橢圓”的例子。
這就是為什么:不良的繼承總有一個有額外能力(經常是一個或兩個額外的成員函數;有時是一個或多個成員函數給出的承諾)的基類,而派生類卻無法滿足它。你要么使基類弱一些,派生類強一些,要么消除繼承關系。我見過很多很多很多不良的繼承方案,相信我,它們都可以歸結為圓/橢圓的例子。
因此,如果你真的理解了圓/橢圓的例子,你就能找出所有的不良繼承。如果你沒有理解圓/橢圓問題,那么你很可能犯一些嚴重的并且昂貴的繼承錯誤。
令人憂傷,但是真的。
(注意:?本?FAQ?的論述僅與公有繼承(`public` inheritance)有關;?私有和保護繼承并不相同)
- C++ FAQ Lite
- [1] 復制許可
- [2] 在線站點分發本文檔
- [3] C++-FAQ-Book 與 C++-FAQ-Lite
- [6] 綜述
- [7] 類和對象
- [8] 引用
- [9] 內聯函數
- [10] 構造函數
- [11] 析構函數
- [12] 賦值算符
- [13] 運算符重載
- [14] 友元
- [15] 通過 &lt;iostream&gt; 和 &lt;cstdio&gt;輸入/輸出
- [16] 自由存儲(Freestore)管理
- [17] 異常和錯誤處理
- [18] const正確性
- [19] 繼承 — 基礎
- [20] 繼承 — 虛函數
- [21] 繼承 — 適當的繼承和可置換性
- [22] 繼承 — 抽象基類(ABCs)
- [23] 繼承 — 你所不知道的
- [24] 繼承 — 私有繼承和保護繼承
- [27] 編碼規范
- [28] 學習OO/C++
- [31] 引用與值的語義
- [32] 如何混合C和C++編程
- [33] 成員函數指針
- [35] 模板 ?
- [36] 序列化與反序列化
- [37] 類庫