C++ 虛函數的內部實現

C++ 虛函數的內部實現

虛函數看起來是個玄之又玄的東西,但其實特別簡單!了解了虛函數的內部實現,關於虛函數的各種問題都不在話下啦!

1. 知識儲備

閱讀這篇文章,你需要事先了解以下幾個概念:

  • 什麼是繼承?

  • 什麼是虛函數?

    在C++中,在基類的成員函數聲明前加上關鍵字 virtual 即可讓該函數成為 虛函數,派生類中對此函數的不同實現都會繼承這一修飾符。

  • 為什麼需要虛函數?

這涉及到面向對象程序設計中多態動態綁定的概念。

如果你已經完全了解上述概念,那麼這篇文章很適合你去深入了解虛函數~

2. C++中類的memory Layout

為了更好地理解虛函數的內部實現,我們首先需要知道,C++的類中成員函數和成員變量在內存中的空間分配。

1. 我們從最普通的一個類說起~
class X {
    int	x;
    float	xx;
    static int  count;
public:
    X() {}
    ~X() {}
    void printInt() {}
    void printFloat() {}
  	static void printCount() {}
};

如果我們在這個程序中定義了這個類的一個對象,那麼這個類的內存分佈如下圖所示:

類的非靜態成員變量會被保存在棧上,類的靜態成員變量被保存在數據段,而類的成員函數被保存在代碼段。

 class X {
 	int	x;
 	float	xx;
 	static int  count;
 public:
 	X() {}
 	~X() {}
	void printInt() {}
	void printFloat() {}
	static void printCount() {}
};
              
2. 含有虛函數的基類的內存分佈

如果一個類中含有虛函數,那麼為了實現動態綁定,編譯器會在原來的代碼中插入(augment)一個新的成員變量–一個成員指針 vptr, 這個指針指向一張包含所有虛函數的函數指針表 vtable. 當我們調用虛函數時,實際上是通過vptr這個指針來調用函數指針表vtable裏面的某個函數指針來實現函數調用。

一般而言,這張vtable會在數據段,是靜態的,每一個類僅有一張表。但是這不是死規定,這是由編譯器的實現決定的。vptr這個指針和成員變量一致,存在在堆棧段,是每一個對象都會有的。

vtable中的第一個entry包含了當前類及其基類的相關信息,其餘entry是函數指針。

現在來看一個例子~

class X {
    int         x;
    float       xx;
    static int  count;
public:
    X() {}
    virtual ~X() {}
    virtual void printAll() {}
    void printInt() {}
    void printFloat() {}
    static void printCount() {}
};
              
3. 含有虛函數的子類的內存分佈

此時,基類的成員變量和成員函數相當於派生類的子對象,也就是說派生類會繼承基類的vptr。這時會先為基類的成員函數和成員對象分配內存空間,然後再為派生類的自己的成員變量和成員函數分配空間。vptr會指向Y這個類的vtable

如果派生類寫了一個不在基類里的新的虛函數,那麼這個vtable會多出一行,行內的內容是指向這個新虛函數的函數指針。

class X {
    int     x;
public:
    X() {}
    virtual ~X() {}
    virtual void printAll() {}
};
class Y : public X {
    int     y;
public:
    Y() {}
    ~Y() {}
    void printAll() {}
};
              
4. 含有虛函數、有多繼承的子類的內存分佈

有多個基類的派生類會有多個vptr, 用來指向繼承自不同基類的vtable。也就是說,每一個有虛函數的基類都會有一個虛函數指針表。

我們來看一個Z類繼承自X類和Y類的例子。

class X {
public:
    int     x;
    virtual ~X() {}
    virtual void printX() {}
};
class Y {
public:
    int     y;
    virtual ~Y() {}
    virtual void printY() {}
};
class Z : public X, public Y {
public:
    int     z;
    ~Z() {}
    void printX() {}
    void printY() {}
    void printZ() {}
};
              

3. 虛函數的內部實現

了解了虛函數在內存中的分配方式後,理解虛函數的實現以及動態綁定就變得非常簡單了。

這裡以多繼承的子類的代碼為例,上代碼~

class X {
public:
    int     x;
    virtual ~X() {}
    virtual void printX() { cout<<"printX() in X"<<endl; }
};

class Y {
public:
    int     y;
    virtual ~Y() {}
    virtual void printY() { cout<<"printY() in Y"<<endl; }
};

class Z : public X, public Y {
public:
    int     z;
    ~Z() {}
    void printX() { cout<<"printX() in Z"<<endl; }
    void printY() { cout<<"printY() in Z"<<endl; }
    void printZ() { cout<<"printZ() in Z"<<endl; }
};

int main(){
  Y *y_ptr = new Z();
  y_ptr->printY(); // OK
  y_ptr->printZ(); // Not OK, Y類的虛函數表中沒有printZ()函數
  
  y_ptr->y = 3; // OK
  y_ptr->z = 3;// not OK, Y類的空間中沒有變量z
}

所以在上述代碼中,y_ptr指向的是在Z類對象中的子對象,即Y類對象在Z類中函數與變量。

注意⚠️ 此時y_ptr中的_vptr指向的是Z類對象的vtable

y_ptr->printY()這行代碼,其實會被編譯器翻譯成如下偽代碼

((y_ptr->_vptr)->_vtbl[2])();

其中y_ptr->_vptr指向Y類對象的vptr指針,vptr指針再指向虛函數表中對應的函數指針項,即((y_ptr->_vptr)->_vtbl[2]), 最後通過函數指針來實現函數調用。

由於這個_vptr指向的是Z類對象的虛函數表,所以調用的printY()函數實際上是Z類中實現的printY(),即輸出"printY() in Z"。 動態綁定就這樣實現了。

4. 用幾個問題加深理解

沿用3中的例子,我們來看接下來的幾個問題。

Q1. 如果將Z類對象賦值給Y類變量,動態綁定還會發生嗎?

即如下代碼中,輸出是"printY() in Z"還是"printY() in Y"

Z zz;
Y yy = zz;
yy.printY();

答案是不會發生,輸出的結果是"printY() in Y"

首先我們需要明確一個很重要的概念,對_vptr這個指針的賦值操作是在構造類對象的過程中發生的。換一句話說,當一個類的實例被創建的時候_vptr被賦值,指向該類的vtable。一旦類的實例被創建,一個類對象裏面的_vptr永遠不會變,永遠都會指向所屬類型的虛函數表。

不論是賦值操作還是賦值構造時, 只會處理成員變量,即把zz中的成員變量賦值給yy, 但是_vptr還是指向Y類的虛函數表。

Q2. 如果在基類中不聲明某個函數是虛函數,在子類中重寫了這個函數,動態綁定還會發生嗎?

即如下代碼中,輸出是"printX() in Z"還是"printX() in X"

class X {
public:
    int     x;
    virtual ~X() {}
    void printX() { cout<<"printX() in X"<<endl; }
};
class Z : public X {
public:
    int     z;
    ~Z() {}
    void printX() { cout<<"printX() in Z"<<endl; }
    void printZ() { cout<<"printZ() in Z"<<endl; }
};

int main(){
  X *x_ptr = new Z();
  x_ptr->printX(); // OK
}

答案是不會發生,輸出的結果是"printX() in X"。沒有聲明為虛函數的函數,不會被放入虛函數表中,即vtable不會保存該函數的函數指針。這時,動態綁定肯定不會發生了。

5. 總結

  1. 一般而言,虛函數表是屬於一個類的(one vtable per class), 位於靜態數據區,而虛函數表指針_vptr是屬於一個類的對象的(one vptr per object).
  2. 一個由多繼承關係的類會有多個虛函數指針。
  3. 虛函數指針的賦值操作是在構造類對象的過程中發生的,之後的賦值操作不會改變vptr的值
  4. C++標準沒有定義動態綁定的具體實現方式,只是陳述了動態綁定的行為。具體的實現與編譯器相關。

以上為個人學習總結,如有錯誤歡迎指出!

這篇博文參考了如下文章:

//www.vishalchovatiya.com/memory-layout-of-cpp-object/

//www.vishalchovatiya.com/part-1-all-about-virtual-keyword-in-cpp-how-virtual-function-works-internally/

//www.learncpp.com/cpp-tutorial/the-virtual-table/

//www.cnblogs.com/yinheyi/p/10525543.html

Tags: