深入C++成員函式及虛擬函式表

fl2011sx發表於2020-12-08

深入C++成員函式及虛擬函式表

大家好!這次逗比老師要和大家分享的是C++中的成員函式,我們會深入解析C++處理物件成員時的方式,還有關於成員函式指標、虛擬函式表等問題的深入研究。

簡單物件的記憶體佈局

在介紹其他問題之前,我們們先來研究一下,一個C++物件在記憶體中的儲存佈局。首先,如果是POD型別的物件,那麼佈局方式和C中的結構體相同,按照定義的順序排布所有成員,並且會在適宜的時候進行記憶體對齊。例如下面例程我們寫了一個簡單的用來列印一個物件內部結構(十六進位制方式)的程式碼:

#include <iostream>
#include <iomanip>

class C1 {
public:
    char m1;
    // pad 7 Bytes
    uint32_t m2[5];
};

template <typename T>
void ShowMemory(const T &ref, const std::string &name = "no name") {
    std::cout << "=====begin=====" << std::endl;
    auto base = reinterpret_cast<const uint8_t *>(&ref);
    auto size = sizeof(typename std::remove_reference<T>::type);
    
    std::cout << "name: " << name << std::endl;
    std::cout << "size: " << size << " Byte(s)" << std::endl;
    
    std::cout << "  |";
    for (int i = 0; i < 16; i++) {
        std::cout << std::setw(2) << std::hex << i << "|";
    }
    std::cout << std::endl;
    
    int i = 0;
    for (const uint8_t *ptr = base; ptr < base + size; ptr++) {
        if (i % 16 == 0) {
            std::cout << " " << std::hex << i / 16 << "|";
        }
        i++;
        std::cout << std::setw(2) << std::setfill('0') << std::hex << uint16_t{*ptr} << "|";
        if (i % 16 == 0) {
            std::cout << std::endl;
        }
    }
    std::cout << std::endl << "======end======" << std::endl;
}

#define SHOW(obj) ShowMemory(obj, #obj)

int main(int argc, const char * argv[]) {
    C1 c1;
    c1.m1 = 44;
    c1.m2[0] = 88;
    SHOW(c1);
    return 0;
}

示例的輸出結果如下:

=====begin=====
name: c1
size: 24 Byte(s)
  | 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
 0|2c|00|00|00|58|00|00|00|00|00|00|00|00|00|00|00|
 0|00|00|00|00|00|00|00|00|
======end======

相信這一部分大家都很熟悉了,不再囉嗦。

接下來我們要研究的是,非POD型別中,C++到底都“偷偷”在物件中做了什麼。首先我們先看一下簡單繼承的方式,假如有B繼承自A,那麼B中是如何佈局的呢?請看例程:

// ShowMemory相關內容省略,參考之前的例程即可
class A {
public:
    uint16_t m1, m2;
    uint8_t m3;
};

class B : public A {
public:
    uint16_t m4;
};

int main(int argc, const char * argv[]) {
    B b;
    b.m1 = 0x1234;
    b.m2 = 0x4567;
    b.m3 = 0xef;
    b.m4 = 0x789a;
    SHOW(b);
    return 0;
}

輸出如下:

=====begin=====
name: b
size: 8 Byte(s)
  | 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
 0|34|12|67|45|ef|00|9a|78|
======end======

看得出,地址0x00和0x01是m1,0x02和0x03是m2,0x04是m3,然後0x05是一個位元組的記憶體對齊。也就是說,0x00~0x05其實就是一個完整的A型別物件,也就是父類整合到的內容。然後0x06和0x07是m4,也就是後面排的是子類的擴充套件內容。

我們知道,資料型別僅僅是處理資料的方式,然而資料本身都只是相同的二進位制數罷了,如果知道了一個物件的實際記憶體佈局,那麼我們其實也可以反過來直接構造一個物件。請看下面歷程:

// 省略A和B的定義,請參考上面歷程
int main(int argc, const char * argv[]) {
    // 直接構造二進位制資料
    uint8_t data[] = {0x34, 0x12, 0x67, 0x45, 0xef, 0x00, 0x9a, 0x78};
    // 用物件方式解析資料
    B *ptr = reinterpret_cast<B *>(data);
    // 嘗試讀取m2和m4
    std::cout << std::hex << ptr->m2 << ", " << ptr->m4 << std::endl;
    return 0;
}

輸出結果如下:

