關於C++虛函數與靜態、動態綁定的問題

覆蓋:如果派生類中的方法,和基類繼承來的某個方法,返回值、函數名、參數列表都相同,而且基類的方法是virtual虛函數,那麼派生類的這個方法,自動處理成虛函數,它們之間成為覆蓋關系;也就是說派生類會在自己虛函數表中將從基類繼承來的虛函數進行替換,替換成派生類自己的。

靜態綁定:編譯時期的多態,通過函數的重載以及模板來實現,也就是說調用函數的地址在編譯時期我們就可以確定,在匯編代碼層次,呈現的就是 call 函數名;

動態綁定:運行時期的多態,通過派生類重寫基類的虛函數來實現。在匯編代碼層次,呈現的就是 call 寄存器,寄存器的值隻有運行起來我們才可以確定。

不存在虛函數

#include <iostream>
#include <typeinfo>
class Base
{
public:
  Base(int data = 10): ma(data) {}
  ~Base() {};
 
  void show() {
    std::cout << "Base::show()" << std::endl;
  }
 
  void show(int data) {
    std::cout << "Base::show()" << data << std::endl;
  }
 
protected:
  int ma;
 
};
 
class Derive :public Base 
{
public:
  Derive(int data) :Base(data), mb(data) {}
  ~Derive() {}
  void show() {
    std::cout << "Derive::show()" << std::endl;
  }
 
private:
  int mb;
};
 
int main() {
 
  Derive d(50);
  Base *pb = &d;
  pb->show();//靜態(編譯時期)綁定(函數調用) Base::show (06F12E4h)   
  pb->show(10);//Base::show (06F12BCh)
 
  std::cout << "Base size:" << sizeof(Base) << std::endl;//4
  std::cout << "Derive size:" << sizeof(Derive) << std::endl;//8
 
  std::cout << typeid(pb).name() << std::endl;//class Base *
  std::cout << typeid(*pb).name() << std::endl;//class Base 
 
  return 0;
 
 }

打斷點,F5進入調試,點擊反匯編

可以看到調用的都是基類的show(),在編譯階段已經生成指令調用Base下的show;

可以看到結果:
因為pb是Base類型的指針,所以調用的都是Base類的成員方法;
基類Base隻有一個數據成員ma,所以大小隻有4字節;
派生類Derive繼承瞭ma,其次還有自己的mb,所以有8字節;
pb的類型是一個class Base *;
*pb的類型是一個class Base。
為瞭更好地理解上述過程,我們簡單畫圖如下:

在這裡插入圖片描述

為什麼Base *類型的指針,Derive類型的對象,調用方法的時候是Base而不是Derive呢?
原因如上圖:
Derive類繼承瞭Base類,導致瞭派生類的大小要比基類大,而pb的類型是基類的指針,所以通過pb調用方法時隻能訪問到Derive中從Base繼承而來的方法,訪問不到自己重寫的方法(指針的類型限制瞭指針解引用的能力)

基類定義虛函數

#include <iostream>
#include <typeinfo>
class Base
{
public:
  Base(int data = 10): ma(data) {}
  ~Base() {};
 
  //虛函數
  virtual void show() {
    std::cout << "Base::show()" << std::endl;
  }
 
  void show(int data) {
    std::cout << "Base::show()" << data << std::endl;
  }
 
protected:
  int ma;
 
};
 
class Derive :public Base 
{
public:
  Derive(int data) :Base(data), mb(data) {}
  ~Derive() {}
  void show() {
    std::cout << "Derive::show()" << std::endl;
  }
 
private:
  int mb;
};
 
int main() {
 
  Derive d(50);
  Base *pb = &d;
 
  /*
  pb->show();
  pb 指針是base類型,如果發現Base中的show是虛函數,就進行動態綁定
mov         ecx,dword ptr [pb]  
00292B01 8B 45 D4             mov         eax,dword ptr [pb]   //將pb指向的內存前4個字節放入ecx寄存器,pb指向derive對象,前四個字節即vfptr,將虛函數表地址加載到eax
00292B04 8B 10                mov         edx,dword ptr [eax]  //將eax 的前四個字節 即Derive::show 加載到edx中
00292B06 8B F4                mov         esi,esp
00292B08 8B 4D D4             mov         ecx,dword ptr [pb]
00292B0B 8B 02                mov         eax,dword ptr [edx]
00292B0D FF D0                call        eax   //虛函數的地址
00292B0F 3B F4                cmp         esi,esp
00292B11 E8 9C E7 FF FF       call        __RTC_CheckEsp (02912B2h)
我們可以看到這一次,匯編碼call的就不是確切的函數地址瞭,而是寄存器eax;
那麼就很好理解瞭:
eax寄存器裡存放的是什麼內容,編譯階段根本無從知曉,隻能在運行的時候確定;
故,動態綁定。
  pb->show(10);  如果發現show是普通函數,就進行靜態綁定 call Base::show
  
  */
  pb->show();//
  pb->show(10);//
 
  std::cout << "Base size:" << sizeof(Base) << std::endl;//8
  std::cout << "Derive size:" << sizeof(Derive) << std::endl;//12
 
  std::cout << typeid(pb).name() << std::endl;//class Base *
  /*
  pb的類型:Base類型,查看Base中有沒有虛函數
  (1)Base中沒有虛函數*pb識別的就是編譯時期的類型 *pb 就是Base類型
  (2) Base中有虛函數,*pb識別的就是運行時期的類型 RTTI類型:Derive
  */
  std::cout << typeid(*pb).name() << std::endl;//class Derive 
 
  return 0;
 
 }

在我們添加瞭virtual關鍵字後,對應的函數就變成瞭虛函數;
那麼,一個類添加瞭虛函數,對這個類有什麼影響呢?

  • 首先,如果類裡面定義瞭虛函數,那麼編譯階段,編譯器給這個類類型產生一個唯一的vftable虛函數表,虛函數表中主要存儲的內容是:RTTI(Run-time Type Information)指針和虛函數的地址,當程序運行時,每一張虛函數表都會加載到內存的.rodata區;
  • 一個類裡面定義瞭虛函數,那麼這個類定義的對象,在運行時,內存中會多存儲一個vfptr虛函數指針,指向瞭對應類型的虛函數表vftable;
  • 一個類型定義的n個對象,他們的vfptr指向的都是同一張虛函數表;
  • 一個類裡面虛函數的個數,不影響對象內存的大小(vfptr),影響的是虛函數表的大小。
  • 如果派生類中的方法和從基類繼承來的某個方法中返回值、函數名以及參數列表都相同,且基類的方法是virtual,那麼派生類的這個方法,自動處理成虛函數

圖示如下:(以Base為例)

在這裡插入圖片描述

虛函數表
1、RTTI,存放的是類型信息,也就是(Base或者Derive)
2、偏移地址:虛函數指針相對於對象內存空間的偏移,一般vfptr都在0偏移位置
3、下面的函數時虛函數入口地址

在Derive類中,由於重寫瞭show(),因此在Derive的虛函數表中,是使用子類的show()方法代替瞭Base類的show()

VS的工具來查看虛函數表的有關信息

1 找到

2 在打開的窗口中切換到當前工程所在目錄:

C:\Program Files (x86)\Microsoft Visual Studio\2017\Community>cd C:\Users\Admin\source\repos\C++test\

3 輸入命令:cl XXX.cpp /d1reportSingleClassLayoutXX(第一個XXX表示源文件的名字,第二個代表你想查看的類類型,我這裡就是Derive)

以看到class Derived的對象的內存佈局,在派生類對象的開始包含瞭基類Base的對象,其中有一個虛表指針,指向的就是下面的Derived::$vftable@ (virtual function table),表中包含瞭Derived類中所有的虛函數

多重繼承、多繼承 的虛函數表 1 內存分佈

假設有一個基類ClassA,一個繼承瞭該基類的派生類ClassB,並且基類中有虛函數,派生類實現瞭基類的虛函數。
我們在代碼中運用多態這個特性時,通常以兩種方式起手:
(1) ClassA *a = new ClassB();
(2) ClassB b; ClassA *a = &b;
以上兩種方式都是用基類指針去指向一個派生類實例,區別在於第1個用瞭new關鍵字而分配在堆上,第2個分配在棧上

這裡寫圖片描述

請看上圖,不同兩種方式起手僅僅影響瞭派生類對象實例存在的位置。
以左圖為例,ClassA *a是一個棧上的指針。
該指針指向一個在堆上實例化的子類對象。基類如果存在虛函數,那麼在子類對象中,除瞭成員函數與成員變量外,編譯器會自動生成一個指向**該類的虛函數表(這裡是類ClassB)**的指針,叫作虛函數表指針。通過虛函數表指針,父類指針即可調用該虛函數表中所有的虛函數。

2 類的虛函數表與類實例的虛函數指針

