Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 28 · 臺南 AQI 24 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

類別與物件

C++ 類別與物件:用型別系統守住你的約束

從封裝、建構解構到 virtual 多型,親手寫出符合慣例的 C++ 物件導向,並理解 vtable 成本與三/五/零法則背後的設計取捨。

從一個會「漏水」的銀行帳戶說起

想像你要寫一個銀行帳戶系統。在 Python 裡,你大概會直接 self.balance = 1000,誰都能 account.balance = -999999,跑起來也不會抱怨。系統上線了,某天有人把餘額改成負的,程式照樣執行,直到報表那一刻才爆炸。

C++ 的世界觀不一樣。它希望你在編譯時就把「餘額不該被外人亂改」這件事寫進型別系統,讓任何違規的程式碼根本編譯不過。這就是 C++ 物件導向的核心精神:用語言機制把約束變成編譯期就能檢查的事實,而且這層抽象幾乎不花你任何執行成本(zero-cost abstraction,零成本抽象)。

相較於 Python 的「我們都是成年人」哲學,C++ 更像一份嚴謹的合約:你宣告了什麼,編譯器就替你守住什麼。這篇我們就用 C++ 親手把帳戶、形狀、繼承關係一個個寫出來,並且時時刻刻問一句:「這在 Python 裡是怎樣?為什麼 C++ 要這樣做?」

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++ 裡,classstruct 幾乎是同一件事,唯一的差別是預設存取權限class 預設 privatestruct 預設 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++ 把「不變式」交給編譯器守護的方式:餘額只能透過 depositwithdraw 改動,而那些函式裡可以塞檢查(不准變負數等)。外人沒有任何合法管道繞過。

小提醒: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++ 特有的重點:

  1. 成員初始化串列: owner_(...), balance_(...))優先於建構子大括號內的賦值。const 成員、參考成員、沒有預設建構子的成員,只能在這裡初始化。
  2. 初始化順序由宣告順序決定,不是初始化串列裡的書寫順序。owner_ 先宣告就先初始化,寫反了編譯器會警告。
  3. 解構子在物件生命週期結束時自動且確定被呼叫——堆疊上的物件離開作用域、堆積上的物件被 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++ 的繼承多了一層「繼承方式」的選擇(publicprotectedprivate),而且預設是 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 程式員最不習慣的:

  1. 多型預設是「關閉」的。Python 的方法天生就會依實際型別分派(一律動態),但 C++ 為了效能,預設用靜態分派,你必須主動加 virtual 開啟。這是「零成本抽象」的體現:不用多型就不付多型的代價。
  2. 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)——衍生部分被默默截掉。

重點回顧:初學者最常踩的五個雷

  1. 基底類別忘了寫 virtual ~Shape()。當你用 Shape* 指向 Circledelete,若解構子非虛擬,只會呼叫 ~Shape()Circle 的資源不會被釋放——未定義行為。只要類別會被當基底用、且透過基底指標 delete,解構子就要 virtual。

  2. 以為加了 virtual 才有多型,卻用「值」傳遞void f(Shape s) 會發生物件切片;多型必須用 Shape&Shape*(含智慧指標)。

  3. 覆寫時簽章打錯卻沒寫 override。漏一個 const,就變成「新函式」而非覆寫,多型默默失效。永遠加 override 讓編譯器替你把關。

  4. 繼承忘了寫 publicclass A : B 是私有繼承,會讓「is-a」關係破功。要表達「子型別」一律 class A : public B

  5. 在初始化串列裡依書寫順序而非宣告順序思考。成員一律按宣告順序初始化,若 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):只複製指標的值,不複製指標指向的內容。於是 ab 共享同一塊堆積記憶體,當兩者解構時會 double free(同一塊記憶體被 delete[] 兩次)——程式崩潰。

三法則(Rule of Three) 因此而生:如果你需要自訂以下三者之一,幾乎一定三者都要自訂——

  1. 解構子(destructor)
  2. 複製建構子(copy constructor)
  3. 複製賦值運算子(copy assignment operator)

因為「需要自訂解構子」這件事本身就意味著「這個類別管理著需要特殊處理的資源」,那麼複製這個資源也必然需要特殊處理(深複製,deep copy)。

C++11 引入移動語意(move semantics)後,擴充為五法則(Rule of Five),再加上:

  1. 移動建構子(move constructor)
  2. 移動賦值運算子(move assignment operator)

移動的意義是:當來源物件即將被銷毀(例如回傳的暫存物件),與其昂貴地深複製,不如直接「偷走」它的資源指標,把來源的指標設為 nullptr,成本從 $O(n)$ 降到 $O(1)$。

而最務實的現代建議是 零法則(Rule of Zero)讓你的類別根本不直接管理資源。改用 std::vectorstd::stringstd::unique_ptrstd::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++ 現代風格。

AI 共讀助教正在陪你讀:C++ 類別與物件:用型別系統守住你的約束
嗨!我是這篇文章的共讀助教,只根據〈C++ 類別與物件:用型別系統守住你的約束〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。