C++ 多態實現機制

來源:互聯網
上載者:User

本篇從 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」,但多態帶了一定空間上的開銷,在效率上也有折損。

最為簡單的物件模型:

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

空間上:沒必要為每一個執行個體都儲存靜態成員變數和成員函數

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

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

簡易物件模型經改良後可以的得到這種。sizeof(A) 的結果是 8。

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

非靜態成員變數同指向虛擬函數表的指標(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

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.