4567, 789a

看起來,通過二進位制資料來反向構造物件,到目前為止還是可行的。

請大家先消化上面的內容,我們再一起來往下看。

成員函式指標

我們瞭解到,C++其實本質還是C語言,只不過做了很多語法糖,使得語法更為高階,更加適合用高等思維去設計。但語法並不改變語義,C++的高階語法其實都可以等價翻譯為C語言語法,例如函式過載,其本質是編譯器在函式名前後加上了前字尾用以區分的。

那麼成員函式也是一樣的,雖然我們把它寫在類當中,但本質上,它仍是函式,和普通函式一樣,它的指令也會存入一塊記憶體,我們也可以設法找到這片記憶體。

先來看一個靜態成員函式的例子:

class C1 {
public:
    static void test() {std::cout << "C1::test" << std::endl;}
};

int main(int argc, const char * argv[]) {
    // 靜態成員函式指標
    void (*pf1)() = C1::test;
    // 列印地址的值
    std::cout << reinterpret_cast<void *>(pf1) << std::endl;
    // 直接呼叫
    pf1();
    return 0;
}

執行結果:

0x100003074
C1::test

這裡的0x100003074其實就是C1::test函式儲存的跳轉地址。所以這裡我們看到,其實靜態成員函式就是普通的函式而已,語義上來說,和寫在外面的函式沒什麼區別,這裡的類名其實與名稱空間幾乎無異了。只是語法上來說,它在C1內,那我們自然是要寫和C1相關的內容。

但如果是非靜態成員函式呢?請看例程:

class C1 {
public:
    int m1;
    void test(int a) {std::cout << "C1::test, a=" << a << ", m1=" << m1 << std::endl;}
};

int main(int argc, const char * argv[]) {
    // 定義物件
    C1 c1;
    // 成員賦值
    c1.m1 = 1234;
    // 成員函式指標
    void (C1::*pf1)(int) = &C1::test;
    // pf1的長度
    std::cout << sizeof(pf1) << std::endl;
    // 呼叫
    (c1.*pf1)(5);
    return 0;
}

輸出如下:

16
C1::test, a=5, m1=1234

非靜態成員函式指標的用法大家應該不陌生,但似乎讓我們很詫異的是這個16,照理講,在64位環境下,指標的大小都是8位元組,可pf1卻很個性地來了個16,這是為什麼?

先不急,我們還是把pf1的二進位制內容先列印出來看看:

int main(int argc, const char * argv[]) {
    C1 c1;
    c1.m1 = 1234;
    void (C1::*pf1)(int) = &C1::test;
    
    SHOW(pf1);
    
    return 0;
}

/* 輸出結果:
=====begin=====
name: pf1
size: 16 Byte(s)
  | 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
 0|80|28|00|00|01|00|00|00|00|00|00|00|00|00|00|00|

======end======
*/

後面一長串都是0,而前面這部分看上去有點像是地址,不免讓人猜測,是否前8位元組才是真正的函式地址呢?我們來做個實驗便好:

class C1 {
public:
    int m1;
    void test(int a) {std::cout << "C1::test, a=" << a << ", m1=" << m1 << std::endl;}
    void test2() {std::cout << "C1::test2" << std::endl;}
};

int main(int argc, const char * argv[]) {
    C1 c1;
    c1.m1 = 1234;
    void (C1::*pf1)(int) = &C1::test;
    void (C1::*pf2)() = &C1::test2;
    
    auto test_ppf = reinterpret_cast<void (**)()>(&pf2);
    (*test_ppf)();
    
    return 0;
}

/*輸出結果:
C1::test2
*/

(這裡怕有些讀者看暈,我稍微多解釋一下。由於void (C1::*)()這種型別是16位元組長度的,並不是普通的指標,因此我們不能直接轉換成void *或普通函式指標。而我們現在要做的是把pf2的前8個位元組拿出來,再按照一個普通的函式指標去讀取。因此,我們先取pf2的地址,然後把這個地址按照二級指標進行解指標,得到一個指標,而這個指標的值其實就是pf2的前8個位元組。所以剛才那幾行程式碼如果詳細一點來寫就是這樣:

void (C1::*pf2)() = &C1::test2;
void *ppf2 = reinterpret_cast<void *>(&pf2); // ppf2是pf2的地址
// 但此時ppf2應該是個二級指標,解指標後應該得到一個8位元組的數
uintptr_t pf_addr = *reinterpret_cast<uintptr_t *>(ppf2);
// pf_addr的值,應該就是我們想到的函式的地址的值了,還需要再轉換成函式指標型別
void (*pf)() = reinterpret_cast<void (*)()>(pf_addr);
// 按照函式方式呼叫
pf();

轉義之後相信大家應該更容易看得懂了。)

果然如此,前8個位元組解出來的地址,還真的是個可呼叫的函式。但到目前為止我們都沒有出現任何問題,是因為C1::test2是無參的,並且內部也與成員變數無關。如果把相同的操作用給C1::test的話就會core dump,有興趣的讀者可以自行嘗試。

既然是非靜態的成員函式,我們都只要正常操作都是用物件來呼叫的,這個物件會作為函式的一個隱藏引數(也就是this)來傳入,所以,我們其實少傳了一個this引數。例如C1::test的操作應該是這樣的:

class C1 {
public:
    int m1;
    void test(int a) {std::cout << "C1::test, a=" << a << ", m1=" << m1 << std::endl;}
};

int main(int argc, const char * argv[]) {
    C1 c1;
    c1.m1 = 1234;
    void (C1::*pf1)(int) = &C1::test;
    
    // 物件作為第一個引數傳進去,其他引數跟在後面,即可轉換為普通函式
    auto test_ppf = reinterpret_cast<void (**)(C1 *, int)>(&pf1);
    (*test_ppf)(&c1, 5);
    // 引用其實本質是指標的語法糖,所以也可以改寫成引用型別
    auto test_ppf2 = reinterpret_cast<void (**)(C1 &, int)>(&pf1);
    (*test_ppf2)(c1, 5);
    
    return 0;
}

/*呼叫結果:
C1::test, a=5, m1=1234
C1::test, a=5, m1=1234
*/

看來確實是這樣了,pf1的前8個位元組真的就是一個普通的函式,只不過有一個隱藏的this引數罷了。我們也就把obj->func(arg)的形式,成功改寫成了func(obj ,arg)的形式。那後8個位元組到底是幹什麼的呢?先別急,後面就知道了。在解釋後8個位元組的作用之前,我們不妨先換換腦子,看另一個問題。

成員變數的本質是記憶體偏移量和資料型別的記錄

這個小標題可能會讓讀者有點摸不著頭腦,不過沒關係,很快你就會明白,我們先來看一段例程:

class C1 {
public:
    int m1;
    // 為了方便觀察,這裡我用十六進位制列印m1
    void test(int a) {std::cout << "C1::test, a=" << a << ", m1=" << std::hex << m1 << std::endl;}
};

int main(int argc, const char * argv[]) {
    // 這是隨意寫的一段資料
    uint8_t data[] = {0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc};
    
    void (C1::*pf1)(int) = &C1::test;
    // 注意這裡,我將隱藏引數改為了void *
    auto test_ppf = reinterpret_cast<void (**)(void *, int)>(&pf1);
    auto f = *test_ppf;
    f(data, 8);
    
    return 0;
}
/*輸出結果:
C1::test, a=8, m1=78563412
*/

這通操作相當大膽,f是C1::test所對應的實際函式,照理說,第一個引數是要傳一個C1型別的物件的,但此時我傳入了一個隨意的二進位制資料,程式竟然可以正常執行。並且我們觀察執行結果,0x78563412正好是data的前4個位元組。這也就是說,程式把data當做了C1型別來處理,取的m1,就是取這個物件(或資料)的前4個位元組,並且當做整數來處理。

為了驗證這個說法,我們不妨再多定義幾個變數:

class C1 {
public:
    int m1;
    char m2;
    short m3;
    void test(int a) {std::cout << "C1::test, a=" << a << ", m1=" << std::hex << m1 << ", m2=" << m2 << ", m3=" << m3 << std::endl;}
};

int main(int argc, const char * argv[]) {
    // 這是隨意寫的一段資料
    uint8_t data[] = {0x12, 0x34, 0x56, 0x78, 0x3c, 0xbc, 0x11, 0xaa, 0xcc, 0x55};
    
    void (C1::*pf1)(int) = &C1::test;
    // 注意這裡,我將隱藏引數改為了void *
    auto test_ppf = reinterpret_cast<void (**)(void *, int)>(&pf1);
    auto f = *test_ppf;
    f(data, 8);
    
    return 0;
}

/*輸出結果:
C1::test, a=8, m1=78563412, m2=<, m3=aa11
*/

沒問題,成員都是按照對首地址的偏移,以及定義的型別來解析的,比如這裡m2,應當取的是0x3c所對應的ASCII碼,自然是'<'。

那麼此時我們在回頭看一眼這一節的小標題,有沒有恍然大悟呢?

虛擬函式表

我們再來看看,如果一個類(或父類)擁有虛擬函式,會變成什麼樣。請看下面例程:

// 省略SHOW相關程式碼,請參考前面例程
class C1 {
public:
    int m1 = 0x1234;
    virtual void test() {std::cout << m1 << std::endl;}
};

int main(int argc, const char * argv[]) {
    C1 c1;
    SHOW(c1);
    
    return 0;
}
/*輸出結果:
=====begin=====
name: c1
size: 16 Byte(s)
  | 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
 0|38|40|00|00|01|00|00|00|34|12|00|00|00|00|00|00|

======end======
*/

看起來m1跑到了0x08的位置,那麼0x00~0x07位置應當就是虛擬函式表指標了。將這個指標解開以後,就可以得到虛擬函式表,虛擬函式表其實就是一個指標陣列,每一個元素指向一個函式。為了驗證這個說法,我們不妨再做個實驗,請看例程:

class C1 {
public:
    int m1 = 0x1234;
    virtual void test() {std::cout << std::hex << m1 << std::endl;}
    virtual void test2() {std::cout << "test2" << std::endl;}
};

int main(int argc, const char * argv[]) {
    C1 c1;
    // 由於虛擬函式表指標是最初始的成員,偏移量為0,所以和物件首地址相同
    void *pvfl = static_cast<void *>(&c1);
    // 解pvfl應當得到一個陣列,但是由於無法確定陣列大小(也就是虛擬函式個數),因此用陣列元素指標偏移來完成
    void **vfl = *static_cast<void ***>(pvfl); // vfl是虛擬函式表,也就是個陣列,裡面的元素都是指標,所以vf1是void **型別,而pvfl是這個陣列的指標,所以pvf1是void ***型別(如果實在想不通,把最裡面的一層void *定義為func_t,vfl就是func_t[]型別,然後pvfl就是func_t (*)[]型別,所以*pvfl就是func_t[],再把陣列替換成指標,把func_t替換成void *得到前面程式碼)
    // 嘗試取出第一個元素
    void *vf1 = vfl[0];
    // 將這個元素轉化為函式指標,然後呼叫
    void (*f1)(C1 &) = reinterpret_cast<void (*)(C1 &)>(vf1);
    f1(c1);
    
    return 0;
}
/*呼叫結果:
1234
*/

我們成功通過虛擬函式表訪問到了成員函式。驗證一下,我們來嘗試取出test2對應函式地址:

class C1 {
public:
    int m1 = 0x1234;
    virtual void test() {std::cout << std::hex << m1 << std::endl;}
    virtual void test2() {std::cout << "test2" << std::endl;}
};

int main(int argc, const char * argv[]) {
    C1 c1;
    void *pvfl = static_cast<void *>(&c1);
    void **vfl = *static_cast<void ***>(pvfl);
    void *vf2 = vfl[1];
    void (*f2)(C1 &) = reinterpret_cast<void (*)(C1 &)>(vf2);
    f2(c1);
    
    return 0;
}

/*呼叫結果:
test2
*/

沒有問題,看來虛擬函式表,就是普通函式指標的指標,正常按照指標大小偏移即可。

虛擬函式指標及其呼叫過程

剛才我們用通過手動來控制指標偏移,找到了對應的虛擬函式並呼叫。編譯器也可按照同樣的方式,在成員定義列表中找到虛擬函式的位置,數出它是第幾個,然後去虛擬函式表中找。但倘若我把虛擬函式的函式指標單獨拿出來,該怎麼辦呢?(因為此時沒法通過變數名來判斷這是第幾個虛擬函式了。)玄機,就在函式指標當中。

請看下面歷程:

// 省略SHOW相關實現,請參考前面例程
class C1 {
public:
    int m1 = 0x1234;
    virtual void test() {std::cout << std::hex << m1 << std::endl;}
    virtual void test2() {std::cout << "test2" << std::endl;}
};

int main(int argc, const char * argv[]) {
    void (C1::*pf1)() = &C1::test;
    SHOW(pf1);
    
    return 0;
}

/*呼叫結果:
=====begin=====
name: pf1
size: 16 Byte(s)
  | 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
 0|01|00|00|00|00|00|00|00|00|00|00|00|00|00|00|00|

======end======
*/

我們可以看到,虛擬函式的函式指標中儲存得不再是實際的函式地址(因為實際的儲存在虛擬函式表中),而是位元組的偏移量,注意這裡偏移量是1起始,因此實際在虛擬函式表中的偏移量比這個數值少1(主要是由於0用來表示空指標了)。

驗證一下,我們取test2即可:

class C1 {
public:
    int m1 = 0x1234;
    virtual void test() {std::cout << std::hex << m1 << std::endl;}
    virtual void test2() {std::cout << "test2" << std::endl;}
    virtual void test3() {}
};

int main(int argc, const char * argv[]) {
    void (C1::*pf2)() = &C1::test;
    SHOW(pf2);
    
    return 0;
}
/*呼叫結果:
=====begin=====
name: pf2
size: 16 Byte(s)
  | 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
 0|09|00|00|00|00|00|00|00|00|00|00|00|00|00|00|00|

======end======
*/

OK,如果有繼承關係會怎樣呢?

class C1 {
public:
    int m1 = 0x1234;
    virtual void test() {std::cout << std::hex << m1 << std::endl;}
    virtual void test2() {std::cout << "test2" << std::endl;}
};

class C2 : public C1 {
public:
    virtual void test3() {}
};


int main(int argc, const char * argv[]) {
    // 這裡一定不可以用auto,因為C2中沒有override這個函式,所以auto會推匯出void (C1::*)()而不是void (C2::*)()
    void (C2::*pf1)() = &C2::test;
    SHOW(pf1);
    
    void (C2::*pf2)() = &C2::test2;
    SHOW(pf2);
    
    void (C2::*pf3)() = &C2::test3;
    SHOW(pf3);
    
    return 0;
}

/*執行結果:
=====begin=====
name: pf1
size: 16 Byte(s)
  | 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
 0|01|00|00|00|00|00|00|00|00|00|00|00|00|00|00|00|

======end======
=====begin=====
name: pf2
size: 16 Byte(s)
  | 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
 0|09|00|00|00|00|00|00|00|00|00|00|00|00|00|00|00|

======end======
=====begin=====
name: pf3
size: 16 Byte(s)
  | 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
 0|11|00|00|00|00|00|00|00|00|00|00|00|00|00|00|00|

======end======
*/

也就是說,繼承時,父類的虛擬函式表也會繼承過來,新的虛擬函式會續寫在父類的虛擬函式表之後。

多繼承和那神祕的高8位元組

單繼承的,虛擬函式表順延續寫看起來理所應當,可多繼承呢?

多繼承時,C++會將第一個繼承類作為主父類,而其他的父類虛擬函式表將單獨繼承,不再合併。也就是說,如果一個類有N個父類的話,就會有N個虛擬函式表。

為了驗證,請看例程:

struct A {
    uint8_t pad[4] {1, 2, 3, 4};
    virtual void f1() {}
};
struct B {
    uint8_t pad[8] {1, 2, 3, 4, 5, 6, 7, 8};
    virtual void f2() {}
};
struct C : A, B {};

int main(int argc, const char * argv[]) {
    C c;
    SHOW(c);
    
    return 0;
}

/*執行結果:
=====begin=====
name: c
size: 32 Byte(s)
  | 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
 0|50|40|00|00|01|00|00|00|01|02|03|04|00|00|00|00|
 1|68|40|00|00|01|00|00|00|01|02|03|04|05|06|07|08|

======end======
*/

可以看到,0x00~0x07是一個虛擬函式表指標,也是主的(C和A拼接後的),而0x10~0x17是另一個虛擬函式表(從B直接繼承下來的),讀者可以自行驗證該說法。

接下來我們做一個操作,在三個類中的函式裡分別列印出this,請看例程:

struct A {
    uint8_t pad[4] {1, 2, 3, 4};
    virtual void f1() {std::cout << "f1, this=" << this << std::endl;}
};
struct B {
    uint8_t pad[8] {1, 2, 3, 4, 5, 6, 7, 8};
    virtual void f2() {std::cout << "f2, this=" << this << std::endl;}
};
struct C : A, B {
    int m = 5;
    virtual void f3() {std::cout << "f3, this=" << this << std::endl;}
};

int main(int argc, const char * argv[]) {
    C c;
    c.f1();
    c.f2();
    c.f3();
    SHOW(c);
    
    return 0;
}
/*呼叫結果:
f1, this=0x16fdff448
f2, this=0x16fdff458
f3, this=0x16fdff448
=====begin=====
name: c
size: 32 Byte(s)
  | 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
 0|50|40|00|00|01|00|00|00|01|02|03|04|00|00|00|00|
 1|70|40|00|00|01|00|00|00|01|02|03|04|05|06|07|08|
 2|05|00|00|00|00|00|00|00|

======end======
*/

this指標的不同,其實也證實了上面的說法,A和C中函式都是正常的this(實際物件的首地址),而B中的函式列印出的this卻發生了偏移。其實,C++的多繼承,從第二個父類開始,就會轉換成類的組合來處理。相當於在C類中先放了一個B的物件,因此我們觀察物件c的記憶體佈局,首先0x00~0x0f是從A類繼承來的內容,然後0x10~0x1f是一個完整的B,最後0x20~0x27是C中新增的成員。

由於C的虛擬函式直接續寫在了從A繼承來的虛擬函式表後面,因此,這兩個類中的虛擬函式傳入的this都是物件的首地址,而B類中的虛擬函式的this則要傳入C類中B類繼承來位置的首地址,可以看得出偏移量是0x10,正好上面驗證f2的this比f1和f3的this向後偏移了0x10。

現在我們再來列印一下三個函式指標:

struct A {
    uint8_t pad[4] {1, 2, 3, 4};
    virtual void f1() {std::cout << "f1, this=" << this << std::endl;}
};
struct B {
    uint8_t pad[8] {1, 2, 3, 4, 5, 6, 7, 8};
    virtual void f2() {std::cout << "f2, this=" << this << std::endl;}
};
struct C : A, B {
    int m = 5;
    virtual void f3() {std::cout << "f3, this=" << this << std::endl;}
};

int main(int argc, const char * argv[]) {
    void (C::*p1)() = &C::f1;
    SHOW(p1);
    void (C::*p2)() = &C::f2;
    SHOW(p2);
    void (C::*p3)() = &C::f3;
    SHOW(p3);
    return 0;
}
/*呼叫結果:
=====begin=====
name: p1
size: 16 Byte(s)
  | 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
 0|01|00|00|00|00|00|00|00|00|00|00|00|00|00|00|00|

======end======
=====begin=====
name: p2
size: 16 Byte(s)
  | 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
 0|01|00|00|00|00|00|00|00|10|00|00|00|00|00|00|00|

======end======
=====begin=====
name: p3
size: 16 Byte(s)
  | 0| 1| 2| 3| 4| 5| 6| 7| 8| 9| a| b| c| d| e| f|
 0|09|00|00|00|00|00|00|00|00|00|00|00|00|00|00|00|

======end======
*/

終於,成員函式指標神祕的高8位元組作用解開了,就是這個this的偏移量,這裡的0x10表示要向後偏移16位元組。這就是成員函式指標是2個指標長度的原因所在了,第8位元組是函式指標,高8位元組是this的偏移量。

總結,完整的虛擬函式指標的呼叫方式如下:

1.取第8位元組,表示虛擬函式表的偏移量

2.取高8位元組,表示this的偏移量

3.根據this偏移量找到虛擬函式表

4.根據虛擬函式表偏移量找到函式

5.將呼叫者向後偏移對應的位置,作為實際呼叫者,傳入函式的第一個引數中

例如:(obj.*vf1)() 【obj是物件,vf1是一個虛擬函式指標】

1.取vf1低8位元組,記為f1

2.取vf2高8位元組,記為adj

3.將&obj向後偏移adj位元組,這是虛擬函式表指標,記為vpfl

4.vpfl向後偏移f1 * 指標大小,這是實際的函式指標,記為rf

5.呼叫rf,第一個引數是&obj偏移adj位元組,其他引數遞補。

結語

C++確實很難,因為它用了很基礎的C作為底層支撐,卻提供了很多高階的語法和功能,但如果我們可以把握本質,揭開它神祕面紗以後,發現其實也不過如此。

關於C++成員函式指標的相關問題就講解到這裡,如果讀者有疑問,歡迎留言!

相關文章