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++ 物件模型探祕: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、菱形繼承與不靠繼承的多型〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。