Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

類別與物件

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++ 類別與物件:用型別系統守住你的約束〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。