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

UeduGPTs

--

Jupyters

4

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

AI 回覆桌面通知

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

聊天訊息通知

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

聲音通知

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

類別與物件

C++ 物件模型探祕:vptr、菱形繼承與不靠繼承的多型

從一個物件在記憶體裡的真實佈局出發,拆解 vtable 動態分派、多重繼承的 this 位移、RTTI,再到 CRTP 與型別抹除這兩條現代零成本路線

一個物件在記憶體裡到底長什麼樣子?

入門篇我們學會了用 class 封裝、用 virtual 開啟多型,也知道「基底類別要記得寫 virtual 解構子」。但有一個問題入門篇刻意跳過了:當你寫下 Circle c;,這個物件在記憶體裡到底是什麼形狀?它的成員怎麼排?那個讓多型運作的 vptr 藏在哪一個 byte?如果一個類別同時繼承兩個基底,它的 this 指標會不會「跑掉」?

這些問題聽起來很底層,但它們不是學究式的好奇心。一旦你開始寫稍具規模的 C++——把物件序列化、與 C 程式交換資料、追一個 dynamic_cast 為什麼回傳 nullptr、或單純想看懂一段 crash 的 stack trace——你就會發現:不理解 C++ 物件模型(object model),你只是在背語法。這篇我們把物件拆開來看,從記憶體佈局一路講到多重繼承的菱形問題,再到現代 C++ 如何用「不繼承」的方式拿到多型。

類別與物件進階概念示意圖

沒有 virtual 的物件:就是成員的「貼著排」

先從最單純的情況開始。一個沒有任何虛擬函式的類別,它的物件在記憶體裡就是把成員依宣告順序一個個排好,中間可能塞一些對齊用的填充位元組(padding)。沒有別的,沒有隱藏欄位,沒有型別標籤。

#include <cstdio>

struct Plain {
    char  c;    // 1 byte
    int   n;    // 4 bytes
    char  d;    // 1 byte
};

int main() {
    printf("sizeof(Plain) = %zu\n", sizeof(Plain));  // 多半印出 12,不是 6
}

你可能預期 1 + 4 + 1 = 6,但實際多半是 12。原因是對齊(alignment)int 通常要求落在 4 的倍數位址上,於是 c 之後補 3 個 padding byte 讓 n 對齊,結尾的 d 之後再補 3 個 byte 讓整個結構的大小是 4 的倍數(這樣放進陣列時每個元素都對齊)。把成員依大小重排(大的在前)常能省下空間,這是寫高密度資料結構時的實戰技巧。

關鍵結論:一個沒有 virtual 的物件,大小完全等於「成員 + padding」,不多一個 byte。這正是入門篇說的零成本——你沒用多型,就不會被偷塞任何東西。

加了 virtual 之後:vptr 偷偷住進物件開頭

一旦類別出現第一個虛擬函式,物件的佈局就變了。編譯器會在物件的最前面塞一個指標——vptr(virtual table pointer,虛擬函數表指標),指向該類別專屬的一張 vtable(virtual table,虛擬函數表)

#include <cstdio>

struct Base {
    virtual void f() {}
    int x;
};

int main() {
    printf("sizeof(Base) = %zu\n", sizeof(Base));  // 64 位元系統上印 16
}

Base 只有一個 int x(4 bytes),但 sizeof 是 16——因為前面多了 8 bytes 的 vptr,加上對齊填充。這 8 bytes 就是多型的「入場費」:每個多型物件都要付一次(注意是每個物件,不是每個類別)。

vtable 則是每個類別一張、放在唯讀資料區的靜態表格,每一格是一個函式指標,指向該類別對應虛擬函式的實作。它的概念佈局像這樣:

物件 b (記憶體中)            Base 的 vtable(程式中只有一份)
┌──────────────┐            ┌────────────────────────┐
│ vptr ────────┼──────────► │ [0] &Base::f           │
├──────────────┤            │ [1] &Base::~Base        │
│ int x        │            └────────────────────────┘
└──────────────┘

當你透過基底指標呼叫 p->f(),編譯器不知道p 實際指向什麼,於是產生的程式碼是:「從 p 取出 vptr → 在 vtable 的固定第 0 格取出函式位址 → 間接呼叫它」。Circle 的 vtable 第 0 格放的是 Circle::fSquare 的放 Square::f——同一句 p->f(),不同物件跳到不同實作。這就是動態分派的全部祕密。

看一個例子:親手把 vptr 挖出來

下面這段程式用一點「不該在正式碼裡做」的手法,直接讀出物件開頭的 vptr,證明它真的存在、而且兩個不同子類別的物件指向不同的 vtable。用 g++ -std=c++17 vptr.cpp -o vptr 編譯:

#include <cstdio>
#include <cstdint>

struct Animal {
    virtual void speak() { printf("..."); }
    virtual ~Animal() = default;
};
struct Dog : Animal { void speak() override { printf("Woof"); } };
struct Cat : Animal { void speak() override { printf("Meow"); } };

// 讀出物件最前面那 8 bytes(vptr)的「值」
uintptr_t vptr_of(void* obj) {
    return *reinterpret_cast<uintptr_t*>(obj);
}

int main() {
    Dog d1, d2;
    Cat c1;
    printf("d1 vptr = %p\n", (void*)vptr_of(&d1));
    printf("d2 vptr = %p\n", (void*)vptr_of(&d2));   // 與 d1 相同
    printf("c1 vptr = %p\n", (void*)vptr_of(&c1));   // 與 d1 不同
}

執行後你會看到:d1d2 的 vptr 完全相同(兩個 Dog 共用 Dog 的 vtable),而 c1 的 vptr 不一樣(指向 Cat 的 vtable)。這正是「型別資訊存在物件裡」的具體證據——而且不是存在每個物件、而是每個物件存一個指向「全類別共用一張表」的指標。

提醒:reinterpret_cast 讀 vptr 是依賴實作細節(Itanium C++ ABI 把 vptr 放在物件開頭),標準並未保證。這段只用於教學透視,永遠不要寫進正式程式。要在執行期取型別資訊,請用下面會講的 typeiddynamic_cast

為什麼建構子裡呼叫 virtual 不會多型?

這是一個經典面試題,答案藏在「vptr 是什麼時候被設定的」。物件的建構是由基底往衍生逐層進行的:先跑 Base 的建構子,再跑 Derived 的建構子。在 Base 建構子執行的當下,這個物件「還只是個 Base」——它的 vptr 此刻指向 Base 的 vtable,而非最終的 Derived vtable。

#include <cstdio>
struct Base {
    Base() { init(); }                 // 在建構子裡呼叫虛擬函式
    virtual void init() { printf("Base::init\n"); }
};
struct Derived : Base {
    void init() override { printf("Derived::init\n"); }
};

int main() {
    Derived d;   // 輸出 Base::init,不是 Derived::init!
}

很多從 Java/Python 過來的人會預期印出 Derived::init,但 C++ 印的是 Base::init。因為建構 Base 那一層時,Derived 的部分還沒被初始化,C++ 刻意讓 vptr 隨建構進度逐層更新,避免你呼叫到一個操作著尚未初始化成員的覆寫版本——這其實是 C++ 在保護你。解構時同理,vptr 由衍生往基底「退回去」。結論:別在建構子/解構子裡依賴虛擬分派。

多重繼承與菱形問題:this 指標會「位移」

C++ 允許一個類別同時繼承多個基底(多重繼承,multiple inheritance),這是 Java 沒有、Python 用 MRO 另闢蹊徑處理的能力。多重繼承下,物件佈局會把每個基底子物件依序拼接起來:

struct A { int a; virtual void fa() {} };
struct B { int b; virtual void fb() {} };
struct C : A, B { int c; };
// C 的佈局大致是:[A 子物件(vptr_A, a)] [B 子物件(vptr_B, b)] [c]

注意 C 物件裡有兩個 vptr——一個給 A 的介面,一個給 B 的介面。更微妙的是:當你把 C* 轉成 B*,編譯器必須把指標往後位移,讓它指到物件裡 B 子物件的起點。這就是為什麼「指標轉型在多重繼承下不再只是改個型別名稱,位址值真的會變」。

真正棘手的是菱形繼承(diamond inheritance)

struct Device { int id; };
struct Scanner : Device {};
struct Printer : Device {};
struct MFP : Scanner, Printer {};   // 多功能事務機:同時是 Scanner 和 Printer

MFP 透過兩條路徑繼承了 Device,於是它的物件裡會有兩份 Device::idmfp.id 會直接編譯失敗(ambiguous,有歧義),你得寫 mfp.Scanner::idmfp.Printer::id 來指定是哪一份——這幾乎一定不是你要的。

動手算一下:虛擬繼承怎麼把兩份合成一份

解法是虛擬繼承(virtual inheritance):在中間層用 virtual 繼承共同基底,告訴編譯器「不管有幾條路徑通到 Device,整個物件裡只保留一份」。

#include <cstdio>
struct Device { int id = 0; };
struct Scanner : virtual Device {};    // 注意 virtual
struct Printer : virtual Device {};    // 注意 virtual
struct MFP : Scanner, Printer {};

int main() {
    MFP m;
    m.id = 42;                          // 不再有歧義,只有一份 Device
    printf("id = %d\n", m.id);          // 42
    printf("sizeof(MFP) = %zu\n", sizeof(MFP));
}