首先不考慮繼承的情況。如果一個類中有虛函數,那麼該類就有一個虛函數表。
這個虛函數表是屬於類的,所有該類的實例化對象中都會有一個虛函數表指針去指向該類的虛函數表。
從第一部分的圖中我們也能看到,一個類的實例要麼在堆上,要麼在棧上。也就是說一個類可以有很多很多個實例。但是!一個類隻能有一個虛函數表。在編譯時,一個類的虛函數表就確定瞭,這也是為什麼它放在瞭隻讀數據段中。

這裡寫圖片描述

3 多態代碼及多重繼承情況

在第二部分中,我們討論瞭在沒有繼承的情況下,虛函數表的邏輯結構。
那麼在有繼承情況下,隻要基類有虛函數,子類不論實現或沒實現,都有虛函數表。

#include <iostream>
 
using namespace std;
 
class ClassA
{
public:
  ClassA() { cout << "ClassA::ClassA()" << endl; }
  virtual ~ClassA() { cout << "ClassA::~ClassA()" << endl; }
 
  void func1() { cout << "ClassA::func1()" << endl; }
  void func2() { cout << "ClassA::func2()" << endl; }
 
  virtual void vfunc1() { cout << "ClassA::vfunc1()" << endl; }
  virtual void vfunc2() { cout << "ClassA::vfunc2()" << endl; }
private:
  int aData;
};
 
class ClassB : public ClassA
{
public:
  ClassB() { cout << "ClassB::ClassB()" << endl; }
  virtual ~ClassB() { cout << "ClassB::~ClassB()" << endl; }
 
  void func1() { cout << "ClassB::func1()" << endl; }
  virtual void vfunc1() { cout << "ClassB::vfunc1()" << endl; }
private:
  int bData;
};
 
class ClassC : public ClassB
{
public:
  ClassC() { cout << "ClassC::ClassC()" << endl; }
  virtual ~ClassC() { cout << "ClassC::~ClassC()" << endl; }
 
  void func2() { cout << "ClassC::func2()" << endl; }
  virtual void vfunc2() { cout << "ClassC::vfunc2()" << endl; }
private:
  int cData;
};
 
 
int main()
{
  ClassC c;
 
  return 0;
}

請看上面代碼
(1) ClassA是基類, 有普通函數: func1() func2() 。虛函數: vfunc1() vfunc2() ~ClassA()
(2) ClassB繼承ClassA, 有普通函數: func1()。虛函數: vfunc1() ~ClassB()
(3) ClassC繼承ClassB, 有普通函數: func2()。虛函數: vfunc2() ~ClassB()
基類的虛函數表和子類的虛函數表不是同一個表。下圖是基類實例與多態情形下,數據邏輯結構。註意,虛函數表是在編譯時確定的,屬於類而不屬於某個具體的實例。虛函數在代碼段,僅有一份
ClassB繼承與ClassA,其虛函數表是在ClassA虛函數表的基礎上有所改動的,變化的僅僅是在子類中重寫的虛函數。如果子類沒有重寫任何父類虛函數,那麼子類的虛函數表和父類的虛函數表在內容上是一致的

ClassA *a = new ClassB();
a->func1();                    // "ClassA::func1()"   隱藏瞭ClassB的func1()
a->func2();                    // "ClassA::func2()"
a->vfunc1();                   // "ClassB::vfunc1()"  重寫瞭ClassA的vfunc1()
a->vfunc2();                   // "ClassA::vfunc2()"

這個結果不難想象,看上圖,ClassA類型的指針a能操作的范圍隻能是黑框中的范圍,之所以實現瞭多態完全是因為子類的虛函數表指針與虛函數表的內容與基類不同
這個結果已經說明瞭C++的隱藏、重寫(覆蓋)特性。

同理,也就不難推導出ClassC的邏輯結構圖瞭
類的繼承情況是: ClassC繼承ClassB,ClassB繼承ClassA
這是一個多次單繼承的情況。(多重繼承)

這裡寫圖片描述

4、多繼承下的虛函數表 (同時繼承多個基類)

多繼承是指一個類同時繼承瞭多個基類,假設這些基類都有虛函數,也就是說每個基類都有虛函數表,那麼該子類的邏輯結果和虛函數表是什麼樣子呢?

#include <iostream>
 
using namespace std;
 
class ClassA1
{
public:
  ClassA1() { cout << "ClassA1::ClassA1()" << endl; }
  virtual ~ClassA1() { cout << "ClassA1::~ClassA1()" << endl; }
 
