核心原則
Item 32 的金句:Public Inheritance means "is-a" (公有繼承意味着“是一個”).
它的嚴格定義是:
如果類 D (Derived) 公有繼承自類 B (Base),那麼每一個類型為 D 的對象同時也是一個類型為 B 的對象。 任何需要 B 類型對象的地方(函數參數、指針等),如果你把 D 類型的對象傳進去,程序都必須表現正常。
這在軟件工程領域對應着著名的 里氏替換原則 (Liskov Substitution Principle, LSP)。
經典陷阱 1:企鵝與鳥 (The Bird-Penguin Problem)
這是最容易犯錯的邏輯陷阱:生活常識 vs. 編程語義。
直覺邏輯:
- 企鵝是鳥。
- 鳥會飛。
- 所以,企鵝應該繼承自鳥,並擁有“飛”的功能。
錯誤代碼:
class Bird {
public:
virtual void fly(); // 鳥會飛
};
class Penguin : public Bird {
// 繼承了 fly() 接口
};
void migrate(Bird& b) {
b.fly(); // 所有的鳥都能飛?
}
Penguin p;
migrate(p); // 邏輯錯誤:企鵝飛起來了!或者你在運行時報錯?
問題分析: 在 C++ 中,公有繼承主張的是:所有對 Base 有效的事情,對 Derived 也必須有效。 如果 Bird 定義了 fly(),就是在向全世界承諾:“所有的鳥都會飛”。既然企鵝不會飛,那麼企鵝就不是(C++ 語義下的)鳥。
修正方案: 區分“行為”,而不是單純區分“物種”。
class Bird {
// Bird 的通用屬性
};
class FlyingBird : public Bird {
public:
virtual void fly();
};
class Penguin : public Bird {
// 企鵝是鳥,但不是 FlyingBird
};
這樣,如果函數參數要求 FlyingBird,你傳 Penguin 進去,編譯器就會直接報錯(Compile-time error),這比運行時錯誤好得多。
經典陷阱 2:矩形與正方形 (The Rectangle-Square Problem)
這個例子更加隱蔽,經常出現在數學背景較強的程序員代碼中。
直覺邏輯: 幾何學上,正方形(Square)是一個特殊的矩形(Rectangle)。所以 Square 應該公有繼承自 Rectangle。
錯誤代碼:
class Rectangle {
public:
virtual void setHeight(int newH);
virtual void setWidth(int newW);
virtual int height() const;
virtual int width() const;
};
void makeBigger(Rectangle& r) {
int oldHeight = r.height();
// 這裏的邏輯對於矩形是完全正確的:
// 改變寬度不應該影響高度。
r.setWidth(r.width() + 10);
assert(r.height() == oldHeight); // 檢查高度是否未變
}
class Square : public Rectangle {
// 正方形的特性:寬必須等於高
// 所以我們需要重寫 setWidth 和 setHeight
// 無論改哪個,都要同時修改兩邊
};
Square s;
s.setWidth(10); // 假設此時 h=10, w=10
makeBigger(s); // 災難發生!
災難分析:
makeBigger接收一個Rectangle引用。- 它調用
setWidth,心裏默認Rectangle的高度不會變(這是矩形的不變性 Invariant)。 - 但如果你傳進去的是
Square,Square::setWidth為了維持正方形的特性,被迫同時也修改了高度。 assert失敗,程序崩潰。
結論: 雖然在幾何學上正方形是矩形,但在 C++ 的公有繼承語義下,正方形並不是矩形。 因為矩形允許寬和高獨立變化,而正方形不允許。它們的行為契約不同。
如何判斷“Is-a”是否成立?
Scott Meyers 建議我們在設計繼承體系時,必須通過以下測試:
“Is-a” 關係必須滿足:Derived class 必須能無條件地替代 Base class,且不破壞程序的正確性。
如果在你的設計中:
- 代碼需要進行類型檢查 (Type Checking) 才能正常工作(例如
if (dynamic_cast<Penguin*>(&bird))),説明違反了 Item 32。 - 派生類屏蔽了基類的某些功能(比如把基類的
fly()在子類中定義為報錯或空操作),説明這不是一個完美的is-a關係。
怎麼判斷你是對的?(靈魂三問)
寫代碼的時候,當你想要讓 B 繼承 A 時,問自己三個問題:
- A 的所有函數,B 都能調用嗎?
- A 調用的結果是正常的,B 調用的結果也必須是正常的嗎?
- 有沒有任何一個場景,用 A 沒問題,換成 B 就出 Bug?
如果有任何一個答案是“不對勁”,那就千萬別用公有繼承。
其他關係 (如果不是 Is-a 怎麼辦?)
如果兩個類很像,但又不滿足完全的 is-a,你應該考慮:
- Has-a (有一個): 組合 (Composition)。例如,正方形“有一個”矩形作為實現細節,或者正方形和矩形都繼承自一個更抽象的
Shape類。這對應 Item 38。 - Is-implemented-in-terms-of (根據...實現): 私有繼承 (Private Inheritance)。這對應 Item 39。
總結
- Public 繼承 = Is-a (是一個)。
- 這不僅是分類學上的歸屬,更是行為上的承諾。
- 基類做出的所有承諾(接口行為、不變性),派生類都必須遵守。
- 如果發現派生類在某些行為上必須違背基類的邏輯,那麼請斷開繼承關係。