加上 virtual 後,m.id 不再有歧義。但天下沒有白吃的午餐:虛擬繼承的實作需要額外的間接層(編譯器常用一個 vbase pointer 或在 vtable 裡存「到共享基底的位移量」),所以 MFP 會比非虛擬版本更大、存取共享基底的成員也多一次間接。算一下你會發現 sizeof(MFP) 比把 virtual 拿掉時更大——這就是「把兩份合成一份」要付的代價。實務建議:多重繼承盡量只用在「繼承多個純抽象介面(只有純虛擬函式、沒有資料成員)」的場景,那樣根本不會有資料重複的菱形問題,也就用不到虛擬繼承的複雜度。

RTTI:dynamic_cast 與 typeid 在執行期問「你到底是誰」

既然型別資訊(透過 vptr → vtable)藏在多型物件裡,C++ 就能提供執行期型別資訊(RTTI,Run-Time Type Information),讓你在執行期安全地把基底指標「向下轉型」成衍生指標。

#include <cstdio>
#include <typeinfo>

struct Shape { virtual ~Shape() = default; };
struct Circle : Shape { void roll() { printf("rolling\n"); } };
struct Square : Shape {};

void try_roll(Shape* s) {
    // dynamic_cast:失敗回傳 nullptr(對指標)或丟 bad_cast(對參考)
    if (Circle* c = dynamic_cast<Circle*>(s)) {
        c->roll();
    } else {
        printf("這不是圓,滾不動\n");
    }
}

int main() {
    Circle c; Square s;
    try_roll(&c);   // rolling
    try_roll(&s);   // 這不是圓,滾不動
    printf("c 的型別名稱:%s\n", typeid(c).name());  // 實作相關,可能是亂碼
}

dynamic_cast<Circle*>(s) 會在執行期檢查 s 真正指向的物件是不是 Circle(或其衍生)。是,回傳正確調整過的指標;不是,回傳 nullptrstatic_cast 不會做這個檢查——它在編譯期就決定,轉錯了是未定義行為。typeid(x) 則回傳一個 std::type_info,可拿來比較兩物件型別是否相同。

但要注意兩件事:第一,RTTI 只對多型型別(有虛擬函式的類別)有意義,因為它要靠 vptr 找型別資訊;對沒有 virtual 的型別,dynamic_cast 會編譯失敗。第二,頻繁使用 dynamic_cast 常是設計警訊。如果你在一連串 if (dynamic_cast<A>) ... else if (dynamic_cast<B>) ... 裡分流行為,多半代表你該把這些行為變成基底的虛擬函式,讓多型自己分派,而不是手動問型別。dynamic_cast 留給「真的只能在執行期才知道型別、且無法用虛擬函式表達」的少數場合。

不用繼承也能多型:CRTP 與型別抹除

入門篇的多型是執行期的(靠 vtable),但 C++ 還有兩條更現代、更省成本的路。

第一條是 CRTP(Curiously Recurring Template Pattern,奇異遞迴模板模式):讓基底以「衍生類別本身」為模板參數。它在編譯期就把呼叫綁定到正確版本,沒有 vtable、沒有間接呼叫,可被完全內聯。

#include <cstdio>
template <typename Derived>
struct Shape {
    void describe() {
        // 編譯期就把 this 轉成真正的衍生型別來呼叫
        printf("面積 = %f\n", static_cast<Derived*>(this)->area());
    }
};

struct Circle : Shape<Circle> {
    double r;
    Circle(double r) : r(r) {}
    double area() const { return 3.14159 * r * r; }
};

int main() {
    Circle c(2.0);
    c.describe();   // 面積 = 12.56636,零虛擬呼叫成本
}

這叫靜態多型(static polymorphism):行為依型別而變,但分派發生在編譯期,是零執行期成本的抽象。代價是失去「把不同型別放進同一個 vector<Shape*> 統一處理」的能力——CRTP 的每個 Shape<Circle>Shape<Square>不同型別,沒有共同基底可以指向。

第二條路反過來:當你確實需要把異質型別裝進同一個容器、卻又不想強迫它們繼承某個基底,可以用型別抹除(type erasure)。標準庫的 std::function 就是現成例子——它能裝下任何「可呼叫且簽章相符」的東西(函式、lambda、函式物件),這些型別之間毫無繼承關係:

#include <functional>
#include <vector>
#include <cstdio>

int main() {
    std::vector<std::function<int(int)>> ops;
    ops.push_back([](int x){ return x + 1; });        // lambda
    ops.push_back([](int x){ return x * x; });        // 另一個無關的 lambda
    for (auto& op : ops) printf("%d ", op(5));         // 6 25
}

型別抹除的內部其實也用了多型(在實作裡藏了一個小型 vtable 概念),但對使用者而言介面乾淨、無侵入——你不必為了被裝進容器而改變既有型別的繼承關係。這代表了現代 C++ 的設計趨勢:用組合與泛型取代深繼承階層

重點回顧

  1. 沒有 virtual 的物件 = 成員 + padding,一個 byte 都不多;對齊規則會插入填充位元組,重排成員可省空間。
  2. 第一個虛擬函式會在物件開頭塞入 vptr(每物件一份),指向每類別共用的 vtable;多型的「型別資訊」就是這個指標。
  3. 別在建構子/解構子裡依賴虛擬分派:vptr 隨建構由基底往衍生逐層設定,那一刻物件「還不是」最終型別。
  4. 多重繼承會讓 this 指標位移,菱形繼承會讓共同基底出現兩份;用虛擬繼承合成一份,但要付出更大物件與額外間接的代價,故多重繼承宜限於純介面。
  5. dynamic_cast/typeid 靠 vptr 運作、只對多型型別有效;頻繁向下轉型通常是「該用虛擬函式」的設計警訊。需要零成本多型時走 CRTP(編譯期),需要異質容器又不想侵入繼承時走型別抹除(如 std::function)。

深入探討(研究所視角)

Itanium C++ ABI:抽象規則背後的具體合約

C++ 標準只規定「多型該有什麼行為」,從不規定 vptr 放在哪、vtable 怎麼排——這些屬於 ABI(Application Binary Interface,應用程式二進位介面) 的範疇。Linux/macOS 上 GCC 與 Clang 共同遵循 Itanium C++ ABI,它精確規定了:vptr 放在物件(或其最左基底子物件)的偏移 0;vtable 不只存函式指標,還在負偏移處存了 RTTI 指標offset-to-top(從目前子物件回到完整物件起點的位移,多重繼承下做指標調整時要用)。MSVC 則用另一套不相容的 ABI——這正是為什麼一個用 GCC 編的 .so 不能直接被 MSVC 程式以 C++ 介面連結,也是「跨編譯器邊界只敢用 C 介面(extern "C")」這條工程鐵律的根源。當你 dynamic_cast 跨越多重繼承層級,背後就是 ABI 規定的 __dynamic_cast runtime 函式在沿著 offset-to-top 與 RTTI 鏈走訪、計算正確的指標位移。

虛擬分派的真實成本:不是那兩次讀取,而是內聯的喪失

入門篇說虛擬呼叫多「兩次記憶體讀取加一次間接跳轉」,在記憶體都在快取裡時,這幾個週期其實微不足道。真正昂貴的是它阻斷了內聯(inlining)。編譯器面對 p->f() 無法在編譯期確定要呼叫哪個 f,於是無法把函式本體展開到呼叫點,連帶失去後續一整串依賴內聯的最佳化(常數傳播、迴圈向量化等)。一個放在百萬次熱迴圈裡的非必要 virtual,慢的往往不是那次間接跳轉本身,而是「這個迴圈本來可以被向量化、現在不行了」。現代編譯器會做去虛擬化(devirtualization):當它能在編譯期證明實際型別(例如對具體物件呼叫、或加了 final 讓覆寫鏈封死),就把虛擬呼叫還原成直接呼叫甚至內聯。所以 final 不只是「禁止再被覆寫」的設計意圖宣告,更是給編譯器的最佳化提示——把可能被覆寫的類別或函式標 final,常能讓編譯器安心去虛擬化。

從 OOP 到「資料導向設計」:當物件模型成為瓶頸

把「一個物件的所有欄位綁在一起」(Array of Structs,AoS)在物件導向裡很自然,但對 CPU 快取常是災難。當你只想對一百萬個粒子的 x 座標做運算,AoS 佈局讓你每讀一個 x 就把整個粒子(連同用不到的速度、顏色、質量)拉進快取行,有效頻寬被無關欄位稀釋。資料導向設計(Data-Oriented Design,DOD) 主張改用 Struct of Arrays(SoA):把所有 x 放一個連續陣列、所有 y 放另一個。這樣對 x 的批次運算能完美利用快取與 SIMD(單指令多資料)。這不是要你放棄類別,而是提醒:封裝是給「人」讀的抽象,記憶體佈局是給「機器」跑的現實,兩者有時拉扯。高效能領域(遊戲引擎的 ECS 架構、科學計算)常刻意打破「一物件一結構」的直覺,把資料依存取模式而非依概念歸屬來組織。理解了本文的物件佈局,你才有能力判斷:什麼時候該信任 OOP 的抽象,什麼時候該為了硬體現實退一步、用更扁平的資料佈局換取數量級的效能。這正是從「會用 C++ 寫物件」邁向「為特定硬體寫出極致 C++」的分水嶺。

AI 共讀助教正在陪你讀:C++ 物件模型探祕:vptr、菱形繼承與不靠繼承的多型
嗨!我是這篇文章的共讀助教,只根據〈C++ 物件模型探祕:vptr、菱形繼承與不靠繼承的多型〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。