C++ 多態實現機制


本篇從 C++ 初學者遇到的一個有趣的問題開始。

有趣的問題

考慮下面的 C++ 程序:

class A
{
     void func(){}
};

class B:public A
{
      void func(){}
};

int main(void)
{
     cout << sizeof(A) << " " << sizeof(B) << endl;
     return 0;
}

輸出結果是:1 1

再考慮下面很相似的程序:

class A
{
     virtual void funcA(){}
};

class B:public A
{
     virtual void funcB(){}
};

int main(void)
{
     cout << sizeof(A) << " " << sizeof(B) << endl;
     return 0;
}

輸出結果是:4 4

再來考慮下面的形似的程序:

class A
{
     virtual void funcA(){}
};

class B:virtual public A
{
     virtual void funcB(){}
};

int main(void)
{
     cout << sizeof(A) << " " << sizeof(B) << endl;
     return 0;
}

輸出結果是:4 12

對於第一種情況,沒有出現虛函數,也無任何成員變量,因此是一個空類,空類理論上可以進行實例化,每個實例在內存中都有獨一無二的地址來標明,所以會占用 1B 的空間,無可厚非。

但第二種情況和第三種情況加入了虛函數(virtual function),而且在第三種情況當中,引入了虛基類(virtual base class)的概念,所得到的結果大相徑庭,這是 C++ 引入了 virtual function 和 virtual base class,即多態,更形象的解釋是「以一個 public base class 的指針或者引用,尋址出一個 derived class object」,但多態帶了一定空間上的開銷,在效率上也有折損。

其實, 多態機制可以歸結為下面三這句話:

  • 一般而言, 我們無法知道指針 ptr 所指的對象的真正類型. 但經由 ptr 總是可以存取到對象的 virtual table.
  • 虛函數 fn() 總是放在 virtual table 中的固定位置, 用一個固定的索引值就可以 fetch 到.
  • 唯一一個執行期需要知道的是 ptr 所指的對象.

下面是 C++ 多態機制實現詳解.

從最簡單的對象模塊開始

最為簡單的對象模型:

Image

靜態/非靜態 成員函數 和 靜態/非靜態 成員變量 的地址都存儲在一個表當中,通過表內存儲的地址指向相應的部分。這樣的設計簡易,便於理解,類的實例只需要維護這張表就好了,賠上的是空間和執行效率:

空間上:沒必要為每一個實例都存儲靜態成員變量和成員函數

效率上:每次執行實例的一個成員函數都要在表內進行搜索

這是最初的假設,實際的實現肯定沒有那么簡單,下面是將變量和函數分割存儲的模型(表格驅動對象模型):

Image(1)

簡易對象模型經改良后可以的得到這種。sizeof(A) 的結果是 8。

為支撐 virtual function ,引入了現在的 C++ 對象模型:

Image(2)

非靜態成員變量同指向虛擬函數表的指針(vptr),靜態成員變量/函數,非靜態成員函數分離存儲。類的每一個實例都存有 vptr 和 非靜態成員變量,他們獨立擁有這些數據,並不和其他的實例共享。這時候,回到第二種情況,class A 和 繼承自 A 的 class B 都擁有虛函數,因此都會有一個 vptr,因此 sizeof 運算得到的結果都為 4.然而,如果往里面添加一個非靜態 int 型變量,那么相應可以得到 8B 的大小;但往里面添加靜態 int 型變量,大小卻沒有改變。

單一繼承

下面是單一繼承里經常看到的一個程序:

class A
{
public:
	int a;
	void foo(){}
	virtual void funcA(){}
	virtual void func()
	{cout << "class A's func." << endl;}
};

classB : public A
{
public:
	int b;
	void foo(){}
	virtual void funcB(){}
	virtual void func()
	{cout << "class B's func." << endl;}
};

int main(void)
{
	A *pa = newB;
	pa->func();
}

輸出結果是:class B'sfunc.

多態就是多種狀態,一個事物可能有多種表現形式,譬如動物,有十二生肖甚至更多的表現形式。當基類里實現了某個虛函數,但派生類沒有實現,那么類 B 的實例里的虛函數表中放置的就是 &A::func。此外,派生類也實現了虛函數,那么類 B 實例里的虛函數表中放置的就是 B::func。A *pa = new B; 因為 B 實現了 func,那么它被放入 A 實例的虛擬函數表中,從而代替 A 實例本身的虛擬函數。pa->func(); 調用的結果就不稀奇了,這是虛函數機制帶來的。

class A 和 class B 的內存布局和 vptr 可能是下面的樣子:

  1. ----------
  2. |   int a |
  3. ----------
  4. |    vptr | -------->|      &A::funcA()
  5. ----------             -------------------------------------------------
  6.                           |      &A::func()
  7.                          -------------------------------------------------
  1. ----------
  2. |   int a |
  3. ----------
  4. |    vptr | -------->|     &A::funcA() 依舊是 A 的虛函數
  5. ----------             -------------------------------------------------
  6. |   int b |              |     &B::func() A::func()
  7. ----------             -------------------------------------------------
  8.                           |     &B::funcB()
  9.                           -------------------------------------------------

倘若 虛函數 以外的就沒有「多態」效果了,除非進行強制類型轉換:

  • pa->a;          //     成功,因為 pa 的類型就是 A
  • pa->b;          //     失敗,因為 B::b
  • pa->funcB();  //     失敗,因為B::funcB() 不是虛函數
  • pa->funcA();  //     成功,因為A::funcA()

總結一下:

  • 當引入虛函數的時候,會添加 vptr 和 其指向的一個虛擬函數表從而增加額外的空間,這些信息在編譯期間就已經確定,而且在執行期不會插足修改任何內容。
  • 在類的構造和析構函數當中添加對應的代碼,從而能夠為 vptr 設定初值或者調整 vptr,這些動作由編譯器完成,class 會產生膨脹。
  • 當出現繼承關系時,虛擬函數表可能需要改寫,即當用基類的指針指向一個派生類的實體地址,然后通過這個指針來調用虛函數。這里要分兩種情況,當派生類已經改寫同名虛函數時,那么此時調用的結果是派生類的實現;而如果派生類沒有實現,那么調用依然是基類的虛函數實現,而且僅僅在多態僅僅在虛函數上表現。
  • 多態僅僅在虛函數上表現,意即倘若同樣用基類的指針指向一個派生類的實體地址,那么這個指針將不能訪問和調用派生類的成員變量和成員函數。
  • 所謂執行期確定的東西,就是基類指針所指向的實體地址是什么類型了,這是唯一執行期確定的。以上是單一繼承的情況,在多重繼承的情況會更為復雜。

多重繼承

下面是少有看到的程序代碼:

class A
{
public:
	virtual ~A(){cout << "A destruction" << endl;}
	int a;
	void fooA(){}
	virtual void func(){cout << "A func." << endl;};
	virtual void funcA(){cout << "funcA." << endl;}
};

class B
{
public:
	virtual ~B(){cout << "B destruction" << endl;}
	int b;
	void fooB(){}
	virtual void func(){cout << "B func." << endl;};
	virtual void funcB(){cout << "funcB." << endl;}
};

class C : public A,public B
{
public:
	virtual ~C(){cout << "C destruction" << endl;}
	int c;
	void fooC(){}
	virtual void func(){cout << "C func." << endl;};
	virtual void funcC(){cout << "funcC." << endl;}
};

int main(void) 
{  
	return 0;
}

當用基類的指針指向一個派生類的實體地址,基類有兩種情況,一種是 class A 和 class B,如果是 A,問題容易解決,幾乎和上面單一繼承情況類似;但倘若是 B,要做地址上的轉換,情況會比前者復雜。先展現class A,B,C 的內存布局和 vptr:

  1. ----------
  2. |   int a |
  3. ----------
  4. |    vptr | -------->|      &A::~A()
  5. ----------             -------------------------------------------------
  6.                             |      &A::func()
  7.                             -------------------------------------------------
  8.                             |      &A::funcA()
  9.                             -------------------------------------------------
  1. ----------
  2. |   int b |
  3. ----------
  4. |    vptr | -------->|     &B::~B()
  5. ----------             -------------------------------------------------
  6.                             |     &B::func()
  7.                             -------------------------------------------------
  8.                             |     &B::funcB()
  9.                             --------------------------------------------------

 

  1.                             |      &C::~C() &A::~A()
  2. ----------             -------------------------------------------------
  3. |   int a |               |      &C::func() &A::func()
  4. ----------             -------------------------------------------------
  5. ----------             |      &C::funcC()
  6. |    vptr | -------->-------------------------------------------------
  7. ----------             |      &A::funcA()
  8. ----------             -------------------------------------------------
  9. |   int b |               |      &B::funcB() 跳
  10. ----------             -------------------------------------------------
  11. ----------
  12. |    vptr | -------->|     &C::~C() &B::~B() 跳
  13. ----------             -------------------------------------------------
  14. |   int c |               |     &C::func() &B::func() 跳
  15. ----------             -------------------------------------------------
  16.                            |     &B::funcB()
  17.                             --------------------------------------------------

多重繼承中,會有保留兩個虛擬函數表,一個是與 A 共享的,一個是與 B 相關的,他們都在原有的基礎上進行了修改:

對於 A 的虛擬函數表:

  • 覆蓋派生類實現的同名虛函數,並用派生類實現的析構函數覆蓋原有虛函數
  • 添加了派生類獨有的虛函數
  • 添加了右端父類即 B 的獨有虛函數,需跳轉

對於 B 的虛擬函數表:

  • 覆蓋派生類實現的同名虛函數,並用派生類實現的析構函數覆蓋原有虛函數,但需跳轉
  1. int main(void)
  2. {
  3.      A *pa = new C;
  4.      B *pb = new C;
  5.      C *pc = new C;
  6.      pa->func();
  7.      pb->func();
  8.      pc->funcC();
  9.      delete pb;
  10.      delete pa;
  11.      delete pc;
  12. }

輸出結果是:

C func.
C func.
funcC.
C destruction
B destruction
A destruction
C destruction
B destruction
A destruction
C destruction
B destruction
A destruction

7 行和 8 行的行為有很大的區別,7 行的調用和上面的單一繼承的情況類似,不贅述。8 行的 pb->func(); 中,pb 所指向的是上圖第 9 行的位置,編譯器已在內部做了轉換,也就是 pa 和 pb 所指的位置不一樣,pa 指向的是上圖第 3 行的位置。接着需要注意的是,pb->func(); 調用時,在虛擬函數表中找到的地址需要再進行一次跳轉,目標是 A 的虛擬函數表中的 &C::func(),然后才真正執行此函數。所以,上面的情況作了指針的調整。

那什么時候會出現跳,常見的有兩種情況:

  1. 右端基類,對應上面的具體是 B,調用派生類虛擬函數,比如 pb->~C() 和 pb->func()
  2. 派生類調用右端基類的虛擬函數,比如 pc->funcB()

所以 delete pa; 和 delete pa; 的操作是不一樣的,pb->funcB(); 和 pc->funcB(); 也不一樣。

C++ 為實現多態引入虛函數機制,帶來了空間和執行上的折損。

單一/多重繼承的構造和析構

單一繼承中,構造函數調用順序是從上到下(單一繼承),從左到右(多重繼承),析構函數調用順序反過來。在上一段程序中,

  1.      delete pa;
  2.      delete pb;
  3.      delete pc;

都自動調用了基類和派生類的析構函數,其中只有 delete pc; 涉及了虛擬函數機制。《Effective C++》中07條款中有這樣一句話:當derived class 對象經由一個 base 指針被刪除,而該對象帶有一個 non-virtual 析構函數,其結果未有定義---實際執行時通常發生的是對象的 derived 成分未被銷毀。

特地,寫了下面的程序:

class A
{
public:
	~A(){cout << "A destruction" << endl;}
	int a;
};

class B
{
public:
	~B(){cout << "B destruction" << endl;}
};

class C : public A,public B
{
public:
	~C(){cout << "C destruction" << endl;}
};

int main(void)
{
	A *pa = new C;
	B *pb = new C;
	C *pc = new C;
	delete pa;     // 沒有問題
	delete pb;     // 出錯
	delete pc;     // 沒有問題
}

所說的「未定義」就在 delete pa; 和 delete pb; 體現出來。

強烈建議,在設計繼承關系的時候,為每一個基類實現 virtual 析構函數。

回到開始的問題:

  1. 第一種情況是因為編譯器安插了一個字節,為的是一個類的對象能再內存有獨一無二的地址,無可厚非。
  2. 第二種情況是因為編譯器安插了 vptr。
  3. 第三種情況是因為編譯器除了安插 A 和 B 的 vptr 外,還有一個指向虛基類的指針。

另外,虛擬繼承在應用比較少應用,一個例子就是:

class ios {...};

class istream : public virtual ios {...};

calss ostream : public virtual ios {...};

class iostream : public istream,public ostream {...};

這里 istream,ostream,iostream 共享同一份 ios。要和下面的情況區分開來:

class ios {...};

class istream : public ios {...};

calss ostream : public ios {...};

class iostream : public istream,public ostream {...};

這里實際有兩份 ios !全文完。daoluan.net


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM