程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> C++入門知識 >> C++多態篇1一靜態聯編,動態聯編、虛函數與虛函數表vtable

C++多態篇1一靜態聯編,動態聯編、虛函數與虛函數表vtable

編輯:C++入門知識

C++多態篇1一靜態聯編,動態聯編、虛函數與虛函數表vtable


首先,說起多態就必須要講靜態聯編,動態聯編。這倆也叫靜態綁定和動態綁定。有些書比如C++ Primer也叫靜態類型和動態類型。譚浩強寫的C++程序設計直接叫靜態多態性和動態多態性。

為什麼說起多態就要先說他倆呢,首先,多態是什麼?

多態(Polymorphism)按字面的意思就是“多種狀態”。在面向對象語言中,接口的多種不同的實現方式即為多態。引用Charlie Calverts對多態的描述——多態性是允許你將父對象設置成為一個或更多的他的子對象相等的技術,賦值之後,父對象就可以根據當前賦值給它的子對象的特性以不同的方式運作(摘自“Delphi4 編程技術內幕”)。簡單的說,就是一句話:允許將子類類型的指針賦值給父類類型的指針。多態性在Object Pascal和C++中都是通過虛函數(Virtual Function) 實現的。

上面的一段話講得十分官方(哈哈),讓我總結來說,多態就是一個事物有多種形態。
一、靜態聯編,動態聯編,靜態類型,動態類型
1.靜態多態,動態多態
靜態多態和動態多態的區別其實用下面的圖就可以體現:
這裡寫圖片描述
2.靜態聯編,動態聯編
那麼靜態聯編和動態聯編分別是什麼呢
首先我們先搞清楚聯編是什麼:
聯編的作用是:程序調用函數,編譯器決定使用哪個可執行代碼塊。
也就是確定調用的具體對象。

class A
{
public:
    void Fun();
};
class B:public A
{
public:
    void Fun();
};
int main()
{
    B b;
    b.Fun();
    //上一行究竟調用A類的Fun()函數還是B類的Fun函數
    //確定具體對象的過程叫做聯編
    return 0;
    //這個例子只是讓大家了解一下什麼是聯編
    //關於這個例子中涉及的知識點,在後面會提及
}

正如上面所說的,聯編的分類是根據進行階段不同分類的。
靜態聯編其實就是類似上面我們提到的,函數重載和運算符重載,它是在編譯過程匯總進行的聯編,又稱早期聯編。
而動態聯編是在程序運行過程中才動態的確定操作對象。
3.靜態類型,動態類型
在C++Primer一書中,講到了靜態類型和動態類型。
靜態類型和動態類型可用於變量或表達式。
表達式的靜態類型在編譯時總是已知的,它是在變量聲明時的類型或表達式生成的類型。
動態類型則是變量或表達式表示的內存中的對象的類型,直到運行時才可知。
其實靜態類型和動態類型與靜態聯編,動態聯編是與指針和引用有著很大關系的。
原因如下:
實際上一個非指針非引用的變量,在聲明時已經確定了它自己的類型,不會再後面改變。而指針或引用可以進行類型轉換的原因,就是下面要好好分析的。
下面我要問一些問題。
1.什麼有兩種類型的聯編?
2.既然動態聯編如此好,為什麼不將他設置成默認的?
3.動態聯編是如何工作的?
現在我對上面的問題解答一下。
為什麼有兩種類型的聯編以及為什麼默認為靜態聯編?
原因有兩個——效率和概念模型。
1.效率
為了使程序能夠在運行階段進行決策,必須采取一些方法來跟蹤基類指針或引用指向的對象類型,這增加了額外的處理開秀,所以,在派生類不需要重新定義基類方法的情況下,靜態聯編的效率更高。
2.內存和存取時間,這點在後面虛函數的介紹中會提及
二、指針和引用類型兼容性
在我以前的博文C++繼承詳解之二——派生類成員函數詳解(函數隱藏、構造函數與兼容覆蓋規則)中,我提到過賦值兼容覆蓋規則,其實就是這裡的指針和引用類型兼容性。
在C++中,動態聯編與指針和引用調用的方法相關。其實從某種程度上說,這是由繼承控制的。在公有繼承中,建立is-a關系的一種方法是如何處理指向對象的指針和引用。
一般情況下,C++是不允許將一種類型的地址賦給另一種類型的指針,也不允許一種類型的引用指向另一種類型。

float f;
int &a = f; //編譯器報錯
double *p = &f;//編譯器報錯

但是,在賦值兼容轉換規則中,指向基類的引用或指針可以引用派生類對象,而不必進行顯示類型轉換。


class Base
{
public:
    int b;
};
class Derive:public Base
{
public:
    int d;
};
int main()
{
    Base b;
    Derive d;
    Base *pb = &b;//基類指針指向基類對象
    pb = &d;//基類指針指向派生類對象
    Base &B = b;//基類引用指向基類對象
    Base &D = d;//基類引用指向派生類對象
    //編譯運行都不會出錯
    return 0;
}

1.向上強制轉換
將派生類引用或指針轉換為基類引用或指針被稱為向上強制轉換(upcasting),這使得公有繼承不需要進行顯示類型轉換。這也是is-a規則的一部分( C++繼承詳解之四——is-a接口繼承和has-a實現繼承)。
因為公有繼承中是接口繼承,即基類中的成員派生類中都有,所以發生向上強制轉換的時候,勢必擔心出現問題的。
將指向對象對象的指針作為函數參數時,也是如此。
class Base
{
public:
    int b;
};
class Derive:public Base
{
public:
    int d;
};
int main()
{
    Base b;
    Derive d;
    Base *pb = &d;//向上強制轉換
    pb->b = 1;  //可以賦值
    cout << "Base::b = " << pb->b << endl;
    return 0;
}

運行結果為:
這裡寫圖片描述
向上強制轉換是可以傳遞的。
即:我在派生類基礎上再派生一個類,這時依然可以向上強制轉換。<喎?http://www.Bkjia.com/kf/ware/vc/" target="_blank" class="keylink">vcD4NCjxwcmUgY2xhc3M9"brush:java;"> class Base { public: int b; }; class Derive:public Base { public: int d; }; class DerivePlus :public Derive { public: int dp; }; int main() { Base b; Derive d; DerivePlus dp; Base *pb = &dp;//向上強制轉換 pb->b = 1; //可以賦值 cout << "Base::b = " << pb->b << endl; return 0; }

運行結果為:
這裡寫圖片描述
2.向下強制轉換
與向上強制轉換相反,將基類指針或引用轉換為派生類指針或引用成為向下強制轉換。
如果不使用顯示類型轉換,向下強制轉換是不允許的,因為is-a關系是不可逆的。
比如香蕉是水果,但是水果不是香蕉。
派生類香蕉可以新增數據成員,因此這些數據成員不能應用於基類水果,比如香蕉中有黃色,但是不是所有水果都是黃色的。

class Fruit
{
public:
    int weight;
};
class Banana:public Fruit
{
public:
    int yellow;
};
int main()
{
    Fruit b;
    Banana d;
    Banana *pb = &b;//隱式向下強制轉換
    //報錯
    Banana *p = (Banana*)&b;//顯式類型轉換,不會報錯
    return 0;
}

編譯後編譯器報錯:

Error   1   error C2440: 'initializing' : cannot convert from 'Fruit *' to 'Banana *'   e:\demo\繼承\blog\project1\project1\source.cpp    93  1   Project1
    3   IntelliSense: a value of type "Fruit *" cannot be used to initialize an entity of type "Banana *"   e:\DEMO\繼承\blog\Project1\Project1\Source.cpp    93  15  Project1

現在已經了解了向上向下強制轉換是什麼了。
那麼隱式向上強制轉換在使用的過程中是不會報錯的,所以它是基類指針或引用可以指向基類對象以及派生類對象。因此需要動態聯編。
C++使用虛成員函數來滿足這種需求。
那麼,什麼是虛函數呢?
三、虛函數
1.什麼是虛函數
虛函數就是在某基類中聲明為 virtual 並在一個或多個派生類中被重新定義的成員函數。
用法格式為:virtual 函數返回類型 函數名(參數表) {函數體};
實現多態性,通過指向派生類的基類指針或引用,訪問派生類中同名覆蓋成員函數。
如果沒有使用關鍵字virtual,程序將根據引用類型或指針類型選擇方法。
請看下面的例子:

class Base
{
public:
    int b;
    void Fun()
    {
        cout << "Base::Fun()" << endl;
    }
};
class Derive:public Base
{
public:
    int d;
    void Fun()
    {
        cout << "Derive::Fun()" << endl;
    }
};
int main()
{
    Base b;
    Derive d;
    Base *pb = &d;//向上強制轉換
    pb->Fun();
    return 0;
}

上面的代碼會輸出什麼呢?
這裡寫圖片描述
上面的程序調用的Fun()函數是基類的成員函數,也就是上面我所說的,程序是根據指針類型選擇的方法,在上面的代碼中,指針變量pb的類型是Base類,所以程序調用的是基類的成員函數Fun()。
那麼我們加上virtual關鍵字,將Fun()函數變為虛函數又會輸出什麼呢?

class Base
{
public:
    int b;
    virtual void Fun()
    {
        cout << "Base::Fun()" << endl;
    }
};
class Derive:public Base
{
public:
    int d;
    void Fun()
    {
        cout << "Derive::Fun()" << endl;
    }
};
int main()
{
    Base b;
    Derive d;
    Base *pb = &d;//向上強制轉換
    pb->Fun();
    return 0;
}

上面的代碼運行結果為:
這裡寫圖片描述
那麼可以得到,如果使用了virtual,程序將根據引用或指針指向的對象類型來選擇方法。
在上面的例子中,雖然指針變量pb的類型為Base類,但是通過

Base *pb = d;

這句話,我們進行了向上強制轉換,也就是說,Base類型的指針變量pb,指向了Derive類的對象,那麼,在程序運行的時候,因為加了virtual關鍵字,Fun()函數變為了虛函數,所以程序將根據指針所指向的對象的類型,在本例中也就是Derive類型來選擇函數。
如果將指針變為引用,結果類似。

class Base
{
public:
    int b;
    virtual void Fun()
    {
        cout << "Base::Fun()" << endl;
    }
};
class Derive:public Base
{
public:
    int d;
    void Fun()
    {
        cout << "Derive::Fun()" << endl;
    }
};
int main()
{
    Base b;
    Derive d;
    Base &pb = d;//向上強制轉換
    pb.Fun();
    return 0;
}

結果仍然為:
這裡寫圖片描述
在後面將會看到,這種行為給我們帶來了很大的方便。
要說明的是:

如果在基類中定義了虛函數,那麼派生類中的同名函數將自動變為虛函數,但是我們可以在派生類同名函數前也加上virtual關鍵字,這樣會增加程序的可讀性。
總結:
如果要在派生類中重新定義基類的方法,通常應將基類方法聲明為虛擬的。這樣程序將根據對象類型而不是引用或指針的類型來選擇方法也就是函數的版本
注意
這裡一定要注意什麼時候用虛函數,必須是用指針或引用調用方法的時候用虛函數,因為如果是通過對象調用方法,那麼編譯的時候就知道應該用哪個方法了。
2.虛函數的工作原理
C++規定了虛函數的行為,但將實現方法留給了編譯器。
其實我們不需要知道實現方法就可以使用虛函數,但了解虛函數的工作原理會更好的幫助我們理解後面的更難的知識點,下面我就來剖析一下虛函數的工作原理。
通常,編譯器處理虛函數的方法是:
給每個對象添加一個隱藏成員
隱藏成員中保存了一個指向函數地址數組的指針。
其實這裡的函數地址數組指的就是虛函數表(virtual function table),vtbl。
虛函數表中存儲了為類對象進行聲明的虛函數的地址。
例如,基類對象包含一個指針,該指針指向基類中所有虛函數的地址表。派生類對象將包含一個指向獨立地址表的指針。
如果派生類提供了虛函數的新定義,該虛函數表將保存新函數的地址,如果派生類沒有重新定義虛函數,該vtbl將保存函數原始版本的地址。
如果派生類定義了新的虛函數,則該函數的地址也將被添加到vtbl中,注意,無論類中包含的虛函數是一個還是是個,都只需要在對象中添加一個地址成員,只是表的大小不同。
下面我將舉一個例子並畫出虛函數機制內存布局:

class Base
{
public:
    int b;
    virtual void Fun1()
    {
        cout << "Base::Fun1()" << endl;
    }
    virtual void Fun2()
    {
        cout << "Base::Fun2()" << endl;
    }
};
class Derive: public Base
{
public:
    int d;  
};
int main()
{
    Base b;
    b.b = 1;
    Derive d;
    d.b = 1;
    d.d = 2;
    return 0;
}

看上面的例子,這個例子中派生類沒有重新定義函數也沒有新增函數。
我們看一下內存布局。
首先先看

Base b;

的內存布局
這裡寫圖片描述
這是在內存中的存儲
我們已經知道了編譯器會給每個對象添加一個隱藏成員vptr,vptr中存儲的是虛表地址,那麼我們進入虛表中查看一下虛表中存儲了什麼:
這裡寫圖片描述
可以看到虛表占了十二個字節,最後的四個字節均為0,其實這是編譯器給虛表最後都會加四個字節的0,意義是NULL,表示虛表已經結束。
那麼b的內存布局如下圖所示:
這裡寫圖片描述

基類的內存布局已經清楚了,那麼我們現在來看派生類的內存布局:
上面的代碼中:

class Derive: public Base
{
public:
    int d;  
};

派生類僅僅是繼承了基類的虛函數,沒有自己重定義也沒有自己新增函數。
那麼派生類在內存中是如何存儲的呢:
後面的cccccccc是為了區分d和其他東西的不屬於d
由上面的圖我們可以看到,派生類d在內存中是十二個字節,前四個字節依然是編譯器給的vptr,後面緊跟的是基類成員,然後是自己新增的成員d,那麼我們進入d的虛表看看。
這裡寫圖片描述
我們可以看到,他還是十二個字節,與上面我們生成的派生類的虛表完全相同,d的內存布局如下
這裡寫圖片描述

到這為止我們大概對虛表有了一定的認識,因為這裡涉及到的知識點多,這篇文章又有點長,所以我後面會再跟一篇文章專門寫虛表。
這篇就點到為止。
還記得上面我們提出的為什麼不默認使用動態聯編的問題嗎。
第二點就是下面說的:
簡而言之,使用虛函數的時候,在內存和執行速度方面是有一定成本的。

每個對象都將增大,增大量為存儲地址的空間 對每個類,編譯器都創建一個虛函數的地址表 每個函數調用都需要執行一步額外的操作,即到表中查找地址

雖然非虛函數的執行效率比虛函數較高,但不具備動態聯編功能。
3.虛函數的注意事項
上面我們已經討論了虛函數的一些要點,下面我們再來看一些虛函數相關的注意事項:

1.構造函數:構造函數不能是虛函數
2.析構函數:析構函數應當是虛函數
這一點我會在後面專門寫一篇文章分析這個問題
3.友元:友元不能是虛函數
因為友元不是類成員,而只有成員才能是虛函數。
4.重定義,隱藏和覆蓋
這個問題的復雜性也值得一篇文章,此處只提及,後面會詳解。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved