C++ 指向類別成員的指位器的實作細節
Tags: cpp
Published: 2024-06-25 03:48:58

C++ 可以定義指向成員函式的指位器, 不過因為成員函式可能是虛擬函式, 如何能夠透過指向成員函式的指位器達到呼叫正確的成員函式呢？本來就來簡單探究。(本文均以 g++ 為例, 並且只探討單純的單一繼承)。

## 指向非虛擬函式的指位器

首先來看個簡單的[範例](https://godbolt.org/z/oaPx469xT), 建立指向非虛擬函式的指位器：

```cpp
#include <iostream>

using namespace std;

class A
{
public:
    virtual void f_v1() { cout << "A::f_v1()" << endl; }
    virtual void f_v2() { cout << "A::f_v2()" << endl; }

    void f_nv() { cout << "A::f_nv()" << endl;}
};

class B : public A
{
public:
    void f_v1() { cout << "B::f_v1()" << endl; }
};

int main(void)
{
    B b;
    A a;

    A *pa = &b;
    void (A::*pf)() = &A::f_nv;
    (pa->*pf)();

    return 0;
}
```

以下針對編譯出來的組合語言碼分開說明, 首先是個別類別的成員函式, 這裡不是我們探討的重點, 因此都略過程式碼內容：

```nasm
.LC0:
        .string "A::f_v1()"
A::f_v1():
        ...(略)
.LC1:
        .string "A::f_v2()"
A::f_v2():
        ...(略)

.LC2:
        .string "A::f_nv()"
A::f_nv():
        ...(略)

.LC3:
        .string "B::f_v1()"
B::f_v1():
        ...(略)
```

接著是配置區域變數, 就依照程式碼內的順序分別配置：

```nasm
main:
        push    rbp 
        mov     rbp, rsp
        sub     rsp, 48                                    ; 配置區域變數空間
        mov     eax, OFFSET FLAT:vtable for B+16           ; 取得 B 的虛擬函式表位址
        mov     QWORD PTR [rbp-16], rax                    ; 放入 b 物件
        mov     eax, OFFSET FLAT:vtable for A+16           ; 取得 A 的虛擬函式表位址
        mov     QWORD PTR [rbp-24], rax                    ; 放入 a 物件
        lea     rax, [rbp-16]                              ; 取得 b 的位址
        mov     QWORD PTR [rbp-8], rax                     ; 放入 pa 指位器
```

接著就是重點了, 指向成員函式的指位器佔 16 個位元組, 指向非虛擬函式時, 低的 8 位元組就是成員函式的位址, 高 8 位元組是物件的位移, 本文都不會使用到物件的位移：

```nasm
        mov     QWORD PTR [rbp-48], OFFSET FLAT:A::f_nv()  ; 將 f_nv 的位址放入 pf 的低 8 位元組
        mov     QWORD PTR [rbp-40], 0                      ; 將物件位移 0 放入 pf 的高位元組
```

由於指向成員函式的指位器和一般的指位器並不相同, 所以並不能隨意混用。當需要透過指向成員函式的指位器呼叫成員函式時, 第一步是判斷指向的成員函式是否為虛擬函式？這裡編譯器用了一個小技巧, 由於函式都會對齊 2 的次方的位址, 所以函式的位址最後一個位元一定會 0, 把函式的位址拿來和 1 做位元 and 運算, 就會把位址變成 0, 稍後指向虛擬函式的指位器就會依據這一點特別設計, 讓指位器的低 8 位元組與 1 進行 and 位元運算時不會得到 0, 藉此區分指位器指向的是否為虛擬函式：

```nasm
        mov     rax, QWORD PTR [rbp-48]                    ; 取得虛擬函式位址
        and     eax, 1                     ; 由於函式會對齊 2 的次方位址, 所以這會 eax 變 0
        test    rax, rax                   ; 測試 rax & rax 是否為 0
        je      .L6                        ; 是的話 (非虛擬函式) 跳到 .L6 處
```

以下這段是為虛擬函式設計的, 我們稍後再說明：

```nasm
        mov     rax, QWORD PTR [rbp-40]
        mov     rdx, rax
        mov     rax, QWORD PTR [rbp-8]
        add     rax, rdx
        mov     rax, QWORD PTR [rax]
        mov     rdx, QWORD PTR [rbp-48]
        sub     rdx, 1
        add     rax, rdx
        mov     rax, QWORD PTR [rax]
        jmp     .L7
```

確認指位器指向的是非虛擬函式後, 並不需要透過物件的虛擬函式表找出真正的函式位址, 就可以直接呼叫成員函式了：

```nasm
.L6:
        mov     rax, QWORD PTR [rbp-48]    ; 取得非虛擬函式的位址
.L7:
        mov     rdx, QWORD PTR [rbp-40]    ; 取得物件位移 (0)
        mov     rcx, rdx                   ; 將位物件移放入 rcx
        mov     rdx, QWORD PTR [rbp-8]     ; 取得 pa 指向的位址
        add     rdx, rcx                   ; 加上位移 (0)
        mov     rdi, rdx                   ; 設定為第一個引數
        call    rax                        ; 呼叫非虛擬函式
        mov     eax, 0
        leave
        ret
vtable for B:
        .quad   0
        .quad   typeinfo for B
        .quad   B::f_v1()
        .quad   A::f_v2()
vtable for A:
        .quad   0
        .quad   typeinfo for A
        .quad   A::f_v1()
        .quad   A::f_v2()
        ...(略)
```

## 指向虛擬函式的指位器

如果指位器指向的成員函式是虛擬函式, 就必須到物件的虛擬函式表中找出真正的函式位址, 請看以下[範例](https://godbolt.org/z/dzanE1GaK), 它跟上一個程式幾乎一樣, 只是設定的是指向虛擬函式的指位器：

```cpp
#include <iostream>

using namespace std;

class A
{
public:
    virtual void f_v1() { cout << "A::f_v1()" << endl; }
    virtual void f_v2() { cout << "A::f_v2()" << endl; }

    void f_nv() { cout << "A::f_nv()" << endl;}
};

class B : public A
{
public:
    void f_v1() { cout << "B::f_v1()" << endl; }
};

int main(void)
{
    B b;
    A a;

    A *pa = &b;
    void (A::*pf)() = &A::f_v1; // 改用虛擬函式
    (pa->*pf)();

    return 0;
}
```

編譯出來的組合語言程式碼跟剛剛幾乎一樣, 我們略過相同的部分：

```nasm
main:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 48                            ; 配置區域變數空間
        mov     eax, OFFSET FLAT:vtable for B+16   ; B 的虛擬函式表位址
        mov     QWORD PTR [rbp-16], rax            ; 放入 b 物件
        mov     eax, OFFSET FLAT:vtable for A+16   ; A 的虛擬函式表
        mov     QWORD PTR [rbp-24], rax            ; 放入 a 物件
        lea     rax, [rbp-16]                      ; b 的位址
        mov     QWORD PTR [rbp-8], rax             ; 放入 pa 指位器
```

建立指位器時你會看到現在低 8 位元組不是直接放置成員函式的位址, 而是放置 (0 + 1), 其中的 0 表示這個虛擬函式在虛擬函式表中的位移, 因為 `f_v1` 是第一個虛擬函式, 所以位移為 0；後面的 1 是為了讓最低位元不是 0, 以便能透過剛剛介紹的檢查機制分辨這是虛擬函式：

```nasm
        mov     QWORD PTR [rbp-48], 1              ; 將虛擬函式位移 1 放入 pf 的低 8 位元組
        mov     QWORD PTR [rbp-40], 0              ; 將物件位移 0 放入 pf 的高 8 位元組
```

當要透過這個指位器呼叫成員函式時, 會經過一模一樣的檢查步驟, 不過因為指向虛擬函式的指位器最低位元一定會是 1, 所以相同的位元 and 運算結果就會是 1, 而不是 0, 即可分辨這是指向虛擬函式的指位器：

```nasm
        mov     rax, QWORD PTR [rbp-48]            ; 取得 pf 的低 8 位元組 (1)
        and     eax, 1                             ; 1 & 1 = 1
        test    rax, rax                           ; 1 & 1 = 1, 不會設定 zf 旗標
        je      .L5                                ; zf 旗標不是 1, 不會跳到 .L5
```

下一個步驟就是到虛擬函式表中找出函式的位址, 它會以虛擬函式表的位址為準, 加上虛擬函式位移找到記錄函式位址的地方, 不過要注意虛擬函式位移有包含最低位元的 1, 所以要將它扣除：

```nasm
        mov     rax, QWORD PTR [rbp-40]            ; 取得物件位移
        mov     rdx, rax                           ; 放入 rdx
        mov     rax, QWORD PTR [rbp-8]             ; 取得 pa 指向的位址, 也就是物件的開頭位址
        add     rax, rdx                           ; 加上虛擬函式表的位移
        mov     rax, QWORD PTR [rax]               ; 取得虛擬函式表的位址
        mov     rdx, QWORD PTR [rbp-48]            ; 取得虛擬函式位移
        sub     rdx, 1                             ; 扣除用來識別是否為虛擬函式的 1
        add     rax, rdx                           ; 找到儲存虛擬函式位址的欄位位址
        mov     rax, QWORD PTR [rax]               ; 取得虛擬函式的位址
        jmp     .L6                                ; 移到 .L6
.L5:
        mov     rax, QWORD PTR [rbp-48]
```

最後, 就可以依據找到的虛擬函式為只傳入物件的位址呼叫它了：

```nasm
.L6:
        mov     rdx, QWORD PTR [rbp-40]            ; 取得物件位移
        mov     rcx, rdx                           ; 放次 rcx
        mov     rdx, QWORD PTR [rbp-8]             ; 取得 pa 指向的位址
        add     rdx, rcx                           ; 加上位移
        mov     rdi, rdx                           ; 設為第一個引數
        call    rax                                ; 呼叫虛擬函式
        mov     eax, 0
        leave
        ret
```

## 指向第二個虛擬函式的指位器

為了進一步確認指向虛擬函式的指位器運作方式, 這裡再看一個呼叫第二個虛擬函式的[範例](https://godbolt.org/z/E4YonhTj5)

```cpp
#include <iostream>

using namespace std;

class A
{
public:
    virtual void f_v1() { cout << "A::f_v1()" << endl; }
    virtual void f_v2() { cout << "A::f_v2()" << endl; }

    void f_nv() { cout << "A::f_nv()" << endl;}
};

class B : public A
{
public:
    void f_v1() { cout << "B::f_v1()" << endl; }
};

int main(void)
{
    B b;
    A a;

    A *pa = &b;
    void (A::*pf)() = &A::f_v2; // 改成第二個虛擬函式
    (pa->*pf)();

    return 0;
}
```

編譯後的組合語言都跟剛剛幾乎一樣, 我們略過不看：

```nasm
main:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 48                            ; 配置區域變數空間
        mov     eax, OFFSET FLAT:vtable for B+16   ; B 的虛擬函式表位址
        mov     QWORD PTR [rbp-16], rax            ; 放入 b 物件
        mov     eax, OFFSET FLAT:vtable for A+16   ; A 的虛擬函式表
        mov     QWORD PTR [rbp-24], rax            ; 放入 a 物件
        lea     rax, [rbp-16]                      ; b 的位址
        mov     QWORD PTR [rbp-8], rax             ; 放入 pa 指位器
```

只有虛擬函式位移不同, 這裡因為是第二個虛擬函式, 所以從虛擬函式表開頭算起位移 8 個位元組, 加上區別虛擬函式用的 1, 所以是 9：

```nasm
        mov     QWORD PTR [rbp-48], 9              ; 將虛擬函式位移 9 放入 pf 的低 8 位元組
        mov     QWORD PTR [rbp-40], 0              ; 將物件位移 0 放入 pf 的高 8 位元組
```

之後的內容就跟前一個範例一樣, 可自行參考：

```nasm
        mov     rax, QWORD PTR [rbp-48]            ; 取得 pf 的低 8 位元組 (1)
        and     eax, 1                             ; 9 & 1 = 1
        test    rax, rax                           ; 1 & 1 = 1, 不會設定 zf 旗標
        je      .L5                                ; zf 旗標不是 1, 不會跳到 .L5
        mov     rax, QWORD PTR [rbp-40]            ; 取得物件位移
        mov     rdx, rax                           ; 放入 rdx
        mov     rax, QWORD PTR [rbp-8]             ; 取得 pa 指向的位址, 也就是物件的開頭位址
        add     rax, rdx                           ; 加上虛擬函式表的位移
        mov     rax, QWORD PTR [rax]               ; 取得虛擬函式表的位址
        mov     rdx, QWORD PTR [rbp-48]            ; 取得虛擬函式位移
        sub     rdx, 1                             ; 扣除用來識別是否為虛擬函式的 1
        add     rax, rdx                           ; 找到儲存虛擬函式位址的欄位位址
        mov     rax, QWORD PTR [rax]               ; 取得虛擬函式的位址
        jmp     .L6                                ; 移到 .L6
.L5:
        mov     rax, QWORD PTR [rbp-48]
.L6:
        mov     rdx, QWORD PTR [rbp-40]            ; 取得物件位移
        mov     rcx, rdx                           ; 放次 rcx
        mov     rdx, QWORD PTR [rbp-8]             ; 取得 pa 指向的位址
        add     rdx, rcx                           ; 加上位移
        mov     rdi, rdx                           ; 設為第一個引數
        call    rax                                ; 呼叫虛擬函式
        mov     eax, 0
        leave
        ret
```

依據本文的說明, 你也可以自行觀察多重繼承時的處理方式, 雖然比較複雜, 不過基本的運作原理都一樣。