經(jīng)常在編譯錯誤中看到的vTable究竟是什么忽妒?
為什么要有虛函數(shù)
C++的設(shè)計理念是:用不到的功能就不要在運(yùn)行時花費(fèi)時間。正因如此,C++中會有靜態(tài)綁定段直、動態(tài)綁定吃溅、虛函數(shù)這些概念。
對比其他一些面向?qū)ο蟮恼Z言鸯檬,可以認(rèn)為它們所有成員函數(shù)都是虛函數(shù)决侈,都是動態(tài)綁定,而C++則為了性能考慮喧务,只有實(shí)際用到時赖歌,即成員函數(shù)有virtual修飾,才開啟動態(tài)綁定蹂楣。
靜態(tài)綁定與動態(tài)綁定
所謂靜態(tài)綁定俏站,是指成員函數(shù)的地址在編譯期就可以確定讯蒲,運(yùn)行時則直接跳轉(zhuǎn)到對應(yīng)地址執(zhí)行痊土;而動態(tài)綁定則是指編譯期不確定,只有到運(yùn)行時才能找到函數(shù)地址墨林,這需要兩次額外的尋址指令:第1次找到虛表赁酝,第2次從虛表中找到函數(shù)地址。
哪些情況會出現(xiàn)動態(tài)綁定旭等?答案是只有使用指針或引用調(diào)用虛成員函數(shù)時才會出現(xiàn)酌呆。
例如:
class VirtualClass {
public:
virtual void f() {}
};
class SubClass : public VirtualClass {
virtual void f() override {}
};
int main() {
auto* p = new SubClass();
p->f(); // 動態(tài)綁定
}
之所以不能用靜態(tài)綁定,是因?yàn)閜不僅可以指向VirtualClass對象搔耕,也可以指向它的子類SubClass 隙袁,而我們在編譯期并不能確定它具體指向哪個類。
內(nèi)存布局與虛表指針
在分析虛表之前弃榨,先討論一下類的內(nèi)存布局菩收。
一個類實(shí)例需要占據(jù)一定的內(nèi)存空間,而空間的大小以及其中內(nèi)容的排布鲸睛,對同一個類的所有實(shí)例都是相同的娜饵。例如下面這個類:
class Layout {
public:
short s;
int i;
long long int l;
void f();
};
在Visual Studio的工具中可以看到它的內(nèi)存排布:
而當(dāng)我們把成員函數(shù)f改成虛函數(shù)時:
class Layout {
public:
short s;
int i;
long long int l;
virtual void f();
};
我們發(fā)現(xiàn),前8個字節(jié)增加了一個指針 vfptr
即虛表指針官辈,它指向一個虛表箱舞。從另一個角度,我們也可以理解為拳亿,Layout類的前8個字節(jié)用來標(biāo)識它的實(shí)際類型(Layout或其某個子類)晴股。
虛表
前面說到了虛表,那么虛表到底是什么呢肺魁?
虛表就是一個數(shù)組电湘,它存儲了一系列函數(shù)指針。只有包含虛函數(shù)的類才會有虛表,一個類的所有實(shí)例公用一個虛表胡桨。虛表中的每個指針則是指向這個類的所有虛函數(shù)官帘。
下面的代碼和對應(yīng)的示意圖可以看得很清楚:
class Instrument {
public:
virtual void play() {};
virtual void adjust() {};
};
class Wind : public Instrument {
public:
virtual void play() override { printf("Wind play"); }
virtual void adjust() override { printf("Wind adjust"); }
int score = 1;
};
class Brass : public Wind {
public:
virtual void play() override { printf("Brass play"); }
virtual void what() { printf("Brass what"); }
int score = 2;
};
int main() {
Instrument* list[4];
list[0] = new Instrument();
list[1] = new Wind();
list[2] = new Brass();
list[3] = new Brass();
return 0;
}
從這個例子中可以看到,當(dāng)子類重寫(override)父類的虛函數(shù)時昧谊,虛表中的對應(yīng)指針也會修改刽虹,但順序不變。當(dāng)子類新增虛函數(shù)時呢诬,則會在虛表末尾新增涌哲。
按照這樣的規(guī)則,當(dāng)我們把子類的指針或引用向上類型轉(zhuǎn)換時尚镰,它的虛表完全可以當(dāng)做時父類的虛表來使用阀圾,無需關(guān)心實(shí)際類型。
多繼承
上面說到狗唉,子類的內(nèi)存布局和虛表都兼容父類初烘,但這時又出現(xiàn)一個新的問題,如果有多繼承怎么辦分俯?如何同時兼容兩個父類呢肾筐?
其實(shí),多繼承的情況缸剪,子類會的內(nèi)存布局會將兩個父類依次排布吗铐,也就是會有兩個虛表指針。
例如:
class Flyable {
public:
virtual void fly() {}
int hight = 0;
};
class Runnable {
public:
virtual void run() {}
int speed = 0;
};
class Bird : public Flyable, public Runnable {
public:
virtual void fly() override {}
virtual void run() override {}
virtual void eat() {}
int weight = 0;
};
Bird的內(nèi)存布局如下:
從圖上可以清楚看到杏节,0x00 ~ 0x0f 這部分內(nèi)存布局兼容Flyable類唬渗,0x10 ~ 0x1f 兼容,0x20之后的地址是Bird類自己的成員變量奋渔。
而Bird類自己的虛成員函數(shù) eat()
會加在哪個虛表里呢镊逝?答案是加到第一個虛表中,和單繼承的情況類似卒稳。
指針偏移
這時你可能又要問了蹋半,當(dāng)Bird類型指針向上轉(zhuǎn)換成Runnable指針之后,再調(diào)用虛函數(shù)時充坑,又怎么知道此時應(yīng)該去0x10的位置减江,而非0x00找虛表指針呢?
答案是捻爷,不需要辈灼。因?yàn)樵谧鲱愋娃D(zhuǎn)換的時候,會直接將指針偏移到0x10的位置也榄,我們來驗(yàn)證一下:
int main() {
Bird* b = new Bird();
Runnable* r = b;
printf("b: %x\nr: %x\nb == r: %d", b, r, b == r);
}
output:
b: ee617fb0
r: ee617fc0
b == r: 1
可以看到巡莹,b和r的地址確實(shí)不同司志,但當(dāng)我們做比較運(yùn)算時,結(jié)果卻是相等降宅。所以大多數(shù)時候骂远,我們不需要關(guān)注這里的指針偏移。
但這樣一來腰根,也存在一個坑激才,就是我們不能將Bird*類型先轉(zhuǎn)成void*之后,再強(qiáng)轉(zhuǎn)成Runnable*類型额嘿,因?yàn)檫@樣的轉(zhuǎn)換不會做指針偏移瘸恼。
對于包含虛表的類,做類型轉(zhuǎn)換時一般用dynamic_cast册养,但不支持void*东帅。
還是以上面的繼承關(guān)系為例:
int main() {
Bird* b = new Bird();
Flyable* f = b;
void* v = b;
printf("sc: %x\ndc: %x", static_cast<Runnable*>(v), dynamic_cast<Runnable*>(f));
}
output:
sc: 96079740
dc: 96079750
可以看到, v
和 f
實(shí)際指向的時同一個 Bird
對象球拦,但兩種類型轉(zhuǎn)換后指針卻不同靠闭,就是因?yàn)?static_cast
將 void*
轉(zhuǎn)換到 Runnable
時沒有做指針偏移。而 dynamic_cast
會動態(tài)檢查對象的實(shí)際類型刘莹,所以總能做出正確的指針偏移阎毅。
更多思考
就到此為止了嗎焚刚?其實(shí)還有其他更復(fù)雜的情況点弯,例如多繼承時,兩個父類包含相同簽名的虛函數(shù)矿咕;例如有菱形繼承抢肛、虛繼承的情況。這些復(fù)雜情況在實(shí)際應(yīng)用中較少碰到碳柱,就不做詳細(xì)討論了捡絮。
另外再提一下,C++中沒有“虛成員變量”莲镣,當(dāng)我們做向上類型轉(zhuǎn)換后福稳,就無法直接獲取到子類的成員變量了,只能通過虛函數(shù)來獲取瑞侮。