深度解讀《深度探索C++物件模型》之C++虛擬函式實現分析(一)

iShare_爱分享發表於2024-04-23

接下來我將持續更新“深度解讀《深度探索C++物件模型》”系列,敬請期待,歡迎關注!也可以關注公眾號:iShare愛分享,自動獲得推文和全部的文章列表。

假如有這樣的一段程式碼,程式碼中定義了一個Object類,類中有一個成員函式print,透過以下的兩種呼叫方式呼叫:

Object b;
Object* p = new Object;
b.print();
p->print();

請問這兩種方式有什麼區別嗎?在效率上一樣嗎?答案是不確定。因為得看成員函式print的宣告方式,它可能是靜態的,可能是非靜態的,也可能是一個虛擬函式。還得看Object類的具體定義,它可能是獨立的類,也有可能是經過多重繼承來的類,或者繼承的父類中有一個虛基類。

靜態成員函式和非虛成員函式比較簡單,我們在下一小節簡單介紹一下即可,本文重點講解虛擬函式的實現及其效率。

成員函式種類

  • 非靜態成員函式

非靜態成員函式和普通的非成員函式是一樣的,它也是被編譯器放置在程式碼段中,且可以像普通函式那樣可以獲取到它的地址。和普通非成員函式的區別是它的呼叫必須得經由一個物件或者物件的指標來呼叫,而且可以直接訪問類中非公開的資料成員。下面的程式碼列印出函式的地址:

#include <cstdio>

class Object {
public:
    void print() {
        printf("a=%d, b=%d\n", a, b);
    }
    int a = 1;
    int b = 2;
};

void printObject(Object* obj) {
    printf("a=%d, b=%d\n", obj->a, obj->b);
}

int main() {
    printf("Object::print = %p\n", &Object::print);
    printf("printObject = %p\n", &printObject);

    return 0;
}

程式的輸出結果如下,從列印結果來看,兩者的地址比較相近,說明它們都是一起放在程式碼段中的,從生成的彙編程式碼也可以看出來。

Object::print = 0x1007b3f30
printObject = 0x1007b3e70

非靜態成員函式和普通非成員函式的執行效率上也是一樣的,普通非成員函式的實現上,對類中成員的訪問看起來像是要經過指標的間接訪問,如obj->a,非靜態成員函式的訪問看起來更直接一點,直接可以對類中的成員進行存取,好像是非靜態成員函式的效率更高一些,其實不然,非靜態成員函式的呼叫,編譯器會隱式的把它轉換成另一種形式:

Object obj;
obj.print();
// 轉換成:
print(&obj);
// print的定義轉換成:
print(Object* const this) {
    printf("a=%d, b=%d\n", this->a, this->b);
}

兩者在本質上是一樣的,檢視生成的彙編程式碼也是一樣的。另外也說明了為什麼非靜態成員函式要經由一個物件或物件的指標來呼叫。

  • 靜態成員函式

上面提到的非靜態成員函式的呼叫,必須要經由類的物件來呼叫,是因為需要將物件的地址作為函式的引數,也就是隱式的this指標,這樣在函式中訪問類的非靜態資料成員時將繫結到此地址上,也就是將此地址作為基地址,經過偏移得到資料成員的地址。但是如果函式中不需要訪問非靜態資料成員的話,是不需要this指標的,但目前的編譯器並不區分這種情況。靜態成員函式不能訪問類中的非靜態資料成員,所以是不需要this指標的,如Object類中定義了靜態成員函式static int static_func(),透過物件呼叫:

Object obj;

obj.static_func();

或者透過物件的指標呼叫:

Object* pobj = new Object;

pobj->static_func();

最終都會轉換成員如下的形式:

Object::static_func();

透過物件或者物件的指標來呼叫只是語法上的便利而已,它並不需要物件的地址作為引數(this指標)。

那麼靜態成員函式存在的意義是什麼?靜態成員函式在C++誕生之初是不支援的,是在後面的版本中增加進去的。假設不支援靜態成員函式時,類中有一個非公開的靜態資料成員,如果外面的程式碼需要訪問這個靜態資料,那麼就需要寫一個非靜態成員函式來存取它,而非靜態成員函式需要經由物件來呼叫,但有時候在這個時間點沒有建立一個物件或者沒有必要建立一個物件,那麼就有了以下的變通做法:

// 假設定義了get_static_var函式用於返回靜態資料成員
((Object*) 0))->get_static_var();
// 編譯器會轉換成:
get_static_var((Object*) 0));

上面的程式碼把0強制轉換為Object型別的指標,然後經由它來呼叫非靜態成員函式,編譯器會把0作為物件的地址傳遞給函式,但函式中不會使用這個0,所以不會出現問題。由於有這些需求的存在,C++標準委員會增加了支援靜態成員函式,靜態成員函式可以訪問類中的非公開的靜態資料成員,且不需要經由類的物件來呼叫。

靜態成員函式和非靜態成員函式、普通函式一樣都是儲存在程式碼段中的,也可以獲取到它的地址,它是一個實際的記憶體的地址,是一個資料,如上面定義的static_func函式,它的型別為int (*)(),就是一個普通的函式型別。而非靜態成員函式,返回的是一個“指向類成員函式的指標”,如上面定義的print函式,返回的型別是:

void (Object::*) ();

靜態成員函式基本上等同於普通函式,所以和C語言結合程式設計時,可以作為回撥函式傳遞給C語言寫的函式。

總結一下,靜態成員函式具有以下的特性:

    • 靜態成員函式不能存取類中的非靜態資料成員。
    • 靜態成員函式不能被宣告為const、volatile或者是virtual。
    • 靜態成員不需要經由類的物件來呼叫。
  • 虛擬函式

虛擬函式是否也可以像非虛擬函式那樣獲取到它的地址呢?我們寫個程式來測試一下。

#include <cstdio>

class Object {
public:
    virtual void virtual_func1() {
        printf("this is virtual function 1\n");
    }
    virtual void virtual_func2() {
        printf("this is virtual function 2\n");
    }
};

int main() {
    printf("Object::virtual_func1 = %p\n", &Object::virtual_func1);
    printf("Object::virtual_func2 = %p\n", &Object::virtual_func2);
    return 0;
}

上面程式的輸出:

Object::virtual_func1 = 0x0
Object::virtual_func2 = 0x8

程式的輸出結果並不是一個記憶體地址,而是一個數字,其實這是一個偏移值,對應的是這個虛擬函式在虛擬函式表中的位置,一個位置佔用8位元組大小,第一個是0,第二個是8,以此類推,每多一個虛擬函式,就在這個表中佔用一個位置。看起來像是無法獲取到虛擬函式的地址,其實不然,虛擬函式的地址就存放在虛擬函式表中,只是我們無法直接獲取到它,但是我們記得,如果有虛擬函式時,物件的前面會被編譯器插入一個虛擬函式表指標,這個指標就是指向類的虛擬函式表,我們可以透過它來獲取到虛擬函式的地址,下面演示一下透過非常規手段來呼叫虛擬函式的做法:

#include <cstdio>

class Object {
public:
    virtual void virtual_func1() {
        printf("this is virtual function 1\n");
    }
    virtual void virtual_func2() {
        printf("this is virtual function 2\n");
    }
};

int main() {
    Object* pobj = new Object;
    using Fun = void (*)(void);
    Fun** ptr = (Fun**)pobj;
    printf("vptr = %p\n", *ptr);
    for (auto i = 0; i < 2; ++i) {
        Fun fp = *(*ptr + i);	//取得虛擬函式的記憶體地址
        printf("vptr[%d] = %p\n", i, fp);
        fp();	//此行呼叫虛擬函式
    }
    delete pobj;

    return 0;
}

程式的輸出結果:

vptr = 0x100264030
vptr[0] = 0x100263ea4
this is virtual function 1
vptr[1] = 0x100263ecc
this is virtual function 2

可以看到,虛擬函式的地址不光可以獲取得到,而且還可以直接呼叫它,呼叫它的前提是函式中沒有訪問類的非靜態資料成員,不然就會出現執行錯誤。vptr就是寫入到物件前面的虛擬函式表指標,它的值就是虛擬函式表在記憶體中的地址,虛擬函式表中記錄了兩項內容,對應了兩個虛擬函式的地址,即vptr[0]是虛擬函式virtual_func1的地址,vptr[1]是虛擬函式virtual_func2的地址。把他們強制轉換成普通函式的型別指標,然後可以直接呼叫他們,所以這裡是沒有物件的this指標的,也就不能訪問類中的非靜態資料成員了。