  void func1() { cout << "ClassA1::func1()" << endl; }
 
  virtual void vfunc1() { cout << "ClassA1::vfunc1()" << endl; }
  virtual void vfunc2() { cout << "ClassA1::vfunc2()" << endl; }
private:
  int a1Data;
};
 
class ClassA2
{
public:
  ClassA2() { cout << "ClassA2::ClassA2()" << endl; }
  virtual ~ClassA2() { cout << "ClassA2::~ClassA2()" << endl; }
 
  void func1() { cout << "ClassA2::func1()" << endl; }
 
  virtual void vfunc1() { cout << "ClassA2::vfunc1()" << endl; }
  virtual void vfunc2() { cout << "ClassA2::vfunc2()" << endl; }
  virtual void vfunc4() { cout << "ClassA2::vfunc4()" << endl; }
private:
  int a2Data;
};
 
class ClassC : public ClassA1, public ClassA2
{
public:
  ClassC() { cout << "ClassC::ClassC()" << endl; }
  virtual ~ClassC() { cout << "ClassC::~ClassC()" << endl; }
 
  void func1() { cout << "ClassC::func1()" << endl; }
 
  virtual void vfunc1() { cout << "ClassC::vfunc1()" << endl; }
  virtual void vfunc2() { cout << "ClassC::vfunc2()" << endl; }
  virtual void vfunc3() { cout << "ClassC::vfunc3()" << endl; }
};
 
 
int main()
{
  ClassC c;
 
  return 0;
}

ClassA1是第一個基類,擁有普通函數func1(),虛函數vfunc1() vfunc2()。
ClassA2是第二個基類,擁有普通函數func1(),虛函數vfunc1() vfunc2(),vfunc4()。
ClassC依次繼承ClassA1、ClassA2。普通函數func1(),虛函數vfunc1() vfunc2() vfunc3()。

在多繼承情況下,有多少個基類就有多少個虛函數表指針,前提是基類要有虛函數才算上這個基類。
如圖,虛函數表指針01指向的虛函數表是以ClassA1的虛函數表為基礎的,子類的ClassC::vfunc1(),和vfunc2()的函數指針覆蓋瞭虛函數表01中的虛函數指針01的位置、02位置。當子類有多出來的虛函數時,添加在第一個虛函數表中。註意:
1.子類虛函數會覆蓋每一個父類的每一個同名虛函數。
2.父類中沒有的虛函數而子類有,填入第一個虛函數表中,且用父類指針是不能調用。
3.父類中有的虛函數而子類沒有,則不覆蓋。僅子類和該父類指針能調用

虛基類和多重繼承

什麼是多重繼承

多重繼承,很好理解,一個派生類如果隻繼承一個基類,稱作單繼承;
一個派生類如果繼承瞭多個基類,稱作多繼承。
如圖所示:

在這裡插入圖片描述

多重繼承的優點
這個很好理解:
多重繼承可以做更多的代碼復用!
派生類通過多重繼承,可以得到多個基類的數據和方法,更大程度的實現瞭代碼復用。

關於菱形繼承的問題
凡事有利也有弊,對於多繼承而言,也有自己的缺點。
我們先通過瞭解菱形繼承來探究多重繼承的缺點:
菱形繼承是多繼承的一種情況,繼承方式如圖所示:

在這裡插入圖片描述

從圖中我們可以看到:
類B類C類A單繼承而來;
類D類B類C多繼承而來。
那麼這樣繼承會產生什麼問題呢?
我們來看代碼:

#include <iostream>
 
using namespace std;
class A
{
public:
  A(int data) :ma(data) { cout << "A()" << endl; }
  ~A() { cout << "~A()" << endl; }
protected:
  int ma;
};
class B :public A
{
public:
  B(int data) :A(data), mb(data) { cout << "B()" << endl; }
  ~B() { cout << "~B()" << endl; }
protected:
  int mb;
};
class C :public A
{
public:
  C(int data) :A(data), mc(data) { cout << "C()" << endl; }
  ~C() { cout << "~C()" << endl; }
protected:
  int mc;
};
class D :public B, public C
{
public:
  D(int data) : B(data), C(data), md(data) { cout << "D()" << endl; }
  ~D() { cout << "~D()" << endl; }
protected:
  int md;
};
int main()
{
  D d(10);
 
  return 0;
}

通過運行結果,我們發現瞭問題:
對於基類A而言,構造瞭兩次,析構瞭兩次!
並且,通過分析各個派生類的內存佈局我們可以看到:

