C++ 類別與物件:用型別系統守住你的約束
從封裝、建構解構到 virtual 多型,親手寫出符合慣例的 C++ 物件導向,並理解 vtable 成本與三/五/零法則背後的設計取捨。
從一個會「漏水」的銀行帳戶說起
想像你要寫一個銀行帳戶系統。在 Python 裡,你大概會直接 self.balance = 1000,誰都能 account.balance = -999999,跑起來也不會抱怨。系統上線了,某天有人把餘額改成負的,程式照樣執行,直到報表那一刻才爆炸。
C++ 的世界觀不一樣。它希望你在編譯時就把「餘額不該被外人亂改」這件事寫進型別系統,讓任何違規的程式碼根本編譯不過。這就是 C++ 物件導向的核心精神:用語言機制把約束變成編譯期就能檢查的事實,而且這層抽象幾乎不花你任何執行成本(zero-cost abstraction,零成本抽象)。
相較於 Python 的「我們都是成年人」哲學,C++ 更像一份嚴謹的合約:你宣告了什麼,編譯器就替你守住什麼。這篇我們就用 C++ 親手把帳戶、形狀、繼承關係一個個寫出來,並且時時刻刻問一句:「這在 Python 裡是怎樣?為什麼 C++ 要這樣做?」

class 與 struct:唯一的差別是「預設關起來還是打開」
先把最小的類別寫出來:
#include <iostream>
#include <string>
class Account {
private:
std::string owner_; // 慣例:私有成員加後綴底線
long balance_; // 以「分」為單位,避免浮點誤差
public:
void deposit(long amount) {
if (amount > 0) balance_ += amount;
}
long balance() const { // const:保證不修改任何成員
return balance_;
}
};
在 C++ 裡,class 與 struct 幾乎是同一件事,唯一的差別是預設存取權限:class 預設 private,struct 預設 public。也就是說:
struct Point { int x; int y; }; // x、y 預設 public
class Point2 { int x; int y; }; // x、y 預設 private(外面碰不到)
慣例上:純粹放資料、沒有不變式(invariant)要守護的「資料包」用 struct;有封裝、有行為、有需要保護的內部狀態的型別用 class。這和 Python 沒有對應概念——Python 的 class 一律全公開,所謂的「私有」只是 _name 這種約定俗成的命名,並非語言強制。
存取控制:private 不是「藏起來」而是「編譯期防線」
C++ 有三種存取層級:
public:任何人都能存取。private:只有這個類別自己的成員函式(與 friend)能存取。protected:自己與衍生類別能存取。
關鍵在於:違反存取控制會編譯失敗,不是執行時警告。
Account a;
a.deposit(50000); // OK,public 介面
// a.balance_ = 999999; // 編譯錯誤!balance_ is private
std::cout << a.balance(); // OK,透過唯讀介面
那行被註解掉的 a.balance_ = ... 如果解開,g++ 會直接拒絕編譯。這就是 C++ 把「不變式」交給編譯器守護的方式:餘額只能透過 deposit/withdraw 改動,而那些函式裡可以塞檢查(不准變負數等)。外人沒有任何合法管道繞過。
小提醒:
private是編譯期的概念,不是加密。記憶體上資料就在那裡,你硬要用指標位移去讀也讀得到——只是這麼做就脫離了語言保證,後果自負。封裝保護的是「正常程式碼不會誤用」,而非防駭客。
建構子與解構子:物件的「出生」與「臨終」
Python 有 __init__,但沒有真正意義上、保證會被呼叫的解構子(__del__ 時機不確定,靠 GC)。C++ 兩者都有,而且解構子的呼叫時機是確定的——這是 C++ 資源管理的基石。
class Account {
std::string owner_;
long balance_;
public:
// 建構子:用成員初始化串列(member initializer list)
Account(std::string owner, long initial)
: owner_(std::move(owner)), balance_(initial) {
std::cout << owner_ << " 帳戶開立,餘額 " << balance_ << "\n";
}
// 解構子:物件離開作用域時自動呼叫
~Account() {
std::cout << owner_ << " 帳戶關閉\n";
}
};
int main() {
Account a("Alice", 1000); // 建構子在此執行
// ... 使用 a ...
} // 離開 main:a 的解構子在此自動執行
幾個 C++ 特有的重點:
- 成員初始化串列(
: owner_(...), balance_(...))優先於建構子大括號內的賦值。const成員、參考成員、沒有預設建構子的成員,只能在這裡初始化。 - 初始化順序由宣告順序決定,不是初始化串列裡的書寫順序。
owner_先宣告就先初始化,寫反了編譯器會警告。 - 解構子在物件生命週期結束時自動且確定被呼叫——堆疊上的物件離開作用域、堆積上的物件被
delete、容器元素被移除時。這個「確定性」讓 C++ 發展出 RAII(Resource Acquisition Is Initialization,資源取得即初始化):把檔案、鎖、記憶體的釋放寫進解構子,就再也不會忘記釋放。
動手寫一段:用解構子追蹤生命週期
把下面整段存成 lifetime.cpp,用 g++ -std=c++17 lifetime.cpp -o lifetime 編譯後執行:
#include <iostream>
#include <string>
class Tracker {
std::string name_;
public:
Tracker(std::string name) : name_(std::move(name)) {
std::cout << "建構 " << name_ << "\n";
}
~Tracker() {
std::cout << "解構 " << name_ << "\n";
}
};
int main() {
Tracker a("A");
{
Tracker b("B"); // 進入內層作用域
std::cout << "-- 內層中 --\n";
} // b 在此解構
std::cout << "-- 外層中 --\n";
return 0;
} // a 在此解構
預期輸出:
// 輸出:
// 建構 A
// 建構 B
// -- 內層中 --
// 解構 B
// -- 內層中 -- ← 注意:這行其實在 "解構 B" 之前
// -- 外層中 --
// 解構 A
修正後的精確輸出順序是:
// 輸出:
// 建構 A
// 建構 B
// -- 內層中 --
// 解構 B
// -- 外層中 --
// 解構 A
注意解構順序與建構順序相反(後建構的 b 先解構),而且 b 一離開內層大括號就立刻解構——不必等 GC,不必等程式結束。這種確定性是 Python、Java 給不了的。
this:指向「自己」的指標
在成員函式裡,this 是一個指標,指向呼叫這個函式的物件本身。Python 的 self 是明確寫在參數列的第一個參數;C++ 的 this 是隱含傳入的,型別是「指向當前物件的指標」。
class Counter {
int value_ = 0;
public:
Counter& increment() {
++this->value_; // this 是指標,用 -> 存取成員
return *this; // 回傳「自己」的參考,支援鏈式呼叫
}
int value() const { return value_; }
};
int main() {
Counter c;
c.increment().increment().increment(); // 鏈式呼叫
std::cout << c.value() << "\n"; // 輸出:3
}
多數時候你不必明寫 this->,直接寫 value_ 即可(編譯器自動補上)。this 真正派上用場的場合是:需要回傳 *this 做鏈式呼叫、需要區分同名的成員與參數、或要把自己的位址傳出去。
繼承:把共通行為抽到基底類別
假設我們要畫各種形狀,每個形狀都能算面積、也能描述自己。把共通介面放進基底類別:
#include <iostream>
#include <string>
class Shape {
protected:
std::string name_;
public:
Shape(std::string name) : name_(std::move(name)) {}
std::string name() const { return name_; }
};
// Circle「is-a」Shape
class Circle : public Shape {
double radius_;
public:
Circle(double r) : Shape("Circle"), radius_(r) {}
double area() const { return 3.14159265 * radius_ * radius_; }
};
注意 : public Shape——這個 public 表示公開繼承,代表「is-a」關係(Circle 是一種 Shape)。Circle 的建構子用 Shape("Circle") 呼叫基底建構子,必須放在初始化串列。
相較於 Python 的 class Circle(Shape): 與 super().__init__(...),C++ 的繼承多了一層「繼承方式」的選擇(public/protected/private),而且預設是 private——class Circle : Shape 沒寫 public 就變成私有繼承,這是新手常踩的雷。幾乎所有「is-a」場景都該明寫 public。
virtual 與多型:讓「同一句話」對不同物件做不同的事
現在來看 C++ OOP 最關鍵、也最容易出錯的部分。先看一個錯誤示範:
class Shape {
public:
std::string describe() const { return "我是一個形狀"; } // 沒有 virtual
};
class Circle : public Shape {
public:
std::string describe() const { return "我是一個圓"; }
};
void print(const Shape& s) {
std::cout << s.describe() << "\n";
}
int main() {
Circle c;
print(c); // 輸出:我是一個形狀 ← 不是我們要的!
}
問題出在哪?因為 describe 不是 virtual,編譯器在編譯期就根據參數的「靜態型別」Shape& 決定要呼叫 Shape::describe,完全不看實際物件是 Circle。這叫靜態分派(static dispatch)。
要讓 C++ 在執行期依「實際型別」決定呼叫哪個版本,必須加 virtual:
class Shape {
public:
virtual std::string describe() const { return "我是一個形狀"; }
virtual ~Shape() = default; // 基底類別務必有 virtual 解構子!
};
class Circle : public Shape {
public:
std::string describe() const override { // override 讓編譯器幫你檢查
return "我是一個圓";
}
};
加了 virtual 之後,print(c) 就會輸出「我是一個圓」。這就是動態分派(dynamic dispatch)/執行期多型(runtime polymorphism)。
這裡有兩個 C++ 的關鍵設計,是 Python 程式員最不習慣的:
- 多型預設是「關閉」的。Python 的方法天生就會依實際型別分派(一律動態),但 C++ 為了效能,預設用靜態分派,你必須主動加
virtual開啟。這是「零成本抽象」的體現:不用多型就不付多型的代價。 override關鍵字雖然可省略,但強烈建議寫上。它讓編譯器檢查「你真的有覆寫到基底的虛擬函式嗎」——萬一你把函式簽章打錯(例如漏了const),編譯器會報錯而不是默默產生一個無關的新函式。
純虛擬與抽象類別:定義「契約」而不給實作
如果 Shape 根本不該被實體化(誰能畫出「一般的形狀」?),就把它變成抽象類別——用純虛擬函式(pure virtual function):
class Shape {
public:
virtual double area() const = 0; // = 0 表示純虛擬
virtual std::string name() const = 0;
virtual ~Shape() = default;
};
= 0 宣告這個函式「沒有實作、衍生類別必須提供」。只要類別含有任一純虛擬函式,它就是抽象類別,不能被實體化:
// Shape s; // 編譯錯誤:cannot declare variable of abstract type
抽象類別等同於其他語言的「介面(interface)」。任何衍生類別只要覆寫了全部純虛擬函式,就成為可實體化的具體類別:
#include <iostream>
#include <string>
#include <vector>
#include <memory>
class Shape {
public:
virtual double area() const = 0;
virtual std::string name() const = 0;
virtual ~Shape() = default;
};
class Circle : public Shape {
double r_;
public:
Circle(double r) : r_(r) {}
double area() const override { return 3.14159265 * r_ * r_; }
std::string name() const override { return "Circle"; }
};
class Rectangle : public Shape {
double w_, h_;
public:
Rectangle(double w, double h) : w_(w), h_(h) {}
double area() const override { return w_ * h_; }
std::string name() const override { return "Rectangle"; }
};
int main() {
// 用智慧指標管理多型物件,自動釋放記憶體
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(2.0));
shapes.push_back(std::make_unique<Rectangle>(3.0, 4.0));
for (const auto& s : shapes) {
std::cout << s->name() << " 面積 = " << s->area() << "\n";
}
return 0;
}
// 輸出:
// Circle 面積 = 12.5664
// Rectangle 面積 = 12
這段示範了 C++ 多型的標準寫法:透過基底類別的指標/參考操作衍生物件。注意這裡用 std::unique_ptr 而非裸指標 new——智慧指標會在離開作用域時自動 delete,把 RAII 套用到堆積記憶體,避免記憶體洩漏。多型一定要走指標或參考,因為若用值傳遞 Shape(而它根本不能實體化),或把 Circle 塞進 Shape 變數,會發生物件切片(object slicing)——衍生部分被默默截掉。
重點回顧:初學者最常踩的五個雷
-
基底類別忘了寫
virtual ~Shape()。當你用Shape*指向Circle並delete,若解構子非虛擬,只會呼叫~Shape(),Circle的資源不會被釋放——未定義行為。只要類別會被當基底用、且透過基底指標 delete,解構子就要 virtual。 -
以為加了
virtual才有多型,卻用「值」傳遞。void f(Shape s)會發生物件切片;多型必須用Shape&或Shape*(含智慧指標)。 -
覆寫時簽章打錯卻沒寫
override。漏一個const,就變成「新函式」而非覆寫,多型默默失效。永遠加override讓編譯器替你把關。 -
繼承忘了寫
public。class A : B是私有繼承,會讓「is-a」關係破功。要表達「子型別」一律class A : public B。 -
在初始化串列裡依書寫順序而非宣告順序思考。成員一律按宣告順序初始化,若
b_的初始化用到a_,請確保a_宣告在前。
深入探討(研究所視角)
虛擬函數表(vtable)與動態分派的成本
virtual 的魔法背後是一張虛擬函數表(vtable)。當一個類別含有虛擬函式,編譯器會替它生成一張靜態的函式指標表,表中每一格指向該類別對應的虛擬函式實作。每個物件的記憶體開頭則被偷偷塞入一個 vptr(vtable pointer),指向自己所屬類別的 vtable。
於是一次虛擬呼叫 s->area() 在底層大致展開成:
// 偽組語:
// 1. 從物件取出 vptr (一次記憶體讀取)
// 2. 用固定偏移量在 vtable 找到 area 的位址 (一次讀取)
// 3. 間接呼叫該位址 (indirect call)
成本分析:
- 空間:每個多型物件多一個指標大小的 vptr(64 位元系統上 8 bytes);每個多型類別多一張 vtable(每個程式只一份)。
- 時間:每次虛擬呼叫多兩次記憶體讀取與一次間接跳轉。單看 Big-O 仍是 $O(1)$,但這個間接呼叫的「常數」不便宜——它阻止了編譯器內聯(inlining),連帶失去後續一連串最佳化;間接跳轉也可能造成 CPU 分支預測失誤、汙染指令快取。在熱迴圈裡,一個非必要的 virtual 呼叫可能慢上數倍。
這正是 C++「預設不開 virtual」的理由:多型不是免費的,所以由你決定何時付這個代價。相較之下,Python 的每次方法呼叫本質上都是動態查表(__dict__ 與 MRO 查找),開銷遠大於 C++ 的 vtable,只是直譯器本身的開銷讓你感覺不到差別。C++ 的哲學是「不用的東西不該讓你付錢」(zero-overhead principle)。
值得一提的是,若物件的實際型別在編譯期已知(例如 Circle c; c.area(); 直接對具體物件呼叫),編譯器可做去虛擬化(devirtualization),把虛擬呼叫還原成直接呼叫甚至內聯——這是現代編譯器的常見最佳化。需要編譯期多型而非執行期多型時,C++ 還有 template(模板) 與 CRTP(Curiously Recurring Template Pattern)這條完全零執行期成本的路線,但那是另一篇的故事了。
三法則與五法則:管理資源的物件該怎麼複製?
當類別自行管理資源(裸指標、檔案描述子、堆積記憶體等),預設的複製行為會出大問題。考慮:
class Buffer {
int* data_;
size_t size_;
public:
Buffer(size_t n) : data_(new int[n]), size_(n) {}
~Buffer() { delete[] data_; }
// 沒有自訂複製建構子與複製賦值!
};
Buffer a(10);
Buffer b = a; // 預設複製:b.data_ 與 a.data_ 指向「同一塊」記憶體!
預設的複製是淺複製(shallow copy):只複製指標的值,不複製指標指向的內容。於是 a 與 b 共享同一塊堆積記憶體,當兩者解構時會 double free(同一塊記憶體被 delete[] 兩次)——程式崩潰。
三法則(Rule of Three) 因此而生:如果你需要自訂以下三者之一,幾乎一定三者都要自訂——
- 解構子(destructor)
- 複製建構子(copy constructor)
- 複製賦值運算子(copy assignment operator)
因為「需要自訂解構子」這件事本身就意味著「這個類別管理著需要特殊處理的資源」,那麼複製這個資源也必然需要特殊處理(深複製,deep copy)。
C++11 引入移動語意(move semantics)後,擴充為五法則(Rule of Five),再加上:
- 移動建構子(move constructor)
- 移動賦值運算子(move assignment operator)
移動的意義是:當來源物件即將被銷毀(例如回傳的暫存物件),與其昂貴地深複製,不如直接「偷走」它的資源指標,把來源的指標設為 nullptr,成本從 $O(n)$ 降到 $O(1)$。
而最務實的現代建議是 零法則(Rule of Zero):讓你的類別根本不直接管理資源。改用 std::vector、std::string、std::unique_ptr、std::shared_ptr 這些已經正確實作了五法則的標準型別當成員,編譯器自動產生的複製/移動/解構就會全部正確,你一行特殊成員函式都不必寫。
class Buffer {
std::vector<int> data_; // vector 自己管好記憶體
public:
Buffer(size_t n) : data_(n) {}
// 不需要解構子、不需要複製/移動——全部自動正確
};
這也回應了開頭的對比:Python 靠 GC 與參考計數,使用者幾乎不必思考複製語意與資源所有權;C++ 則把「誰擁有這塊資源、何時釋放、複製時該深還是淺」全部攤在你面前。代價是更陡的學習曲線,回報是對記憶體與效能的完全掌控,以及不依賴執行期垃圾回收的確定性。理解三/五/零法則,正是從「會寫 C++ 語法」邁向「寫得出正確且高效的 C++」的分水嶺。
動手做做看:把上面那個會 double free 的 Buffer 補上正確的複製建構子(做深複製),編譯執行確認不再崩潰;再試著用零法則版本,體會「少寫程式碼反而更安全」的 C++ 現代風格。