虛擬函式的實現

從上一小節中我們已經窺探到虛擬函式的一般實現模型,每一個類有一個虛擬函式表,虛擬函式表中包含類中每個虛擬函式的地址,然後每個物件的前面會被編譯器插入一個指向虛擬函式表的指標,同一個類的所有物件都共享同一個虛擬函式表。接下來的內容中將詳細分析虛擬函式的實現細節,包括單一繼承、多重繼承和虛繼承的情況。

多型是C++中最重要的特性之一,也是組成物件導向程式設計正規化的基石,虛擬函式則是為多型而生。那麼何為多型?多型是在基類中定義一組介面,根據不同的業務場景派生出不同的子類,在子類中實現介面,上層程式碼根據業務邏輯呼叫介面,不關心介面的具體實現。在程式碼中,一般是宣告一個基類的指標,此指標在執行期間可能指向不同的派生類,然後透過基類的指標呼叫一個介面,這個介面在不同的派生類中有不同的實現,所以根據基類的指標指向哪個具體的派生類,呼叫的就是這個派生類的例項。假設有一個名稱為print的介面,p是基類型別的指標,那麼下面的呼叫:

p->print();

是如何識別出要實施多型的行為?以及如何呼叫到具體哪個派生類中的print?如果是在指標型別上增加資訊,以指明具體所指物件的型別,那麼會改變指標原有的語義,造成和C語言的不相容,而且也不是每個型別都需要這個資訊,這會造成不必要的空間浪費。如果是在每個類物件中增加資訊,那麼在不需要多型的物件中也需要存放這些資訊,也會造成空間上的浪費。因此增加了一個關鍵字virtual,用於修飾那些需要多型的函式,這樣的函式就叫做虛擬函式,所以識別一個類是否支援多型,就看這個類中是否宣告瞭虛擬函式。只要類中有虛擬函式,就說明需要在類物件中儲存執行期的資訊。

那麼在物件中要儲存哪些資訊才能夠保證保證上面程式碼中print的呼叫是呼叫到正確的派生類中的例項呢?要呼叫到正確的print例項,我們需要知道:

  • p指向具體的物件型別,讓我們知道要呼叫哪個print;
  • print的位置,以便我們可以正確呼叫它。

要如何實現它,不同的編譯器可能有不同的實現方法,通常是使用虛擬函式表的做法。編譯器在編譯的過程中,收集到哪些是虛擬函式,然後將這些虛擬函式的地址存放一個表格中,這些虛擬函式的地址在編譯期間確定的,執行期間是不會改變的,虛擬函式的個數也是固定的,在程式的執行期間不能刪除或者增加,所以表格的大小也是固定的,這個過程由編譯器在編譯期間完成。表格中虛擬函式的位置按照類中宣告的順序,位置是固定不變的,我們在上節中透過虛擬函式名稱列印出來的值就是虛擬函式在虛擬函式表中的位置,即相對於表格首地址的偏移值。

有了這個表格,那麼如何定址到這個表格呢?方法就是編譯器根據類中是否有虛擬函式,如果有虛擬函式,就在類的建構函式里插入一些彙編程式碼,在構造物件時,在物件的前面插入一個指標,這個指標指向這個虛擬函式表,所以這個指標也叫做虛擬函式表指標。下面以具體的程式碼來看看虛擬函式是怎麼呼叫的,把上面的例子main函式修改如下,其它地方不變:

int main() {
    Object* pobj = new Object;
    pobj->virtual_func1();
    pobj->virtual_func2();
    delete pobj;

    return 0;
}

我們來看下生成的彙編程式碼,首先來看看虛擬函式表長什麼樣:

vtable for Object:
    .quad   0
    .quad   typeinfo for Object
    .quad   Object::virtual_func1()
    .quad   Object::virtual_func2()

它是彙編中定義在資料段的一組資料,“vtable for Object”是它的標籤,代表了這個資料區的起始地址,每一行定義一條資料,第一列.quad表示資料的大小,佔用8位元組,第二列表示資料的值,可以是數字,也可以是標籤,標籤是地址的引用。其實這個完整的表叫做虛表,它包含了虛擬函式表、RTTI資訊和虛繼承相關的資訊,Clang和Gcc編譯器是把它們合在一起了,其它編譯器可能是分開的。第一行是虛繼承中用到,之前已經講過了,第二行是RTTI資訊,這個以後再講。第三、四行是兩個虛擬函式的地址。

接著看看Object類的預設建構函式的程式碼:

Object::Object() [base object constructor]: 	# @Object::Object() [base object constructor]
    # 略...
    lea     rcx, [rip + vtable for Object]
    add     rcx, 16
    mov     qword ptr [rax], rcx
    # 略...

之前已經講過,有虛擬函式時編譯器會為類生成預設建構函式,在預設建構函式里在類物件的前面設定了虛擬函式表指標。在這個預設建構函式里,主要的程式碼就是上面這三行,首先獲取虛表(將上面)的起始地址存放在rcx暫存器,然後加上16的偏移值跳過第一、二行,這時指向第三行資料,也就是第一個虛擬函式的位置,然後將這個地址賦值給[rax],rax是存放的物件的首地址,這就完成了給物件設定虛擬函式表指標。

接著看main函式中對虛擬函式的呼叫:

main:								# @main
    # 略...
  	# 呼叫建構函式
    mov     rdi, rax
    mov     qword ptr [rbp - 32], rdi       # 8-byte Spill
    call    Object::Object() [base object constructor]
    mov     rax, qword ptr [rbp - 32]       # 8-byte Reload
    mov     qword ptr [rbp - 16], rax
		# 呼叫第一個虛擬函式
    mov     rdi, qword ptr [rbp - 16]
    mov     rax, qword ptr [rdi]
    call    qword ptr [rax]
  	# 呼叫第二個虛擬函式
    mov     rdi, qword ptr [rbp - 16]
    mov     rax, qword ptr [rdi]
    call    qword ptr [rax + 8]
    # 略...

上面彙編程式碼中的第4行rax是呼叫new函式後返回來的地址,也就是pobj指標,把它存放到rdi暫存器中作為引數,同時也儲存到棧空間rbp - 32中,然後呼叫建構函式,構造完成之後再複製這個地址到棧空間rbp - 16中。接下來的第10到12行是第一個虛擬函式的呼叫,將物件的首地址載入到rdi暫存器中,然後對其取內容,也就是是相當於指標的解引用,即 (*pobj),取得的內容即是建構函式中設定的虛擬函式表的地址,它是一個指向第一個虛擬函式的地址,然後第12行對其取內容,也即是對這個地址解引用,取得第一個虛擬函式的地址,然後以rdi暫存器(即物件的首地址)為第一個引數呼叫它,相當於:virtual_func1(pobj)。第14到16行是對第二個虛擬函式的呼叫,流程和第一個基本一樣,區別在於將虛擬函式表的地址加上8的偏移量以指向第二個虛擬函式。

如果在一個虛擬函式中呼叫另一個虛擬函式又會怎樣?第一個虛擬函式已經決議出是呼叫哪個物件的例項了,那麼在其中呼叫其它虛擬函式還需要再動態決議嗎?把main函式中對第二個虛擬函式的呼叫去掉,在第一個虛擬函式中增加以下程式碼:

virtual_func2();
Object::virtual_func2();

來看下對應生成的彙編程式碼,其它程式碼都差不多,主要看virtual_func1函式的程式碼:

Object::virtual_func1():            # @Object::virtual_func1()
    # 略...
    mov     rdi, qword ptr [rbp - 16]       # 8-byte Reload
    mov     rax, qword ptr [rdi]
    call    qword ptr [rax + 8]
    mov     rdi, qword ptr [rbp - 16]       # 8-byte Reload
    call    Object::virtual_func2()
    # 略...

rbp - 16儲存的是物件的首地址,第3到5行對應的是上面C++程式碼中第一句的呼叫,看起來在虛擬函式中呼叫另一個虛擬函式,用的還是動態決議的方法,這裡編譯器沒有識別出已經決議出具體的物件了。從彙編程式碼的第6、7行看到,透過前面加類名的限定符,是直接呼叫到這個函式,如果你明確呼叫的是哪個函式的話,可以直接在函式的前面加上類名,這樣就不需要用多型的方式去呼叫了。

如果不是透過指標型別來呼叫虛擬函式,而是透過物件來呼叫,結果是什麼情況?把main函式改成如下:

int main() {
    Object obj;
    obj.virtual_func1();
    obj.virtual_func2();

    return 0;
}

檢視main函式對應的彙編程式碼:

main:                           # @main
    # 略...
    lea     rdi, [rbp - 16]
    call    Object::virtual_func1()
    lea     rdi, [rbp - 16]
    call    Object::virtual_func2()
    # 略...

可以看到透過物件來呼叫虛擬函式,是直接呼叫到這個物件的函式例項的,沒有使用多型的方式,所以透過物件的方式呼叫是沒有多型的行為的,只有透過類的指標或者引用型別來呼叫虛擬函式,才會有多型的行為。

單一繼承下的虛擬函式

假設有以下的類定義及繼承關係:

class Point {
public:
    Point(int x = 0) { _x = x; }
    virtual ~Point() = default;
    virtual void drawLine() = 0;
    int x() { return _x; }
    virtual int y() { return 0; }
    virtual int z() { return 0; }
private:
    int _x;
};
class Point2d: public Point {
public:
    Point2d(int x = 0, int y = 0): Point(x) { _y = y; }
    virtual ~Point2d() = default;
    void drawLine() override { }
    virtual void rotate() { }
    int y() override { return _y; }
private:
    int _y;
};
class Point3d: public Point2d {
public:
    Point3d(int x = 0, int y = 0, int z = 0): Point2d(x, y) { _z = z; }
    virtual ~Point3d() = default;
    void drawLine() override { }
    void rotate() override { }
    int z() override { return _z; }
private:
    int _z;
};

int main() {
    Point* p = new Point3d(1, 1, 1);
    printf("z = %d\n", p->z());
    delete p;
    return 0;
}

先來看看生成的彙編程式碼中的虛擬函式表:

vtable for Point:
    .quad   0
    .quad   typeinfo for Point
    .quad   Point::~Point() [base object destructor]
    .quad   Point::~Point() [deleting destructor]
    .quad   __cxa_pure_virtual
    .quad   Point::y()
    .quad   Point::z()

vtable for Point2d:
    .quad   0
    .quad   typeinfo for Point2d
    .quad   Point2d::~Point2d() [base object destructor]
    .quad   Point2d::~Point2d() [deleting destructor]
    .quad   Point2d::drawLine()
    .quad   Point2d::y()
    .quad   Point::z()
    .quad   Point2d::rotate()

vtable for Point3d:
    .quad   0
    .quad   typeinfo for Point3d
    .quad   Point3d::~Point3d() [base object destructor]
    .quad   Point3d::~Point3d() [deleting destructor]
    .quad   Point3d::drawLine()
    .quad   Point2d::y()
    .quad   Point3d::z()
    .quad   Point3d::rotate()

每個類都有一個對應的虛擬函式表,虛擬函式表中的內容主要來自於三方面:

  • 改寫基類中對應的虛擬函式,用自己實現的虛擬函式的地址寫入到對應表格中的位置;
  • 從基類中繼承而來的虛擬函式,直接複製基類虛擬函式的地址新增到虛擬函式表中;
  • 新增的虛擬函式,基類中沒有,子類的虛擬函式表會增加一行容納新條目;

基類和子類使用各自的虛擬函式表,互不干擾,即使子類中沒有改寫基類的虛擬函式,也沒有新增虛擬函式,編譯器也會為子類新建一個虛擬函式表,內容從基類中複製過來,內容和基類完全一樣。

虛擬函式表中的虛擬函式的排列順序是固定的,一般是按照在類中的宣告順序,如C++程式碼中的這行程式碼:

p->z();

要定址到正確的z函式例項的地址,我們首先需要知道p指標所指向的具體物件,然後需要知道z函式在表格中的位置,如上例中,z函式在第5個條目,也就是說虛擬函式表的起始地址加上32的偏移量就可以定址到它,這個位置保持不變,無論p指標指向哪個物件,都能找到正確的z函式。如果子類中有新增的虛擬函式,新增的虛擬函式宣告的位置插在從基類中繼承來的虛擬函式中間,編譯器會做調整,把它安排在後面,在原有的順序上再遞增,如上例中的rotate函式。

如果您感興趣這方面的內容,請在微信上搜尋公眾號iShare愛分享並關注,以便在內容更新時直接向您推送。
image

相關文章