在這裡插入圖片描述

對於派生類D來說,間接繼承的基類A中的數據成員ma重復瞭!
這對資源來說是一種浪費與消耗。
(如果多繼承的數量增加,那麼派生類中重復的數據也會增加!)

查看D類的內存佈局:

其他多重繼承的情況

除瞭菱形繼承外,還有其他多重繼承的情況,也會出現相同的問題

在這裡插入圖片描述

比如說圖中呈現的:半圓形繼承。

如何解決多重繼承的問題

通過分析我們知道瞭,多重繼承的主要問題是,通過多重繼承,有可能得到重復的基類數據,並且可能重復的構造和析構同一個基類對象。
那麼如何能夠避免重復現象的產生呢?
答案就是:=》虛基類。

什麼是虛基類
要理解虛基類,我們首先需要認識virtual關鍵字的使用場景:

修飾成員方法時:產生虛函數;
修飾繼承方式時:產生虛基類。
對於被虛繼承的類,稱作虛基類。
比如說:

class A
{
	XXXXXX;
};
class B : virtual public A
{
	XXXXXX;
};

對於這個示例而言,B虛繼承瞭A,所以把A稱作虛基類。

虛基類如何解決問題

那麼虛基類如何解決上述多重繼承產生的重復問題呢?
我們來看代碼:

#include <iostream>
 
using namespace std;
class A
{
public:
  A(int data) :ma(data) { cout << "A()" << endl; }
  ~A() { cout << "~A()" << endl; }
protected:
  int ma;
};
class B :virtual public A
{
public:
  B(int data) :A(data), mb(data) { cout << "B()" << endl; }
  ~B() { cout << "~B()" << endl; }
protected:
  int mb;
};
class C :virtual public A
{
public:
  C(int data) :A(data), mc(data) { cout << "C()" << endl; }
  ~C() { cout << "~C()" << endl; }
protected:
  int mc;
};
class D :public B, public C
{
public:
  D(int data) : B(data), C(data), md(data) { cout << "D()" << endl; }
  ~D() { cout << "~D()" << endl; }
protected:
  int md;
};
 

提示說:"A::A" : 沒有合適的默認構造函數可用
為什麼會這樣呢?
我們可以這麼理解:

剛開始BC單繼承A的時候,實例化對象時,會首先調用基類的構造函數,也就是A的構造函數,到瞭D,由於多繼承瞭BC,所以在實例化D的對象時,會首先調用BC的構造函數,然後調用自己(D)的。

但是這樣會出現A重復構造的問題,所以,采用虛繼承,把有關重復的基類A改為虛基類,這樣的話,對於A構造的任務就落到瞭最終派生類D的頭上,但是我們的代碼中,對於D的構造函數:D(int data) : B(data), C(data), md(data) { cout << “D()” << endl; }並沒有對A進行構造。
所以會報錯。
那麼我們就給D的構造函數,調用A的構造函數:
D(int data) :A(data), B(data), C(data), md(data) { cout << "D()" << endl; }
這一次再運行

我們會發現,問題解決瞭。

查看虛基類的內存佈局

我們可以看到當前B的內存空間:

當前B的內存空間裡,前四個字節是vbptr(這個就代表裡虛基類指針:virtual base ptr);
vfptr(虛函數指針)指向瞭vftable(虛函數表)一樣,
vbptr(虛基類指針)指向瞭vbtable(虛基類表)。

vbtable(虛基類表)的佈局也如圖所示,
首先是偏移量0:表示瞭虛基類指針再內存佈局中的偏移量;
接著是偏移量8:表示從虛基類中繼承而來的數據成員在內存中的偏移量。

對比普通繼承下的內存佈局

我們可以對比沒有虛繼承下的B的內存佈局來理解:

我們把他們放在一起對比可以看到:

繼承虛基類的類(BC)會把自己從虛基類繼承而來的數據ma放在自己內存的最末尾(偏移量最大),並在原來ma的位置填充一個vbptr(虛基類指針),這個指針指向瞭vbtable(虛基類表)。
理解瞭B,我們可以看看更為復雜的D

可以看到,將ma移動到瞭末尾處,並在含有ma的地方,都用vbptr進行填充。
這樣一來,就隻有一個ma瞭!解決瞭多重繼承的重復問題。

到此這篇關於關於C++虛函數與靜態、動態綁定的問題的文章就介紹到這瞭,更多相關C++虛函數與靜態、動態綁定內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: