物件導向三大支柱之二:繼承 (Inheritance) 與 多型 (Polymorphism)。 繼承讓我們在既有類別的基礎上擴充功能;多型讓同一介面對不同型別產生不同行為。 這兩個機制是 C++ 設計大型可擴充系統的核心,但也是初學者最容易誤用的地方。
學習目標
讀完本章,你應該能夠:
- 理解繼承的語法與三種存取修飾(public / protected / private 繼承)各自的語意
- 區分「is-a(是一種)」、「has-a(擁有)」、「implemented-in-terms-of(以…實作)」三種關係
- 掌握基礎類別與衍生類別之間建構子/解構子的呼叫順序與基礎類別初始化
- 區分「覆寫 (override)」與「隱藏 (hiding)」、「靜態綁定」與「動態綁定」的差異
- 解釋虛擬函式 (virtual function) 背後的 vtable / vptr 機制(能畫出記憶體圖)
- 說明為何需要虛擬解構子 (virtual destructor),並重現「忘記它」造成的 bug
- 使用
override與final關鍵字提高安全性 - 定義純虛擬函式 (pure virtual function)、抽象類別 (abstract class) 與 C++ 的介面慣例
- 使用
dynamic_cast與typeid進行執行期型別識別 (RTTI),並知道何時不該用 - 辨識並避免物件切割 (object slicing) 問題
- 處理多重繼承 (multiple inheritance) 與菱形繼承問題 (diamond problem),使用虛擬繼承
- 套用 C++ Core Guidelines 的「優先使用組合而非繼承」原則
0. 為什麼需要繼承與多型?
想像你要寫一個繪圖程式,需要處理圓形、長方形、三角形。沒有多型時,你會這樣寫:
void drawAll(const std::vector<Shape>& shapes) {
for (const auto& s : shapes) {
if (s.type == CIRCLE) drawCircle(s);
else if (s.type == RECTANGLE) drawRectangle(s);
else if (s.type == TRIANGLE) drawTriangle(s);
// 每新增一種圖形,這裡、計算面積、存檔…全部都要改一遍
}
}
這種「型別標籤 + 一長串 if/switch」的寫法有兩個致命缺點:
- 不可擴充 (Open-Closed 原則被破壞):每加一種圖形,所有
switch都要修改。 - 容易遺漏:新增圖形卻忘了改某個
switch,編譯不會報錯,執行期才爆炸。
有了繼承與多型後:
void drawAll(const std::vector<std::unique_ptr<Shape>>& shapes) {
for (const auto& s : shapes) {
s->draw(); // 每個物件自己知道怎麼畫,新增圖形不必改這裡
}
}
核心價值:繼承讓你重用與擴充既有程式碼;多型讓你對抽象介面寫程式,而不是對具體型別。
1. 繼承基礎
1.1 語法與術語
class Base { // 基礎類別 (base / parent / superclass)
public:
int x = 10;
void hello() { std::cout << "Base::hello()\n"; }
};
class Derived : public Base { // 衍生類別 (derived / child / subclass)
int y = 20;
};
Derived繼承了Base的所有非 private 成員與方法(private 成員仍存在,但不可直接存取)。- 繼承時的
public是繼承存取修飾符,決定基礎類別成員被繼承後的可見性。
1.2 三種繼承存取修飾符的語意
這張表是理解繼承存取控制的核心:
| 基礎類別成員 | public 繼承 |
protected 繼承 |
private 繼承 |
|---|---|---|---|
public |
public |
protected |
private |
protected |
protected |
protected |
private |
private |
不可存取 | 不可存取 | 不可存取 |
但比「可見性表格」更重要的是它們代表的關係:
| 繼承方式 | 代表關係 | 何時使用 | 範例 |
|---|---|---|---|
public |
is-a(是一種) | 99% 的情況 | Dog is-a Animal |
private |
implemented-in-terms-of(以…實作) | 少數,通常可用組合取代 | Stack 以 Vector 實作 |
protected |
罕見 | 幾乎不用 | — |
class Animal { public: void breathe(); };
class Dog : public Animal {}; // Dog 是一種 Animal → 外部可把 Dog 當 Animal 用
class Engine {};
class Car : private Engine {}; // Car 不是 Engine,只是「用」Engine 實作 → 外部看不到 Engine 介面
實務原則:絕大多數情況使用 public 繼承。如果你想寫
private/protected繼承,先問自己:「用組合(把它當成員)是不是更好?」答案通常是「對」。
1.3 is-a 關係與 Liskov 替換原則 (LSP)
public 繼承代表「is-a」,意思是任何接受基礎類別的地方都能安全地傳入衍生類別。這稱為 Liskov 替換原則。
void feed(Animal& a) { a.eat(); }
Dog d;
feed(d); // ✓ Dog is-a Animal,可以替換
反例(違反 is-a,是常見的設計錯誤):
class Rectangle {
public:
virtual void setWidth(int w);
virtual void setHeight(int h);
};
class Square : public Rectangle { // ✗ 數學上 Square is-a Rectangle,但行為上不是!
// setWidth 必須同時改 height,否則破壞「正方形」不變式
// → 任何「設定寬高後驗證面積」的程式碼會被 Square 弄壞
};
「正方形繼承長方形」是經典的 LSP 反例:概念上的 is-a 不等於行為上的 is-a。設計繼承時要看行為,不只看名詞。
1.4 建構子與解構子呼叫順序
class Animal {
public:
Animal() { std::cout << "Animal 建構\n"; }
~Animal() { std::cout << "Animal 解構\n"; }
};
class Dog : public Animal {
public:
Dog() { std::cout << "Dog 建構\n"; }
~Dog() { std::cout << "Dog 解構\n"; }
};
int main() {
Dog d;
}
預期輸出:
Animal 建構
Dog 建構
Dog 解構
Animal 解構
規則(務必記住這個對稱性):
- 建構:基礎類別 → 衍生類別(由內而外、由上往下)。因為衍生類別可能用到基礎類別的成員,所以基礎類別必須先準備好。
- 解構:衍生類別 → 基礎類別(由外而內、由下往上)。與建構順序相反,像堆疊一樣後進先出。
建構: [Animal] → [Dog] → [GuideDog] (往下蓋房子,先打地基)
解構: [GuideDog] → [Dog] → [Animal] (往上拆房子,先拆屋頂)
1.5 在衍生類別建構子中初始化基礎類別
基礎類別的初始化必須寫在初始化列表,因為它在衍生類別本體執行前就要完成。
class Animal {
public:
explicit Animal(const std::string& name) : name_(name) {}
protected:
std::string name_;
};
class Dog : public Animal {
public:
Dog(const std::string& name, const std::string& breed)
: Animal(name), // ← 呼叫基礎類別建構子(必須在最前面)
breed_(breed) {}
private:
std::string breed_;
};
如果你不顯式呼叫,編譯器會嘗試呼叫基礎類別的預設建構子;若基礎類別沒有預設建構子,就會編譯錯誤:
class Dog : public Animal {
public:
Dog() {} // ✗ 錯誤:Animal 沒有預設建構子,又沒在初始化列表呼叫 Animal(...)
};
2. 方法覆寫、隱藏,與靜態 vs 動態綁定
這是初學者最容易混淆的地方,請務必區分三個詞。
2.1 靜態綁定 — 沒有 virtual 的「隱藏」
class Base {
public:
void speak() { std::cout << "Base speaks\n"; }
};
class Derived : public Base {
public:
void speak() { std::cout << "Derived speaks\n"; } // 這是「隱藏」,不是覆寫
};
int main() {
Derived d;
Base* ptr = &d;
ptr->speak(); // 輸出 "Base speaks" — 依「指標型別」決定!
d.speak(); // 輸出 "Derived speaks"
}
預期輸出:
Base speaks
Derived speaks
沒有 virtual 時,編譯器在編譯期就依「指標/參考的靜態型別」決定呼叫哪個函式,這叫靜態綁定 (static binding / early binding)。
2.2 動態綁定 — 使用 virtual 的「覆寫」
class Base {
public:
virtual void speak() { std::cout << "Base speaks\n"; }
};
class Derived : public Base {
public:
void speak() override { std::cout << "Derived speaks\n"; }
};
int main() {
Derived d;
Base* ptr = &d;
ptr->speak(); // 輸出 "Derived speaks" — 依「物件實際型別」決定!
}
預期輸出:
Derived speaks
有了 virtual,呼叫哪個函式在執行期才依「物件的實際型別」決定,這叫動態綁定 (dynamic binding / late binding),是執行期多型的本質。
2.3 三者對照表
| 名詞 | 是否 virtual | 綁定時機 | 依據 | 結果 |
|---|---|---|---|---|
| 隱藏 (hiding) | 否 | 編譯期 | 指標/參考型別 | 呼叫基礎版本 |
| 覆寫 (override) | 是 | 執行期 | 物件實際型別 | 呼叫衍生版本(多型) |
| 多載 (overload) | — | 編譯期 | 參數型別 | 同名不同參數 |
⚠️ 注意:衍生類別只要定義了同名函式,就會隱藏基礎類別所有同名多載(連參數不同的也一起藏)。需要時用
using Base::speak;把它們帶回來。
3. 虛擬函式與 vtable / vptr 機制
3.1 編譯器如何實現動態綁定?
當類別含有虛擬函式時,編譯器會:
- 為每個類別產生一張虛擬函式表 (virtual table, vtable),裡面是該類別各虛擬函式的實際位址。
- 在每個物件裡塞一個隱藏指標 vptr (virtual pointer),指向所屬類別的 vtable。
呼叫 ptr->speak() 時,實際發生的是:ptr → 取出物件的 vptr → 找到 vtable → 查出 speak 的位址 → 呼叫。這就是為什麼能在執行期分辨實際型別。
3.2 記憶體佈局 ASCII 圖
Base* p = new Derived();
p ─────────────┐
▼
┌────────────────────────┐ ┌──────────────────────────┐
│ Derived 物件 │ │ Derived 的 vtable │
│ ┌────────────────────┐ │ ├──────────────────────────┤
│ │ vptr ──────────────┼─┼────────▶│ &Derived::speak() │
│ ├────────────────────┤ │ │ &Base::greet() (未覆寫) │
│ │ Base 的資料成員 │ │ │ &Derived::~Derived() │
│ ├────────────────────┤ │ └──────────────────────────┘
│ │ Derived 的資料成員 │ │
│ └────────────────────┘ │
└────────────────────────┘
- 同一個類別的所有物件共用一張 vtable(vtable 屬於類別,不屬於物件)。
- 每個物件各自有一個 vptr(所以含虛擬函式的物件會多
sizeof(指標)的大小)。
3.3 驗證 vptr 的大小開銷
class Empty {};
class WithVirtual { public: virtual void f() {} };
std::cout << sizeof(Empty) << "\n"; // 1(空類別至少 1 byte)
std::cout << sizeof(WithVirtual) << "\n"; // 8(64 位元系統:一個 vptr)
預期輸出(64 位元):
1
8
多型不是免費的:每個物件多一個指標、每次虛擬呼叫多一次間接跳轉。但對絕大多數程式而言,這點成本完全值得換來的彈性。
4. 虛擬解構子 — 為什麼這麼重要?
4.1 忘記虛擬解構子的 bug(請務必理解這個例子)
class Base {
public:
Base() { std::cout << "Base()\n"; }
~Base() { std::cout << "~Base()\n"; } // ← 沒有 virtual!
};
class Derived : public Base {
int* data_;
public:
Derived() : data_(new int[100]) { std::cout << "Derived()\n"; }
~Derived() {
delete[] data_; // 釋放資源的關鍵程式碼
std::cout << "~Derived()\n";
}
};
int main() {
Base* p = new Derived();
delete p; // 💥 只呼叫 ~Base()!~Derived() 被跳過!
}
預期輸出(錯誤行為):
Base()
Derived()
~Base()
~Derived() 沒有被呼叫,data_ 的 100 個 int 永久洩漏!更糟的是,這在 C++ 標準裡是未定義行為 (undefined behavior)。
4.2 修正:加上 virtual
class Base {
public:
virtual ~Base() { std::cout << "~Base()\n"; } // ← 加上 virtual
};
修正後的輸出(正確):
Base()
Derived()
~Derived()
~Base()
4.3 規則
C++ Core Guidelines C.35:A base class destructor should be either public and virtual, or protected and non-virtual.
翻譯:只要一個類別會被當作多型基礎類別(透過基礎指標 delete 衍生物件),它的解構子就必須是
public virtual。如果不希望被多型刪除,則設為protected non-virtual。
class Base {
public:
virtual ~Base() = default; // 最常見、最安全的寫法
};
5. override 與 final
5.1 override — 讓編譯器幫你抓錯
class Base {
public:
virtual void draw() const;
};
class Derived : public Base {
public:
void draw() override; // ✓ 明確標記覆寫
// void draw(); // 漏了 const → 變成「隱藏」而非覆寫,但編譯器不會警告
// void draw() override; // ✗ 加了 override 後,簽名不符會立刻編譯錯誤!
};
override 不改變行為,但會讓編譯器檢查你是否真的覆寫了基礎類別的虛擬函式。拼錯名字、漏寫 const、參數型別不符——都會在編譯期被抓到。
C++ Core Guidelines C.128:虛擬函式應該恰好標記
virtual、override、final三者之一。衍生類別的覆寫一律用override(不要重複寫virtual)。
5.2 final — 禁止進一步繼承或覆寫
class Shape {
public:
virtual double area() const = 0;
virtual void draw() const;
};
class Circle final : public Shape { // final:Circle 不能再被繼承
public:
double area() const override;
void draw() const final; // final:子類別不能再覆寫 draw(但 Circle 已 final,此處示意)
};
// class Ball : public Circle {}; // ✗ 編譯錯誤:Circle 是 final
final 的用途:表達設計意圖(這個類別不該被擴充)、並讓編譯器有機會做去虛擬化 (devirtualization) 最佳化。
6. 純虛擬函式、抽象類別與介面
6.1 純虛擬函式與抽象類別
class Shape {
public:
virtual double area() const = 0; // 純虛擬函式(= 0):只有宣告,沒有定義
virtual void draw() const = 0;
virtual ~Shape() = default; // 別忘了虛擬解構子
};
// Shape s; // ✗ 編譯錯誤:抽象類別無法實例化
- 含有至少一個純虛擬函式的類別即為抽象類別 (abstract class),無法直接建立物件。
- 衍生類別必須實作所有純虛擬函式,否則它自己也是抽象類別。
- 抽象類別的作用是定義介面契約:「凡是 Shape,都保證能 area() 和 draw()」。
6.2 C++ 的「介面 (interface)」慣例
C++ 沒有 Java/C# 的 interface 關鍵字。慣例上,一個只有純虛擬函式、沒有資料成員的抽象類別就扮演介面的角色:
class Drawable { // 純介面
public:
virtual void draw() const = 0;
virtual ~Drawable() = default;
};
class Serializable { // 另一個純介面
public:
virtual std::string serialize() const = 0;
virtual ~Serializable() = default;
};
// 一個類別可以實作多個介面(這是多重繼承的「好」用法)
class Widget : public Drawable, public Serializable {
public:
void draw() const override { /* ... */ }
std::string serialize() const override { /* ... */ }
};
C++ Core Guidelines I.25 / C.121:用抽象類別表達介面;介面類別通常沒有資料成員、沒有建構子,只有純虛擬函式與虛擬解構子。
7. 物件切割 (Object Slicing) — 重要陷阱!
這是繼承中最隱蔽的 bug 之一。當你把衍生類別物件以值 (by value) 指派或傳遞給基礎類別型別時,衍生部分會被「切掉」。
class Base {
public:
virtual void who() const { std::cout << "Base\n"; }
};
class Derived : public Base {
int extra_ = 42;
public:
void who() const override { std::cout << "Derived\n"; }
};
void byValue(Base b) { b.who(); } // ✗ 以值傳遞 → 切割
void byRef(const Base& b) { b.who(); }// ✓ 以參考傳遞 → 多型正常
int main() {
Derived d;
byValue(d); // 輸出 "Base" —— d 被切割成只剩 Base 部分!
byRef(d); // 輸出 "Derived" —— 正確的多型
Base b = d; // 切割:b 只複製了 d 的 Base 部分,extra_ 遺失
b.who(); // 輸出 "Base"
}
預期輸出:
Base
Derived
Base
Derived d Base b = d; (切割!)
┌──────────────┐ ┌──────────────┐
│ vptr→Derived │ ─── 複製 ──▶ │ vptr→Base │ ← vptr 變回 Base
│ Base 部分 │ │ Base 部分 │
│ extra_ = 42 │ ✂ 被切掉 └──────────────┘
└──────────────┘
如何避免切割:操作多型物件時,永遠使用基礎類別的指標或參考(
Base*、Base&、std::unique_ptr<Base>),絕不用值。
8. 執行期型別識別 (RTTI)
8.1 dynamic_cast — 安全的向下轉型
Base* base = getSomeShape();
if (Circle* c = dynamic_cast<Circle*>(base)) { // 轉型成功才進入
c->setRadius(5.0); // 安全地使用 Circle 專屬功能
}
- 指標版本:轉型失敗回傳
nullptr(所以可以用if檢查)。 - 參考版本:轉型失敗拋出
std::bad_cast(參考不能是 null)。 - 需要基礎類別至少有一個虛擬函式(具備 RTTI 資訊)才能用。
8.2 typeid — 取得執行期型別資訊
#include <typeinfo>
Base* p = new Derived();
std::cout << typeid(*p).name() << "\n"; // 輸出實際型別(名稱經過 mangling,因編譯器而異)
if (typeid(*p) == typeid(Derived)) {
std::cout << "p 確實指向 Derived\n";
}
⚠️
typeid(*p)要解參考才會啟用多型查詢;typeid(p)只會得到指標的靜態型別Base*。
8.3 何時該用、何時不該用 RTTI
| 情境 | 建議 |
|---|---|
| 偶爾需要「恢復」具體型別做特殊處理 | 可用 dynamic_cast |
| 撰寫安全的向下轉型 | dynamic_cast(勝過 C-style cast) |
一長串 if (dynamic_cast<A>) … else if (dynamic_cast<B>) |
設計有問題,應改用虛擬函式 |
| 效能極度敏感的迴圈 | 避免,dynamic_cast 有執行期成本 |
大量使用
dynamic_cast通常是「多型沒設計好」的訊號。先問:能不能把這段邏輯變成基礎類別的一個虛擬函式?
9. 多重繼承與菱形問題
9.1 多重繼承
class Flyable { public: virtual void fly() { std::cout << "Flying\n"; } virtual ~Flyable() = default; };
class Swimmable { public: virtual void swim() { std::cout << "Swimming\n"; } virtual ~Swimmable() = default; };
class Duck : public Flyable, public Swimmable { // 同時是 Flyable 與 Swimmable
};
多重繼承最安全的用法是「繼承多個純介面」(如上)。繼承多個有實作、有資料的類別則容易出問題。
9.2 菱形繼承問題 (Diamond Problem)
Animal ← 含有 name_ 成員
/ \
Dog Cat ← 兩者都繼承 Animal
\ /
Hybrid ← 💥 含有「兩份」Animal!
class Animal { public: std::string name_; };
class Dog : public Animal {};
class Cat : public Animal {};
class Hybrid : public Dog, public Cat {};
Hybrid h;
// h.name_ = "x"; // ✗ 模稜兩可!是 Dog::Animal::name_ 還是 Cat::Animal::name_?
h.Dog::name_ = "x"; // 必須明確指定,而且有兩份資料,浪費又混亂
9.3 解法:虛擬繼承 (virtual inheritance)
class Animal { public: std::string name_; };
class Dog : virtual public Animal {}; // ← virtual
class Cat : virtual public Animal {}; // ← virtual
class Hybrid : public Dog, public Cat {};
Hybrid h;
h.name_ = "x"; // ✓ 只有一份 Animal,不再模稜兩可
虛擬繼承後:
Animal ← 只有「一份」共享
/ \
Dog Cat
\ /
Hybrid
建議:菱形繼承很複雜(建構子初始化規則、效能開銷都更麻煩)。除非在實作介面,否則盡量避免多重繼承實作類別。
10. 最佳實踐:優先使用組合而非繼承
這是物件導向設計最重要的原則之一。
10.1 繼承被濫用的徵兆
// ✗ 不良設計:只是想重用 vector 的功能就去繼承它
class Stack : public std::vector<int> { // Stack 並不「是一種」vector
public:
void push(int x) { push_back(x); }
// 但使用者還能呼叫 stack.insert()、stack[5] … 破壞了 Stack 的封裝!
};
10.2 改用組合 (composition)
// ✓ 良好設計:Stack「擁有 (has-a)」一個 vector
class Stack {
std::vector<int> data_; // 組合:把 vector 當成員
public:
void push(int x) { data_.push_back(x); }
void pop() { data_.pop_back(); }
int top() const { return data_.back(); }
bool empty() const { return data_.empty(); }
// 只暴露 Stack 該有的介面,完全控制
};
10.3 如何選擇?
| 問題 | 答案 → 用 |
|---|---|
| 「B 是一種 A 嗎?」(行為上可替換) | 是 → 繼承 (public) |
| 「B 擁有/使用 A 嗎?」 | 是 → 組合(成員) |
| 只是想重用 A 的程式碼? | → 組合(不要為了重用而繼承) |
| 需要多型(用基礎指標統一操作)? | → 繼承 + 虛擬函式 |
C++ Core Guidelines C.129 / C.121:If a base class is used as an interface, make it a pure abstract class. 並且設計類別階層時「優先組合,謹慎繼承」。繼承會建立強耦合,組合則保持彈性。
重點整理
| 概念 | 說明 |
|---|---|
| public 繼承 | is-a 關係,最常用,須符合 Liskov 替換 |
| private 繼承 | implemented-in-terms-of,通常可用組合取代 |
| 建構順序 | 基礎 → 衍生;解構順序相反 |
| 基礎類別初始化 | 寫在初始化列表,必須最先 |
| 靜態綁定 | 無 virtual,依指標型別,編譯期決定 |
| 動態綁定 | 有 virtual,依物件實際型別,執行期決定 |
| vtable / vptr | 每類別一張表,每物件一個指標,實現多型 |
| 虛擬解構子 | 多型基礎類別必須 public virtual ~T() |
override |
讓編譯器檢查是否真的覆寫 |
final |
禁止繼承或覆寫 |
純虛擬函式 (=0) |
定義抽象類別 / 介面 |
| 物件切割 | 以值操作多型物件會切掉衍生部分;用指標/參考避免 |
dynamic_cast |
安全向下轉型,失敗回 nullptr / 拋例外 |
| 虛擬繼承 | 解決菱形繼承的重複基礎問題 |
| 組合 vs 繼承 | 優先組合;只有 is-a + 需要多型才繼承 |
常見錯誤與陷阱
-
忘記虛擬解構子:透過基礎類別指標
delete衍生物件時,衍生解構子被跳過,造成資源洩漏與未定義行為。規則:多型基礎類別 →virtual ~Base() = default;。 -
覆寫時函式簽名不一致:參數型別、
const、回傳型別不同會變成「隱藏」而非「覆寫」,多型失效。永遠加override,讓編譯器提前抓到。 -
物件切割:以值傳遞/指派多型物件(
void f(Base b)、Base b = derived;)會切掉衍生部分。操作多型物件一律用指標或參考。 -
在建構子或解構子中呼叫虛擬函式:此時 vtable 指向「當前正在建構/解構的類別」,呼叫的不是最終衍生版本。這幾乎總是 bug。
-
名稱隱藏:衍生類別定義同名函式會隱藏基礎類別所有同名多載。需要時用
using Base::func;帶回。 -
誤用 private 繼承表達 is-a:private 繼承是「實作細節」,外部不能把它當基礎類別用。想要 is-a 請用 public 繼承。
-
濫用
dynamic_cast取代多型:一長串if (dynamic_cast<...>)是設計警訊,通常該改成虛擬函式。 -
為了重用程式碼而繼承:「想用某類別的功能」不是繼承的理由,那是組合的工作。繼承會帶來強耦合與脆弱基礎類別問題。
-
多重繼承實作類別導致菱形問題:除非繼承純介面,否則小心多重繼承;需要共享基礎時用虛擬繼承。
-
typeid(p)寫錯:要查實際型別必須typeid(*p)(解參考);typeid(p)只得到指標靜態型別。
練習題
以下練習由淺入深,建議全部動手實作。每題附「提示」。
練習 1(基礎)— 員工階層
設計 Person 基礎類別(name_、age_),衍生 Student(加上 studentId_、grade_)與 Teacher(加上 subject_、years_)。各自實作 introduce() 印出個人資訊。
- 提示:
introduce()設為virtual;衍生類別用override;在衍生的introduce()中可呼叫Person::introduce()重用基礎部分。
練習 2(基礎)— 建構解構順序觀察
建立三層繼承 A → B → C,每個類別的建構子與解構子都印出訊息。在 main 中建立一個 C 物件並觀察輸出,再用 A* p = new C(); delete p; 觀察「有無虛擬解構子」的差異。
- 提示:先讓
~A()非虛擬,記錄輸出;再改成virtual ~A(),比較兩次結果。
練習 3(中級)— 圖形多型與總面積
設計抽象類別 Shape,含純虛擬函式 area() 與 perimeter()。衍生 Circle、Rectangle、Triangle。用 std::vector<std::unique_ptr<Shape>> 裝入各種圖形,計算總面積,並找出面積最大的圖形。
- 提示:別忘了
virtual ~Shape() = default;;用std::make_unique建立物件;遍歷時用s->area()。
練習 4(中級)— 物件切割重現
寫一個函式 print(Shape s)(以值傳遞)與 print(const Shape& s)(以參考傳遞),各自呼叫虛擬函式 draw()。傳入一個 Circle,觀察兩者輸出不同,並用一段文字說明為什麼。
- 提示:以值傳遞時 vptr 被重設為
Shape,輸出基礎版本;以參考則維持多型。
練習 5(中級)— 介面與 RTTI
設計兩個純介面 Printable(print())與 Serializable(serialize())。讓 Document 同時實作兩者。建立 std::vector<Printable*>,其中混入同時為 Serializable 的物件,用 dynamic_cast 找出可序列化的物件並呼叫 serialize()。
- 提示:介面只放純虛擬函式 + 虛擬解構子;
dynamic_cast<Serializable*>(ptr)成功才呼叫。
練習 6(挑戰)— 組合重構
給定一個「為了重用而繼承」的不良設計 class Logger : public std::vector<std::string>,把它重構成使用組合的 Logger(內部持有 std::vector<std::string>),只暴露 log(msg)、dump()、clear() 三個方法。寫一段說明,比較重構前後在封裝性上的差異。
- 提示:把 vector 變成 private 成員;對照 §10「組合 vs 繼承」表格論述。
練習 7(挑戰)— 菱形繼承與虛擬繼承
設計 Device(含 serialNo_)為共同基礎,Scanner 與 Printer 繼承自它,MultiFunctionPrinter 同時繼承 Scanner 與 Printer。先不用虛擬繼承,觀察 serialNo_ 的存取為何模稜兩可;再改成虛擬繼承解決,並驗證只剩一份 serialNo_。
- 提示:
class Scanner : virtual public Device;注意虛擬繼承時,最終衍生類別MultiFunctionPrinter要負責呼叫Device的建構子。
對應程式碼檔案
| 檔案 | 說明 |
|---|---|
| inheritance.cpp | 繼承基礎:建構/解構順序、protected 成員、方法隱藏、多層繼承 |
| polymorphism.cpp | 虛擬函式、多型、虛擬解構子對比、override、vtable 大小驗證 |
| abstract_class.cpp | 純虛擬函式、抽象類別、介面、dynamic_cast、typeid、final |