原來 C++ 虛函數(shù)是這樣實現(xiàn)的!
"為什么我的程序這么占內(nèi)存?"
"虛函數(shù)到底是怎么實現(xiàn)的?"
"多態(tài)背后的原理是什么?"
如果你也有這些疑問,那么這篇文章正是為你準(zhǔn)備的。讓我們拋開枯燥的概念講解,通過一個有趣的故事,一步步揭開 C++ 對象模型的神秘面紗。
C++ 對象模型
"誒,小王??!" 老張端著冒著熱氣的咖啡杯走進(jìn)辦公室,眼睛里閃著神秘的光 ?? "今天咱們來聊個有意思的歷史故事~"
小王從鍵盤上抬起頭,一臉困惑:"歷史?這和代碼有什么關(guān)系?" ??
老張神秘地笑了笑:"你猜猜看,咱們天天用的 C++,最開始是怎么實現(xiàn)的?"
"這還用猜嗎?" 小王信心滿滿,"肯定是直接編譯成機(jī)器碼??!" ??
"嘿嘿,不對哦~" 老張喝了口咖啡,露出高深莫測的笑容,"C++ 最初其實是個'翻譯官',叫 Cfront,它的工作就是把 C++ 代碼翻譯成 C 代碼!" ??
"不會吧!" 小王瞪大了眼睛 ?? "這不是多此一舉嗎?"
老張搖搖手指:"聰明著呢!Stroustrup 大師當(dāng)年在貝爾實驗室可是深謀遠(yuǎn)慮啊。你想啊,C 編譯器都已經(jīng)很成熟了,何必重復(fù)造輪子呢?而且這樣一來,C++ 代碼還能和 C 代碼愉快地玩耍,簡直是一箭雙雕!" ??
"來來來," 老張站起身,走到白板前,"讓我給你變個魔術(shù),看看 C++ 代碼是怎么'變身'的~" ?
老張揮舞著馬克筆,在白板上畫出一段優(yōu)雅的 C++ 代碼:
// 這是一個典型的 C++ 類定義 ???
class Rectangle {
public:
// 構(gòu)造函數(shù),初始化寬和高 ?
Rectangle(int w, int h) : width(w), height(h) {}
// 計算面積的成員函數(shù) ??
int area() { return width * height; }
private:
int width; // 矩形的寬 ??
int height; // 矩形的高 ??
};
"這段 C++ 代碼經(jīng)過編譯器處理后,本質(zhì)上會變成這樣的 C 代碼" 老張微笑著說
// C 語言結(jié)構(gòu)體定義 ??
struct Rectangle {
int width; // 存儲寬度
int height; // 存儲高度
};
// 構(gòu)造函數(shù)被轉(zhuǎn)換為普通的 C 函數(shù) ???
void Rectangle_Rectangle(
struct Rectangle* this,
int w,
int h
) {
this->width = w;
this->height = h;
}
// 成員函數(shù)也變成普通的 C 函數(shù) ??
int Rectangle_area(struct Rectangle* this) {
returnthis->width * this->height;
}
小王恍然大悟:"原來如此!C++ 的類其實就是在 C 的基礎(chǔ)上做了語法糖??!"
老張點點頭:"是的!所有的成員函數(shù)都會被轉(zhuǎn)換成普通的 C 函數(shù),只是多了一個 this 指針參數(shù)。這就是為什么我們說,要真正理解 C++,必須先理解 C 語言的基礎(chǔ)。"
"那繼承是怎么實現(xiàn)的呢?" 小王充滿好奇地問道 ??
老張笑著說 ??:"這個問題問得好!讓我們繼續(xù)深入了解 C++ 對象模型的奧秘..."
簡單對象模型
"來,小王," 老張放下熱騰騰的咖啡 ??,拿起馬克筆 ??,"讓我們來了解一下 C++ 最初的對象模型是什么樣的。這個模型雖然簡單,但對理解繼承特別有幫助哦!" ??
小王立刻坐直了身體,眼睛閃閃發(fā)亮?
"知道為什么現(xiàn)在的 C++ 類寫起來這么優(yōu)雅嗎?" 老張神秘地笑著說 ??♂?,"這還得從上古時代說起..."
老張在白板上寫下一段代碼 ????:
// 一個簡單的學(xué)生類 ????
class Student {
std::string name; // 存儲學(xué)生姓名 ??
int score; // 記錄考試分?jǐn)?shù) ??
void study(); // 學(xué)習(xí)方法 ??
void doHomework(); // 寫作業(yè)功能 ??
};
"猜猜看,在 C++ 剛誕生的年代,Stroustrup 大師是怎么實現(xiàn)這個類的?" 老張眨眨眼 ??
小王搖搖頭 ??:"難道...和現(xiàn)在不一樣嗎?"
"哈哈,那時候為了簡化編譯器的實現(xiàn),他們設(shè)計了'簡單對象模型'" ??
老張畫出了內(nèi)部實現(xiàn)示意圖 ??:
// 在內(nèi)存中的實際表示
struct Student_Internal {
void* name_ptr; // 指向?qū)嶋H的 string 數(shù)據(jù)
void* score_ptr; // 指向?qū)嶋H的 int 數(shù)據(jù)
void* study_ptr; // 指向 study 函數(shù)
void* homework_ptr; // 指向 homework 函數(shù)
};
"看到?jīng)]?" 老張指著圖說 ??,"所有成員,不管是數(shù)據(jù)還是函數(shù),統(tǒng)統(tǒng)變成指針!就像一個巨大的導(dǎo)航表 ??? ??"
小王瞪大了眼睛 ??:"等等...那豈不是一個簡單的 int 也要用指針來存?" ??
"沒錯!" 老張笑著說 ??,"想象一下,你點外賣 ??,每個菜都要先送到隔壁小區(qū),然后給你一張地址條 ??,告訴你:'你的紅燒肉在A棟3層 ??,炒青菜在B棟5層...' ?? ??♂?"
"這...這不是很浪費(fèi)嗎?" 小王忍不住笑了 ??
"所以啊!" 老張喝了口咖啡 ??,"假設(shè)我們創(chuàng)建一個學(xué)生對象 ????:"
// 創(chuàng)建一個學(xué)生對象
Student student;
// 在內(nèi)存中占用 32 字節(jié)
// 因為有 4 個指針,每個指針 8 字節(jié)
// 4 × 8 = 32 字節(jié) ??
"原本一個 int 只需要 4 字節(jié) ??,現(xiàn)在卻要用 8 字節(jié)的指針去指向它。就像點個炒青菜 ??,還得配個專職導(dǎo)游 ????!" 老張搖頭晃腦地說 ??
"而且啊," 老張拿起筆又在白板上寫道 ??,"你想想訪問成員時會有多麻煩 ??:"
// 簡單對象模型下訪問成員的復(fù)雜過程 ??
void useStudent(Student* s) {
// 第一步:定位 score 指針的地址 ??
void** score_ptr_addr = (void**)((char*)s + sizeof(void*));
// 第二步:通過指針找到實際分?jǐn)?shù) ??
int* real_score = (int*)(*score_ptr_addr);
// 第三步:終于可以修改分?jǐn)?shù)了 ??
*real_score = 100;
// 調(diào)用方法更復(fù)雜 ??
// 第一步:找到函數(shù)指針的地址 ??
void** study_ptr_addr = (void**)((char*)s + 2 * sizeof(void*));
// 第二步:獲取實際的函數(shù)指針 ??
void (*study_func)(Student*) =
(void (*)(Student*))*study_ptr_addr;
// 第三步:調(diào)用函數(shù) ??
study_func(s);
}
"天哪!" 小王驚呼 ??,"就改個分?jǐn)?shù)要這么多步驟?"
"是啊!" 老張點點頭 ??,"現(xiàn)在我們寫student.score = 100 或student.study() 這么簡單 ?,但在簡單對象模型下,編譯器要做的工作可復(fù)雜了 ??。每次訪問成員都要進(jìn)行兩次內(nèi)存尋址:一次找到指針 ??,一次通過指針找到實際數(shù)據(jù) ??。"
"而且這還不是全部問題 ??," 老張繼續(xù)說,"想象一下,如果要實現(xiàn)虛函數(shù),還得再加一層間接尋址。性能損失就更大了 ??。"
"那后來呢?" 小王來了興趣 ??
"后來當(dāng)然是改進(jìn)啦!這就像餐廳最后想通了 ??:'與其把菜放在各個地方,還不如直接送到客人桌上呢!'" 老張眨眨眼 ??,"這就是我們現(xiàn)在用的內(nèi)存模型,數(shù)據(jù)直接存在對象里,該多大就多大。"
"不過呢," 老張神秘地補(bǔ)充道 ??,"這個看似笨拙的想法,后來卻啟發(fā)了'成員指針'的設(shè)計。這又是另一個有趣的故事了..." ?
小王托著下巴 ??:"老張,您這故事講得,把我都聽餓了..." ?? ??
"哈哈,那正好!" 老張站起身 ??,"我請你吃飯 ??,路上再給你講講成員指針的故事!" ???
表格驅(qū)動對象模型
"吃飽了吧?" 老張笑瞇瞇地問道 ??,"現(xiàn)在讓我們繼續(xù)講講 C++ 對象模型的演進(jìn)。" ??
小王滿足地點點頭 ??:"剛才您說到簡單對象模型的缺點,后來是怎么改進(jìn)的呢?" ??
"在簡單對象模型之后,出現(xiàn)了一個叫'表格驅(qū)動對象模型'的設(shè)計。" 老張興致勃勃地在白板上畫起了新的示意圖 ??:
// 學(xué)生類的原始定義 ????
class Student {
std::string name; // 學(xué)生姓名
int score; // 學(xué)生分?jǐn)?shù)
void study(); // 學(xué)習(xí)方法
void doHomework(); // 做作業(yè)方法
};
// ===== 表格驅(qū)動模型的內(nèi)部實現(xiàn) =====
// 數(shù)據(jù)部分:專門存儲成員變量 ??
struct Student_Data {
std::string name; // 直接存儲姓名
int score; // 直接存儲分?jǐn)?shù)
};
// 表格部分:管理數(shù)據(jù)和函數(shù) ??
struct Student_Table {
// 指向?qū)嶋H數(shù)據(jù)的指針
Student_Data* data;
// 指向函數(shù)表的指針(存儲所有成員函數(shù))
void** function_table;
};
"看出區(qū)別了嗎?" 老張指著圖說 ??,"這個模型把數(shù)據(jù)和函數(shù)分開存儲。數(shù)據(jù)直接存在對象里,函數(shù)則通過一個表來管理。" ???
"這樣做有什么好處呢?" 小王充滿好奇地問道 ??
"首先,數(shù)據(jù)訪問變快了!" 老張興奮地解釋道 ??,"因為數(shù)據(jù)直接存儲,不用再通過指針間接訪問。其次,這種設(shè)計為后來的虛函數(shù)表鋪平了道路。" ?
"啊,原來虛函數(shù)表是從這里來的!" 小王恍然大悟 ??
"沒錯," 老張欣慰地點頭 ??,"這就是為什么我們說要理解 C++ 的歷史演進(jìn)。每個設(shè)計都不是憑空出現(xiàn)的,都是在解決實際問題中逐步優(yōu)化的結(jié)果。" ??
"不過啊," 老張喝了口咖啡繼續(xù)說 ??,"表格驅(qū)動模型雖然比簡單對象模型好,但還是存在一些問題。" ??
小王認(rèn)真地聽著 ??
"讓我給你列舉幾個主要問題:" 老張掰著手指數(shù)道 ??
// 問題演示 1: 內(nèi)存占用問題 ??
struct Student_Table {
Student_Data* data; // 8字節(jié)
void** function_table; // 8字節(jié)
}; // 總共需要 16 字節(jié)!
// 問題演示 2: 訪問效率問題 ??
void example() {
Student s;
// 訪問數(shù)據(jù)要兩次解引用
int score = s.table->data->score;
// 調(diào)用函數(shù)更復(fù)雜
void (*func)() = s.table->function_table[0];
func();
}
"你看," 老張指著代碼說 ??,"首先是內(nèi)存問題。即使一個空類,也要占用至少 16 個字節(jié)來存儲兩個指針!" ??
"其次是性能問題," 老張繼續(xù)解釋 ??,"每次訪問數(shù)據(jù)都要經(jīng)過兩次指針跳轉(zhuǎn),調(diào)用函數(shù)更是要先找表,再找函數(shù)..."
"這不就和簡單對象模型一樣慢嗎?" 小王皺眉道 ??
"沒錯!" 老張點點頭 ??,"所以后來就有了現(xiàn)代 C++ 對象模型,它采用了一種更聰明的方式。" ?
"那現(xiàn)代的對象模型又是什么樣的呢?" 小王繼續(xù)追問 ??
"這個嘛..." 老張神秘地笑了笑 ??,"我們下次再講。" ?? ?
現(xiàn)代 C++ 對象模型
"說到現(xiàn)代 C++ 對象模型," 老張站起來走到白板前,"這可是 Stroustrup 大師經(jīng)過反復(fù)權(quán)衡后的杰作。"
"它有什么特別之處呢?" 小王問道。
"我們用一個經(jīng)典的例子來說明。" 老張在白板上寫道:
// 現(xiàn)代 C++ 對象模型示例 ??
class ModernStudent {
// 數(shù)據(jù)直接存儲在對象中 ??
std::string name;
int score;
// 普通函數(shù)直接編譯為獨(dú)立函數(shù) ??
void study() {
// 直接訪問數(shù)據(jù)成員
score += 10;
}
// 虛函數(shù)才使用虛表 ??
virtual void doHomework() {
// 通過虛表調(diào)用
}
private:
// 只有需要多態(tài)的類才有虛表指針
void* vptr; // 虛函數(shù)表指針 ??
};
"現(xiàn)代對象模型的特點是:" 老張總結(jié)道 ??
- 數(shù)據(jù)成員直接存儲 ??
- 普通成員函數(shù)獨(dú)立存儲 ??
- 只在需要多態(tài)時才使用虛表 ??
- 最大限度減少間接訪問 ??
"這樣既保證了性能,又支持了 C++ 的所有特性!" 老張笑著說 ??
"原來是這樣!" 小王恍然大悟 ??,"那這就解釋了為什么有些類比其他類占用內(nèi)存更多 - 因為它們需要虛表指針!"
"聰明!" 老張贊許地點點頭 ??,"這就是為什么我們說 - 要理解 C++ 的性能特性,必須先理解它的對象模型。" ??
"那虛表具體是怎么工作的呢?" 小王繼續(xù)追問 ??
"這個問題問得好!" 老張眼睛一亮 ?,"不過這是另一個精彩的話題了,我們下次再聊..."
小王若有所思地點點頭 ??,"感覺 C++ 每個特性背后都有這么多故事啊!"
"是啊," 老張笑道 ??,"這就是 C++ 的魅力所在。它的每個設(shè)計決策,都是在實踐中不斷優(yōu)化的結(jié)果。" ??
虛函數(shù)表工作原理
"說到虛函數(shù)表啊..." 老張放下咖啡杯,眼睛里閃著光 ?,"這可是 C++ 里最精妙的設(shè)計之一。"
"為什么這么說?" 小王好奇地問道 ??
"想啊," 老張拿起馬克筆走向白板,"C++ 需要在運(yùn)行時才能確定調(diào)用哪個函數(shù),但又要保證性能不受太大影響。這就像餐廳里的點菜系統(tǒng),既要讓客人能隨時換菜,又不能讓服務(wù)員跑來跑去問廚師 - 這可怎么辦呢?" ??♂?
"??!就像餐廳的電子菜單?" 小王眼前一亮 ??
"沒錯! ??" 老張開心地笑著說 ??, "虛函數(shù)表就像是每個類的'專屬菜單' ??。來,讓我給你畫個生動的例子..." ?
// 動物基類 ??
class Animal {
public:
// 構(gòu)造函數(shù),初始化動物名字 ???
Animal(conststd::string& name) : _name(name) {}
// 虛析構(gòu)函數(shù),確保正確釋放內(nèi)存 ???
virtual ~Animal() = default;
// 純虛函數(shù),所有動物都要實現(xiàn)發(fā)聲 ??
virtual void makeSound() = 0;
protected:
std::string _name; // 動物的名字 ??
private:
staticint population; // 所有動物的數(shù)量統(tǒng)計 ??
};
// 貓咪類 - 繼承自動物 ??
class Cat :public Animal {
public:
// 構(gòu)造小貓咪 ??
Cat(conststd::string& name) : Animal(name) {}
// 實現(xiàn)貓咪的叫聲 ??
virtual void makeSound() override {
std::cout << "喵喵喵~" << std::endl;
}
private:
int _lives = 9; // 貓有9條命 ?
};
"在現(xiàn)代 C++ 中 ??," 老張拿起馬克筆繼續(xù)畫圖 ??, "這些類在內(nèi)存中的布局是這樣的:"
// Animal 在內(nèi)存中的實際布局 ??
struct Animal_Layout {
void* vptr; // 虛函數(shù)表指針 ??
std::string _name; // 名字成員變量 ??
};
// Cat 在內(nèi)存中的實際布局 ??
struct Cat_Layout {
Animal_Layout base; // 繼承的基類部分 ??
int _lives; // Cat獨(dú)有的成員 ??
};
// 類外部的靜態(tài)成員 ??
static int Animal::population; // 存儲在數(shù)據(jù)段中
"這個設(shè)計展示了幾個超級重要的特點 ??:" 老張指著白板興奮地說 ??:
- 虛表指針總是在最前面 ??
- 基類成員排在前面 ??
- 派生類成員在后面 ??
- 虛函數(shù)通過表格查找調(diào)用 ??
"這樣的設(shè)計既高效又靈活 ??,是 C++ 智慧的結(jié)晶呢!" 老張總結(jié)道 ?
如何訪問虛函數(shù)表?
"讓我們一步步看看虛函數(shù)調(diào)用背后發(fā)生了什么," 老張拿起馬克筆 ??, "首先創(chuàng)建一個貓咪對象:"
Cat kitty("咪咪"); // 創(chuàng)建貓咪對象
"當(dāng)我們創(chuàng)建這個對象時," 老張解釋道 ????, "編譯器會自動初始化虛表指針(vptr),指向 Cat 類的虛函數(shù)表。"
"接下來,當(dāng)我們調(diào)用虛函數(shù)時:"
kitty.makeSound(); // 看起來很簡單的一行代碼
"但在底層,編譯器會生成一系列復(fù)雜的操作。首先是獲取虛表指針:"
// 第一步:獲取對象的虛表指針
void** vptr = *(void***)(&kitty); // 從對象內(nèi)存布局的開始位置讀取虛表指針
// 指針層次分析:
// === 第1步:獲取對象地址 ===
Cat* cat_ptr = &kitty;
// cat_ptr 現(xiàn)在指向?qū)ο蟮钠鹗嘉恢?// 因為虛表指針總是在對象的最開始位置,所以這個地址
// 實際上就指向了虛表指針的存儲位置
// === 第2步:轉(zhuǎn)換為 void*** 類型 ===
void*** triple_ptr = (void***)cat_ptr;
// 為什么要轉(zhuǎn)換成 void***?
// - 因為我們要通過這個指針去讀取虛表指針
// - 虛表指針本身的類型是 void**
// - 所以指向虛表指針的指針就是 void***
// === 第3步:解引用獲取虛表指針 ===
void** vptr = *triple_ptr;
// 現(xiàn)在 vptr 就是真正的虛表指針了
// - 它指向了函數(shù)指針數(shù)組(虛函數(shù)表)
// - 類型是 void**,因為它指向的是函數(shù)指針數(shù)組
// 內(nèi)存布局示意:
/*
內(nèi)存地址 內(nèi)容 類型
0x1000 [ 虛表指針 ] void** <-- cat_ptr/triple_ptr 指向這里
[ name成員 ] string
[ lives成員 ] int
0x2000 [ 析構(gòu)函數(shù)指針 ] void* <-- vptr 指向這里(虛函數(shù)表的開始)
[ makeSound指針 ] void*
[ eat指針 ] void*
[ purr指針 ] void*
*/
"讓我們詳細(xì)解釋一下這個指針轉(zhuǎn)換過程:" 老張拿起馬克筆畫起示意圖 ??
// 假設(shè)有一個 Cat 對象
Cat kitty("咪咪");
// 獲取虛表指針的詳細(xì)步驟分解
void** vptr = *(void***)(&kitty);
/* 讓我們一步步解析這行代碼:
1. &kitty 得到 Cat* 類型
- 這是對象的起始地址
- 因為虛表指針在對象的開頭,所以這個地址就是虛表指針的位置
2. (void***) 轉(zhuǎn)換
- 為什么需要 void***?
- 因為我們要:
a) 首先通過指針訪問對象 (第一個*)
b) 對象開頭存儲的是虛表指針 (第二個*)
c) 虛表本身是函數(shù)指針數(shù)組 (第三個*)
- 這就像一個三層的包裝盒:
最外層: 對象地址
中間層: 虛表指針
最內(nèi)層: 函數(shù)指針數(shù)組
3. *(void***) 解引用
- 這一步實際獲取了虛表指針
- 結(jié)果類型是 void**,正好是函數(shù)指針數(shù)組的類型
*/
"這樣理解起來容易多了吧?" 老張問道 ??
"哦~" 小王恍然大悟 ??,"原來這些指針操作是為了層層剝開對象的結(jié)構(gòu),最終找到虛函數(shù)表!"
"這里用了兩次指針轉(zhuǎn)換," 老張指著代碼說 ??, "&kitty 得到對象地址,然后通過指針轉(zhuǎn)換和解引用,找到虛表指針。"
"讓我用一個三維數(shù)組的類比來幫大家更好地理解這個指針結(jié)構(gòu)," 老張補(bǔ)充道 ????
// 想象一個三維數(shù)組結(jié)構(gòu)
Class[N][M][K] objects;
/*
三個維度分別代表:
第一維 [N]: 不同的類
- 每個類都有自己的虛函數(shù)表
- 比如 Animal、Cat、Dog 等類
第二維 [M]: 虛函數(shù)表
- 存儲了該類所有的虛函數(shù)指針
- 包括繼承的、覆寫的和新增的函數(shù)
第三維 [K]: 具體的函數(shù)指針
- 指向?qū)嶋H的函數(shù)實現(xiàn)
- 比如 makeSound、eat 等方法
訪問過程就像在這個三維空間中導(dǎo)航:
1. 通過對象找到對應(yīng)的類 (第一維)
2. 獲取該類的虛函數(shù)表 (第二維)
3. 在表中找到具體的函數(shù)指針 (第三維)
*/
// 用代碼表示這個訪問過程
void callVirtualFunction(Cat* obj, int funcIndex) {
// 第一維:通過對象找到類的虛表指針
void*** classPtr = (void***)obj;
// 第二維:獲取虛函數(shù)表
void** vtable = *classPtr;
// 第三維:獲取具體函數(shù)指針
void* funcPtr = vtable[funcIndex];
// 調(diào)用函數(shù)
((void(*)(Cat*))funcPtr)(obj);
}
"看到了嗎?" 老張指著圖說 ?? "就像在一個三維空間中導(dǎo)航:"
- "第一維就像一個類的博物館 ???,每個展廳都是一個不同的類"
- "第二維就像每個展廳里的展示柜 ??,里面陳列著該類的所有虛函數(shù)"
- "第三維就是展示柜中的具體展品 ??,也就是實際的函數(shù)實現(xiàn)"
"當(dāng)我們通過對象調(diào)用虛函數(shù)時,就像是在這個三維空間中找到正確的'展品'。" 老張解釋道 ???
"?。∵@么一說就清楚多了!" 小王眼睛一亮 ?? "每次解引用就是在不同維度間穿梭!"
如何通過虛函數(shù)表獲取函數(shù)地址?
"接著是在虛表中查找函數(shù)地址:"
// 第二步:在虛表中查找函數(shù)地址
void (*makeSound)(Cat*) = vptr[1]; // 虛表中查找 makeSound 函數(shù)指針
"虛表就像一個函數(shù)指針數(shù)組," 老張繼續(xù)解釋 ??, "每個虛函數(shù)在表中都有固定的位置。這里的 [1] 表示 makeSound 在虛表中的偏移位置。"
"等等," 小王突然舉手問道 ??, "這個 [1] 是怎么來的?為什么是 1 而不是其他數(shù)字?"
"??!問得好!" 老張笑著說 ??, "虛表中的函數(shù)位置是在編譯時就確定好的。讓我詳細(xì)解釋一下..."
老張在白板上畫起了新的示意圖:
// Animal 類的虛函數(shù)表布局 ??
struct Animal_VTable {
// [0] 析構(gòu)函數(shù)永遠(yuǎn)在第一個位置
void (*destructor)(Animal*);
// [1] makeSound 在第二個位置
void (*makeSound)(Animal*);
// 如果還有其他虛函數(shù),繼續(xù)往后排...
};
// Cat 類繼承并覆寫了這些函數(shù)
struct Cat_VTable {
// [0] Cat 的析構(gòu)函數(shù)
void (*destructor)(Cat*);
// [1] Cat 的 makeSound 實現(xiàn)
void (*makeSound)(Cat*);
};
"編譯器遵循以下規(guī)則來安排虛函數(shù)的位置 ??:" 老張解釋道:
- "虛析構(gòu)函數(shù)總是位于索引 [0] 的位置 ??"
- "其他虛函數(shù)按照它們在基類中首次聲明的順序排列 ??"
- "派生類如果覆寫了基類的虛函數(shù),就使用相同的位置 ??"
- "派生類新增的虛函數(shù)放在表的末尾 ??"
"比如說,如果我們擴(kuò)展一下這個例子:" 老張繼續(xù)寫道:
class Animal {
public:
virtual ~Animal(); // 位置 [0]
virtual void makeSound() = 0; // 位置 [1]
virtual void eat(); // 位置 [2]
};
class Cat : public Animal {
public:
virtual ~Cat(); // 位置 [0]
virtual void makeSound() override;// 位置 [1]
virtual void eat() override; // 位置 [2]
virtual void purr(); // 位置 [3] - Cat特有
};
"所以當(dāng)我們調(diào)用kitty.makeSound() 時,編譯器知道 makeSound 在位置 [1],這是在編譯時就確定好的,運(yùn)行時直接用這個固定位置去查找,非常高效!" 老張總結(jié)道 ??
"原來如此!" 小王恍然大悟 ??, "這就像圖書館的分類系統(tǒng),每本書都有固定的位置編號!"
"沒錯!" 老張點頭贊許 ??, "而且這種設(shè)計還保證了即使基類添加了新的虛函數(shù),已有的函數(shù)位置也不會改變,這對二進(jìn)制兼容性非常重要。"
"最后,才是實際調(diào)用函數(shù):"
// 第三步:調(diào)用找到的函數(shù)
makeSound(&kitty); // 傳入 this 指針調(diào)用函數(shù)
"看到了嗎?" 老張總結(jié)道 ??, "一個簡單的虛函數(shù)調(diào)用,背后其實包含了三個關(guān)鍵步驟:
- 獲取虛表指針
- 查找函數(shù)地址
- 調(diào)用目標(biāo)函數(shù)
這就是為什么虛函數(shù)調(diào)用會比普通函數(shù)調(diào)用慢一點 - 它需要額外的間接尋址操作。"
小王恍然大悟 ??: "原來如此!這就解釋了為什么有些性能敏感的代碼會避免使用虛函數(shù)。"
"沒錯!" 老張點點頭 ??, "不過現(xiàn)代 CPU 的分支預(yù)測已經(jīng)很強(qiáng)大了,所以除非在特別關(guān)鍵的性能熱點,否則虛函數(shù)的開銷通常不會造成明顯影響。"
"這比之前的簡單對象模型和表格驅(qū)動模型高明多了!" 小王驚嘆道。
"是的," 老張點頭,"這個設(shè)計既保證了性能,又支持了多態(tài)。"
同類對象的虛函數(shù)表共享機(jī)制
"對了老張!" 小王突然想到一個問題 ??,"每個對象都有一個虛表指針,那虛函數(shù)表本身是每個對象都有一份嗎?"
老張笑著搖搖頭 ??:"這個問題問得好!虛函數(shù)表是由編譯器為每個類創(chuàng)建的,而不是每個對象。所有同類型的對象共享同一個虛函數(shù)表!"
"讓我畫個圖解釋一下:" 老張走向白板 ??:
// 內(nèi)存布局示意圖 ??
// 代碼段(只讀)中存儲的虛函數(shù)表
const Cat_VTable { // ?? 所有 Cat 對象共享這個表
&Cat::destructor, // [0]
&Cat::makeSound, // [1]
&Cat::eat, // [2]
&Cat::purr // [3]
};
// 堆/棧中的對象
Cat cat1("咪咪"); // ?? 對象1
// {
// vptr -> Cat_VTable // 指向共享的虛函數(shù)表
// name: "咪咪"
// lives: 9
// }
Cat cat2("花花"); // ?? 對象2
// {
// vptr -> Cat_VTable // 指向相同的虛函數(shù)表
// name: "花花"
// lives: 9
// }
"你看," 老張指著圖解釋道 ??,"虛函數(shù)表存儲在程序的只讀數(shù)據(jù)段(.rodata)中,是只讀的。每個 Cat 對象的 vptr 都指向這同一個表。這樣設(shè)計有幾個重要好處:"
- "節(jié)省內(nèi)存 ?? - 不需要為每個對象都存儲一份完整的函數(shù)表"
- "提高緩存效率 ?? - 因為所有對象共享同一份表,增加了緩存命中率"
- "保證一致性 ? - 所有對象調(diào)用的都是同一份虛函數(shù)實現(xiàn)"
"哇!這設(shè)計真是太巧妙了!" 小王贊嘆道 ??,"那是不是說,一個程序里面,每個類只會有一份虛函數(shù)表?"
"基本上是這樣。" 老張點點頭 ??,"不過要注意,如果你的程序使用了動態(tài)庫,同一個類可能在不同的動態(tài)庫中各有一份虛函數(shù)表。但在同一個編譯單元內(nèi),確實是共享同一份表的。"
"這就像一個大餐廳的菜單系統(tǒng)," 老張舉例說 ??,"每個服務(wù)員(對象)手里都有一個平板(vptr),但他們都連接到同一個中央點餐系統(tǒng)(虛函數(shù)表)。這樣不管哪個服務(wù)員接單,都能保證點到一樣的菜!" ???
小王若有所思地點點頭 ??:"所以虛函數(shù)的內(nèi)存開銷主要是每個對象都要多存一個指針,而不是虛函數(shù)表本身?"
"聰明!" 老張贊許地說 ??,"這就是為什么在 C++ 中,一個帶有虛函數(shù)的類的對象,至少要比沒有虛函數(shù)的類多占用一個指針的大小。在 64 位系統(tǒng)上就是 8 字節(jié)。"
派生類和基類的虛函數(shù)表關(guān)系
"等等," 小王突然想到什么 ??, "那派生類和基類的虛函數(shù)表是怎么回事?它們也是共享一個表嗎?"
"啊,這個問題問得好!" 老張眼睛一亮 ?, "派生類會有自己獨(dú)立的虛函數(shù)表,而不是和基類共享。讓我畫個圖解釋一下:"
// 基類 Animal 的虛函數(shù)表
const Animal_VTable { // ?? 基類表
&Animal::destructor, // [0]
&Animal::makeSound, // [1]
&Animal::eat // [2]
};
// 派生類 Cat 的虛函數(shù)表
const Cat_VTable { // ?? 派生類有自己的表
&Cat::destructor, // [0] 覆寫的析構(gòu)函數(shù)
&Cat::makeSound, // [1] 覆寫的 makeSound
&Cat::eat, // [2] 覆寫的 eat
&Cat::purr // [3] Cat 特有的函數(shù)
};
// 內(nèi)存中的對象
Animal* p1 = new Animal(); // 基類對象
// {
// vptr -> Animal_VTable // 指向 Animal 的表
// name: "動物"
// }
Animal* p2 = new Cat(); // 通過基類指針指向派生類對象
// {
// vptr -> Cat_VTable // 指向 Cat 的表!
// name: "咪咪"
// lives: 9
// }
"看到了嗎?" 老張指著圖說 ??, "每個類都有自己的虛函數(shù)表,這樣做有幾個重要原因:"
- "多態(tài)的實現(xiàn) ?? - 當(dāng)通過基類指針調(diào)用虛函數(shù)時,實際會根據(jù)對象的真實類型找到正確的函數(shù)版本"
- "函數(shù)覆寫的支持 ?? - 派生類可以替換掉繼承來的虛函數(shù)實現(xiàn)"
- "擴(kuò)展的靈活性 ?? - 派生類可以添加新的虛函數(shù)"
"所以說," 老張繼續(xù)解釋道 ????, "雖然每個類型都有自己的虛函數(shù)表,但同一個類型的所有對象還是共享同一個表。這就是 C++ 多態(tài)的精妙之處!"
"原來如此!" 小王恍然大悟 ??, "這就像每個餐廳分店(類)都有自己的菜單(虛函數(shù)表),但同一個分店的所有服務(wù)員(對象)都用同一份菜單!"
"沒錯!" 老張笑著說 ??, "而且你注意到了嗎?即使用基類指針指向派生類對象,對象的 vptr 也是指向派生類的虛函數(shù)表。這就是為什么我們能通過基類指針正確調(diào)用派生類的函數(shù)實現(xiàn)!"
"這設(shè)計真是太巧妙了!" 小王贊嘆道 ??, "每個類一份虛函數(shù)表,每個對象一個指針,就實現(xiàn)了如此強(qiáng)大的多態(tài)機(jī)制!"
老張喝了口咖啡,笑著說道 ??: "今天講的這些只是虛函數(shù)表的基礎(chǔ)知識。要完全理解 C++ 的對象模型,還有很多有趣的話題要探討呢!"
"比如說?" 小王來了興趣 ??
"比如..." 老張神秘地眨眨眼 ??:
- 虛函數(shù)表是在什么時候、怎么創(chuàng)建的? ???
- 多重繼承時的虛函數(shù)表是什么樣的? ??
- 虛繼承又會帶來哪些特殊的內(nèi)存布局? ??
- 構(gòu)造和析構(gòu)過程中的虛函數(shù)調(diào)用又是怎么處理的? ?
"這些都是非常有趣的話題," 老張站起身來 ??♂?, "不過這些精彩的內(nèi)容,我們下次再聊..."
小王若有所思地點點頭 ??, "感覺 C++ 的每個特性背后都藏著這么多精妙的設(shè)計啊!"
"是啊," 老張笑著說 ??, "這就是為什么即使到今天,研究 C++ 的底層實現(xiàn)依然是那么有趣。"