C++ 的虛擬函式讓程式碼增加許多彈性, 不過你可能會很好奇, 到底 C++ 是怎麼找到指位器所對應類別版本的虛擬函式?這就要從 C++ 如何幫我們建立物件談起。以下我們就以 x86-64 上的 gcc 為測試平台, 從編譯器產生的組合語言碼觀察實作的方式。
🛈 本文會以 x86 組合語言程式碼解說, 有關 x86 組合語言, 可參考這一篇哈佛大學 C61 課程的課堂筆記。
沒有虛擬函式的類別
對於沒有虛擬函式的一般類別, 是最單純的, 像是以下這個簡單的範例:
#include <iostream>
using namespace std;
class A {
public:
char c;
void f() {}
};
int main(void)
{
A a;
a.c = 'a';
a.f();
}
以下是編譯後的組合語言程式碼, 經過編譯之後, 會把 A 類別的成員函式變成一般函式, 也就是 A::f()
標籤處, 但是需要傳入物件位址當第一個引數, 這就是成員函式中 this
指位器的由來:
A::f(): ; 類別 A 的成員函式 f
push rbp ; 儲存 rbp
mov rbp, rsp ; 取得堆疊頂端
mov QWORD PTR [rbp-8], rdi ; 取得第一個參數 (物件位址) 放入區域變數 (this)
nop
pop rbp ; 復原 rbp
ret ; 返回
要注意的是, 區域變數佔用的空間是以 16 個位元組為單位配置的堆疊框 (stack frame), 不夠用的話就會再配置 16 個位元組, 依此類推。這裡因為 A 類別只有一個字元型別的資料成員, 只會佔用 1 個位元組, 所以只需要配置 16 位元組的空間, 會使用配置區塊的最高一個位元組放置這個字元資料, 物件 a 也是同樣的位址:
main:
push rbp ; 儲存 rbp
mov rbp, rsp ; 取得堆疊頂端
sub rsp, 16 ; 保留空間配置區域變數
mov BYTE PTR [rbp-1], 97 ; 將 'a' 放入 a.c
實際呼叫成員函式時也會自動加上傳入物件位址為引數的程式碼:
lea rax, [rbp-1] ; 取得 a 物件的位址
mov rdi, rax ; 設為第一個引數
call A::f() ; 呼叫成員函式 f
mov eax, 0 ; 設定 main 的返回值為 0
leave ; 復原堆疊
ret ; 返回
加上虛擬函式
一旦類別內含有虛擬函式時, 編譯器就必須生成額外的程式碼, 例如以下這個加上虛擬函式的範例:
#include <iostream>
using namespace std;
class A {
public:
char c;
virtual void f() {}
};
int main(void)
{
A a;
a.c = 'a';
a.f();
}
以下是編譯器實際生成的組合語言程式碼, 之後會分段解說:
A::f(): ; 類別 A 的成員函式 f
push rbp ; 儲存 rbp
mov rbp, rsp ; 取得堆疊頂端
mov QWORD PTR [rbp-8], rdi ; 取得物件位址, 也就是 this
nop
pop rbp ; 復原 rbp
ret
A::A() [base object constructor]: ; 編譯器自動建立的建構函式
push rbp ; 儲存 rbp
mov rbp, rsp ; 取得堆疊頂端
mov QWORD PTR [rbp-8], rdi ; 取得物件位址, 自動建立 this 變數
; 將 vtable 中用來儲存成員函式位址的區塊位址放入 edx
mov edx, OFFSET FLAT:vtable for A+16
mov rax, QWORD PTR [rbp-8] ; 將 this 放入 rax
; 將 vtable 中用來儲存成員函式位址的區塊位址儲存到新配置物件內
mov QWORD PTR [rax], rdx
nop
pop rbp ; 復原 rbp
ret
main:
push rbp ; 儲存 rbp
mov rbp, rsp ; 取得堆疊頂端
sub rsp, 16 ; 配置 a 物件的空間
lea rax, [rbp-16] ; 取得 a 物件位址
mov rdi, rax ; 放入成為第一個引數
call A::A() [complete object constructor] ; 呼叫建構函式
mov BYTE PTR [rbp-8], 97 ; 將 'a' 放入 a 的成員 c 中
lea rax, [rbp-16] ; 取得 a 物件位址
mov rdi, rax ; 放入成為第一個引數
call A::f() ; 呼叫 A 類別的 f 成員函式
mov eax, 0 ; 放入 0 當成 main 的傳回值
leave ; 復原堆疊
ret ; 返回
vtable for A: ; A 類別的 vtable
.quad 0 ; 保留欄位
.quad typeinfo for A ; A 類別型別資訊的位址
.quad A::f() ; 成員函式 f 的位址
typeinfo for A:
.quad vtable for __cxxabiv1::__class_type_info+16
.quad typeinfo name for A
typeinfo name for A:
.string "1A"
只要類別中存在虛擬函式, 編譯器就會幫該類別建立一個虛擬函式表 (vtable), vtable for A:
標籤處就是類別 A 的虛擬表格:
vtable for A: ; A 類別的 vtable
.quad 0 ; 保留欄位
.quad typeinfo for A ; A 類別型別資訊的位址
.quad A::f() ; 成員函式 f 的位址
typeinfo for A:
.quad vtable for __cxxabiv1::__class_type_info+16
.quad typeinfo name for A
typeinfo name for A:
.string "1A"
表格內是用來儲存位址的一個個欄位, 包含了開頭固定的 0 欄位、執行時期型別資訊 (RTTI, Run-Time Type Information) 的位址 (typeinfo for A 標籤處) 以及個別虛擬函式的位址。整體結構如下圖所示:
A 類別的虛擬函式表
+-------
| 0 A 類別的 RTTI
+------- +-------------
| RTTI ------------> | 虛擬函式表位址
+------- +-------------
| f ------+ | 型別名稱位址
+------- | +-------------
|
+-----> A::f()
為了搭配上述虛擬函式表運作, 編譯器還會自動幫該類別生成建構函式, 也就是 A::A() [base object constructor]
標籤處:
A::A() [base object constructor]: ; 編譯器自動建立的建構函式
push rbp ; 儲存 rbp
mov rbp, rsp ; 取得堆疊頂端
mov QWORD PTR [rbp-8], rdi ; 取得物件位址, 自動建立 this 變數
; 將 vtable 中用來儲存成員函式位址的區塊位址放入 edx
mov edx, OFFSET FLAT:vtable for A+16
mov rax, QWORD PTR [rbp-8] ; 將 this 放入 rax
; 將 vtable 中用來儲存成員函式位址的區塊位址儲存到新配置物件內
mov QWORD PTR [rax], rdx
nop
pop rbp ; 復原 rbp
ret
這個建構函式會自動幫新建立的物件在所有資料成員前面安插一個指向虛擬函式表的指位器。不過要注意的是, 安插在物件中的指位器並不是指向虛擬函式表的開頭, 而是直接指到虛擬函式表中第一個紀錄虛擬函式位址的欄位。之後我們提到虛擬函式表的位址時, 指的都是這個位址, 而不是真正虛擬函式表開頭的位址。
在 main
中, 一開始一樣是配置區域變數的空間, 現在的 a
物件除了一個字元型別的資料成員外, 還需要放置虛擬函式表的位址, 共需要 1+8 個位元組, 所以仍然只需要配置 16 個位元組的堆疊框就夠用:
main:
push rbp ; 儲存 rbp
mov rbp, rsp ; 取得堆疊頂端
sub rsp, 16 ; 配置 a 物件的空間
lea rax, [rbp-16] ; 取得 a 物件位址
接著, 就可以傳入新配置物件的位址呼叫編譯器自動產生的建構函式:
mov rdi, rax ; 放入成為第一個引數
call A::A() [complete object constructor] ; 呼叫建構函式
雖然和前一個範例一樣保留了 16 個位元組做為區域變數空間, 不過因為儲存虛擬函式表位置的指位器需要對齊 8 的位址, 不能把所有的資料都往高位址靠, 所以低位址開始的 8 個位元組就是放置虛擬函式表的位址, 資料成員 c
則是放在下一個 8 位元組的低位址處:
mov BYTE PTR [rbp-8], 97 ; 將 'a' 放入 a 的成員 c 中
建立 a 物件後的整體結構如下圖:
a 物件
+--------------- A 類別的虛擬函式表
| vtable pointer ---+ +-------
+--------------- | | 0 A 類別的 RTTI
| c = 'a' | +------- +-------------
|--------------- | | RTTI ------------> | 虛擬函式表位址
| +------- +-------------
+-----> | f ------+ | 型別名稱位址
+------- | +-------------
|
+-----> A::f()
本例雖然有虛擬函式, 不過主程式中並沒有透過指向物件的指位器或是參照呼叫成員函式, 所以實際上呼叫成員函式的部分和前一個範例是一樣的, 因為編譯器在編譯時就可以確定該呼叫哪一個函式, 不會有問題:
lea rax, [rbp-16] ; 取得 a 物件位址
mov rdi, rax ; 放入成為第一個引數
call A::f() ; 呼叫 A 類別的 f 成員函式
mov eax, 0 ; 放入 0 當成 main 的傳回值
leave ; 復原堆疊
ret ; 返回
透過指位器呼叫成員函式
目前還看不出來虛擬函式表的用處, 不過只要透過指向物件的指位器或是參照呼叫成員函式時, 編譯器就無法在編譯時確認實際指向的是哪一種類別的物件, 必須藉助虛擬函式表間接呼叫成員函式, 請看以下改用指位器呼叫成員函式的範例:
#include <iostream>
using namespace std;
class A {
public:
char c;
virtual void f() {}
};
int main(void)
{
A a;
A *pa = &a;
a.c = 'a';
pa->f();
}
以下省略組合語言與前面範例相同的部分, 只看不一樣的地方, 就是 main
函式:
main:
push rbp
mov rbp, rsp
sub rsp, 32
lea rax, [rbp-32]
mov rdi, rax
call A::A() [complete object constructor]
首先因為除了 a
物件, 還有 pa
指位器, 所以單單配置 16 位元組的區域變數空間並不足夠, 所以這裡改成配置 32 位元組的空間。隨區域變數空間的變化, 也要調整取得 a
物件位址的程式碼, 以下是設定 a
物件中資料成員 c
的部分:
lea rax, [rbp-32] ; 取得 a 物件位址
mov QWORD PTR [rbp-8], rax ; 放入 pa 指位器
mov BYTE PTR [rbp-24], 97
你可以看到現在呼叫成員函式的組合語言程式碼跟剛剛明顯不同, 複雜得多, 但其實就是到虛擬函式表中查成員函式的位址後再呼叫:
mov rax, QWORD PTR [rbp-8] ; 從 pa 指位器取得 a 物件位址
mov rax, QWORD PTR [rax] ; 從 a 物件位址取得 vtable 位址
mov rdx, QWORD PTR [rax] ; 取得 f 成員函式位址
mov rax, QWORD PTR [rbp-8] ; 從 pa 指位器取得 a 物件位址
mov rdi, rax ; 設定為第一個引數
call rdx ; 呼叫 f 成員函式
mov eax, 0
leave
ret
vtable for A:
.quad 0
.quad typeinfo for A
.quad A::f()
...(略)
要注意的是虛擬函式表中是依照定義類別時的虛擬函式順序排列, 本例只有一個虛擬函式, 實際查表的步驟就是:
- 透過指位器取得 a 的位址
- 取得 a 物件內虛擬函式表的位址
- 從虛擬函式表中取得成員函式 f 的位址
- 呼叫成員函式 f, 並傳入指位器指向的位址
加入多個虛擬函式
剛剛的範例只有一個虛擬函式, 所以還看不出來關鍵的差異, 這裡幫類別再加入一個虛擬函式:
#include <iostream>
using namespace std;
class A {
public:
char c;
virtual void f() {}
virtual void f2() {}
};
int main(void)
{
A a;
A *pa = &a;
a.c = 'a';
pa->f2();
}
以下只列出組合語言碼中變化的部分, 首先, 虛擬函式表現在多了一欄, 記錄成員函式 f2
的位址:
vtable for A:
.quad 0
.quad typeinfo for A
.quad A::f()
.quad A::f2()
由於主程式中改成呼叫成員函式 f2
, 所以組合語言碼也要跟著變, 取得虛擬函式表位址再加 8 的位址的內容, 才是 f2
的位址:
mov rax, QWORD PTR [rbp-8] ; 取得指位器指向的位址
mov rax, QWORD PTR [rax] ; 取得虛擬函式表的位址
add rax, 8 ; 取得指向下一欄 (也就是 f2) 的位址
mov rdx, QWORD PTR [rax] ; 取得 f2 的位址
mov rax, QWORD PTR [rbp-8] ; 取得指位器指向的位址
mov rdi, rax ; 傳入當引數
call rdx ; 呼叫函式
現在應該可以明確的看到呼叫虛擬函式就變成查表後再呼叫了。
加入子類別
虛擬函式要發揮作用必需要有子類別, 請看以下這個加上子類別的範例:
#include <iostream>
using namespace std;
class A {
public:
char c;
virtual void f() {}
virtual void f2() {}
};
class B:public A {
};
int main(void)
{
B b;
B *pb = &b;
pb->c = 'a';
pb->f2();
}
首先可以看到由於 B
繼承 A
, 所以也是具有虛擬函式的類別, 生成的組合語言程式碼就會有兩個類別的虛擬函式表:
vtable for B:
.quad 0
.quad typeinfo for B
.quad A::f()
.quad A::f2()
vtable for A:
.quad 0
.quad typeinfo for A
.quad A::f()
.quad A::f2()
雖然 B
繼承 A
, 不過 B
的虛擬函式表並不是直接放一個指向 A
的虛擬函式表的位址, 而是重複一遍 A
中的所有虛擬函式, 這樣在查找虛擬函式位址時, 就不需要再一層層查找父類別的虛擬函式表了。
同時也可以看到編譯器也會自動建立 B
類別的建構函式:
B::B() [base object constructor]:
push rbp
mov rbp, rsp
sub rsp, 16 ; 配置區域變數空間
mov QWORD PTR [rbp-8], rdi ; 設定 this 指向物件
mov rax, QWORD PTR [rbp-8] ; 取得 this
mov rdi, rax ; 設為引數
call A::A() [base object constructor] ; 呼叫父類別的建構函式
mov edx, OFFSET FLAT:vtable for B+16 ; 取得 B 類別的虛擬函式表位址
mov rax, QWORD PTR [rbp-8] ; 取得 this
mov QWORD PTR [rax], rdx ; 放入物件開頭
nop
leave
ret
內容與 A
的基本上是一樣的, 只是它會幫你呼叫父類別 A
的建構函式, 然後再將 B
類別的虛擬函式表位址覆蓋上去。
在 main
中我們特意改成使用指位器設定資料成員 c
, 觀察組合語言程式碼:
main:
push rbp
mov rbp, rsp
sub rsp, 32 ; 配置區域變數空間
lea rax, [rbp-32] ; 取得 b 位址
mov rdi, rax ; 設為引數
call B::B() [complete object constructor] ; 呼叫 B 建構函式
lea rax, [rbp-32] ; 取得 b 位址
mov QWORD PTR [rbp-8], rax ; 放入 pb 中
mov rax, QWORD PTR [rbp-8] ; 取得 pb 指向位址
mov BYTE PTR [rax+8], 97 ; 把 'a' 放入資料成員 c
mov rax, QWORD PTR [rbp-8] ; 取得 pb 指向位址
mov rax, QWORD PTR [rax] ; 取得虛擬函式表位址
add rax, 8 ; 取得儲存 f2 的位址
mov rdx, QWORD PTR [rax] ; 取得 f2 位址
mov rax, QWORD PTR [rbp-8] ; 取得 pb 指向的位址
mov rdi, rax ; 設為引數
call rdx ; 呼叫 f2
mov eax, 0
leave
ret
你可以看到因為是透過查虛擬函式表間接呼叫成員函式的關係, 所以會根據指位器實際指向的物件, 找到正確的虛擬函式表, 因而呼叫個別物件所記錄的虛擬函式。
子類別覆寫 (override) 虛擬函式
雖然目前已經很清楚虛擬函式表的作用, 不過我們還可以進一步在子類別中覆寫父類別的虛擬函式:
#include <iostream>
using namespace std;
class A {
public:
char c;
virtual void f() {}
virtual void f2() {}
};
class B:public A {
virtual void f3() {}
virtual void f1() {}
};
int main(void)
{
B b;
B *pb = &b;
pb->c = 'a';
pb->f2();
}
這裡我們除了在 B
中覆寫 f2
以外, 還增加了 f3
虛擬函式。首先來看一下虛擬函式表:
vtable for B:
.quad 0
.quad typeinfo for B
.quad B::f()
.quad A::f2()
.quad B::f3()
vtable for A:
.quad 0
.quad typeinfo for A
.quad A::f()
.quad A::f2()
B
中的虛擬函式表還是會先把 A
中原本的虛擬函式列出來, 然後才是 B
中新增的 f3
。要注意的是, 因為 B
中覆寫了 f
函式, 所以 B
的虛擬函式表記錄的是 B::f()
, 而 A
的虛擬函式表記錄的是 A::f()
, 兩個類別的同名虛擬函式指向個別版本的函式了。
由於 B
的虛擬函式表會根據是否覆寫父類別的虛擬函式而更改內容, 所以透過指位器或是參照呼叫成員函式時, 就可以依據虛擬函式表找到正確的函式。
其餘的部分都跟上一個範例一模一樣, 就不再贅述。
使用指向成員函式的指位器
C++ 有提供透過指向成員函式的指位器呼叫成員函式的方法, 例如:
#include <iostream>
using namespace std;
class A
{
public:
virtual void f()
{
cout << "A::f()" << endl;
}
};
int main(void)
{
A a;
void (A::*pf)() = &A::f;
cout << (void *)&A::f << endl;
(a.*pf)();
return 0;
}
請特別留意指向成員函式的指位器的宣告方式, 一定要冠上類別名稱, 透過這種指位器執行成員函式時, 也要標明物件。執行結果如下:
0x4011e6
A::f()
你也可以透過指向物件的指位器進行同樣的操作, 例如:
#include <iostream>
using namespace std;
class A
{
public:
virtual void f()
{
cout << "A::f()" << endl;
}
};
int main(void)
{
A a;
A *pa = &a;
void (A::*pf)() = &A::f;
cout << (void *)&A::f << endl;
(pa->*pf)();
return 0;
}
利用這種語法, 我們也可以模擬透過虛擬函式表取得成員函式位址的操作:
#include <iostream>
using namespace std;
class A
{
public:
virtual void f()
{
cout << "A::f()" << endl;
}
};
int main(void)
{
A a;
void (A::*pf)() = &A::f;
cout << (void *)&A::f << endl;
(a.*pf)();
void (A::*pmf)() = **((void (A::***)())&a);
cout << (void *)pmf << endl;
(a.*pmf)();
return 0;
}
首先把 a
的位址轉型成指向 A 類別成員函式的三層指位器, 經過兩層取值運算從物件中取得虛擬函式表的位址、再從虛擬函式表中取得虛擬函式的位址, 放入 pmf
中, 即可使用指向成員函式指位器的形式呼叫成員函式。執行結果如下:
0x40128c
A::f()
0x40128c
0x1
A::f()
從顯示的位址也可以確認我們間接從虛擬函式表取得的成員函式位址是正確的。如果想要呼叫第二個開始的虛擬函式, 就必須用點小技巧:
#include <iostream>
using namespace std;
class A
{
public:
virtual void f()
{
cout << "A::f()" << endl;
}
virtual void g()
{
cout << "A::g()" << endl;
}
};
int main(void)
{
A a;
void (A::*pf)() = &A::g;
// cout << sizeof(pf) << endl;
// 會顯示 16
cout << (void *)(&A::g) << endl;
(a.*pf)();
cout << sizeof(void (A::*)()) << endl;
void (A::***pmf0)() = ((void (A::***)())&a);
void (A::**pmf1)() = *pmf0;
// WRONG: pmf2 = pmf1 + 1
// 這會讓位址加 16, 而不是 8
// 底下耍點小技巧
void (A::**pmf2)() = (void (A::**)())((char **)pmf1 + 1);
void (A::*pmf)() = *pmf2;
cout << (void *)pmf << endl;
(a.*pmf)();
return 0;
}
這裡要特別注意的是, 指向成員函式的指位器大小是 16 個位元組, 但實際在虛擬函式表中每個指向函式的指位器是 8 位元組, 如果拿 pmf1 + 1
會得到 pmf1
位址加 16 的錯誤位址, 所以這裡我們耍點技巧, 先轉成指向字元的兩層指位器, 再透過轉型後的指位器做加法, 就會以加減單位是 8 位元組來計算, 得到指向第二個虛擬函式位址的指位器。執行結果如下:
0x4012ca
A::g()
16
0x4012ca
A::g()
從顯示的位址也可以確認我們自己從虛擬函式表中取得的位址是正確的。
結語
利用同樣的方式, 你還可以繼續觀察多重繼承的結果。根據以上的觀察, 可以看到:
- 如果沒有定義虛擬函式, 物件的結構就只是單純的資料, 成員函式除了自動加上一個傳入物件位址的參數外, 跟一般函式沒有什麼差別。
- 一旦定義虛擬函式, 編譯器就會幫類別建立虛擬函式表, 並且在建立物件時自動增加一個欄位記錄虛擬函式表位址。
- 只要是透過指位器或是參照呼叫成員函式, 就會到虛擬函式表中查表間接呼叫成員函式。
- 沒有透過指位器或是參照呼叫成員函式時, 就不會去查表, 即使呼叫的是虛擬函式也一樣。
- 個別類別的虛擬函式表彼此獨立, 沒有關聯。
- 由於編譯器是根據定義類別時虛擬函式的排列順序去虛擬函式表中查找位址, 如果要使用現有編譯好的目的檔, 就要小心不要修改原始檔中的虛擬函式順序與數目, 否則既有目的黨中查找虛擬函式位址的程式碼就會出錯。
所有的繼承關係、虛擬函式都是編譯器在編譯時就會處理, 並建立個別的資料區塊, 實際程式執行時, 就只是查表找到成員函式位址, 呼叫找到的函式而已。
Top comments (0)