面向物件,從單一的類開始說起。
class A{private: int m_a; int m_b;};
這個類中有兩個成員變數,都是int型別,所以這個類在記憶體中佔用多大的記憶體空間呢?
sizeof(A), 8個位元組,一個int佔用四個位元組。下圖驗證:
這兩個資料在記憶體中是怎樣排列的呢?
原來是這樣,我們根據debug出來的地址畫出a物件在記憶體的結構圖
如果 class A 中包含成員函式呢? A 的大小又是多少?
class A{public: void func1() {} private: int m_a; int m_b;};
類的物件共享這一段程式碼,試想,如果每一個物件都有一段程式碼,光是儲存這些程式碼得佔用多少空間?所以同一個類的物件共用一段程式碼。
共用同一段程式碼怎麼區分不同的物件呢?
實際上,你在呼叫成員函式時,a.func1() 會被編譯器翻譯為 A::func1(&a),也就是A* const this, this 就是 a 物件的地址。
所以根據this指標就能找到對應的資料,透過這同一段程式碼來處理不同的資料。
以下的測試可以驗證這個情況。
class A{public: void func1() { cout << "A func1" << endl; }private: int m_a; int m_b;};class B : public A{public: void func2() { cout << "B func2" << endl; }private: int m_c;};int main(int argc, char const* argv[]){ B b; b.func1(); b.func2(); return 0;}
輸出:
// A func1// B func2
那麼物件b在記憶體中的結構是什麼樣的呢?
繼承關係,先把a中的資料繼承過來,再有一份自己的資料。
每個包含虛擬函式的類都有一個虛表,虛表是屬於類的,而不是屬於某個具體的物件,一個類只需要一個虛表即可。同一個類的所有物件都使用同一個虛表。
為了指定物件的虛表,物件內部包含指向一個虛表的指標,來指向自己所使用的虛表。為了讓每個包含虛表的類的物件都擁有一個虛表指標,編譯器在類中添加了一個指標,*__vptr,用來指向虛表。這樣,當類的物件在建立時便擁有了這個指標,且這個指標的值會自動被設定為指向類的虛表。
class A{public: void func1() { cout << "A func1" << endl; } virtual void vfunc1() { cout << "A vfunc1" << endl; }private: int m_a; int m_b;};
cout << sizeof(A);, 輸出12,A中包括兩個int型的成員變數,一個虛指標,指標佔4個位元組。
a的記憶體結構如下:
虛表是一個函式指標陣列,數組裡存放的都是函式指標,指向虛擬函式所在的位置。
物件呼叫虛擬函式時,會根據虛指標找到虛表的位置,再根據虛擬函式宣告的順序找到虛擬函式在陣列的哪個位置,找到虛擬函式的地址,從而呼叫虛擬函式。
呼叫普通函式則不像這樣,普通函式在編譯階段就指定好了函式位置,直接呼叫即可。
class A{public: void func1() { cout << "A func1" << endl; } virtual void vfunc1() { cout << "A vfunc1" << endl; }private: int m_a; int m_b;};class B : public A{public: void func1() { cout << "B func1" << endl; } virtual void vfunc2() { cout << "B vfunc2" << endl; }private: int m_a;};
像這樣,B類繼承自A類,B中又定義了一個虛擬函式vfunc2, 它的虛表又是怎麼樣的呢?
給出結論,虛表如下圖所示:
我們來驗證一下:
A a;B b;void(*avfunc1)() = (void(*)()) *(int*) (*(int*)&a);void (*bvfunc1)() = (void(*)()) *(int*) *((int*)&b);void (*bvfunc2)() = (void(*)()) * (int*)(*((int*)&b) + 4);avfunc1();bvfunc1();bvfunc2();
來解釋一下程式碼: void(*avfunc1)() 宣告一個返回值為void, 無引數的函式指標 avfunc1, 變數名代表我們想要取A類的vfunc1這個虛擬函式。
右半部分的第一部分,(void(*)()) 代表我們最後要轉換成對應上述型別的指標,右邊需要給一個地址。
我們看 (*int(*)&a), 把a的地址強轉成int*, 再解引用得到 虛指標的地址。
*(int*) (*(int*)&a) 再強轉解引用得到虛表的地址,最後強轉成函式指標。
同理得到 bvfunc1, bvfunc2, +4是因為一個指標佔4個位元組,+4得到虛表的第二項。
覆蓋
class A{public: void func1() { cout << "A func1" << endl; } virtual void vfunc1() { cout << "A vfunc1" << endl; }private: int m_a; int m_b;};class B : public A{public: void func1() { cout << "B func1" << endl; } virtual void vfunc1() { cout << "B vfunc1" << endl; }private: int m_a;};
子類重寫父類的虛擬函式,需要函式簽名保持一致,該種情況在記憶體中的結構為:
多型
父類指標指向子類物件的情況下,如果指標呼叫的是虛擬函式,則編譯器會將會從虛指標所指的虛擬函式表中找到對應的地址執行相應的函式。
子類很多的話,每個子類都覆蓋了對應的虛擬函式,則透過虛表找到的虛擬函式執行後不就執行了不同的程式碼嘛,表現出多型了嘛。
我們把經過虛表呼叫虛擬函式的過程稱為動態繫結,其表現出來的現象稱為執行時多型。動態繫結區別於傳統的函式呼叫,傳統的函式呼叫我們稱之為靜態繫結,即函式的呼叫在編譯階段就可以確定下來了。
那麼,什麼時候會執行函式的動態繫結?這需要符合以下三個條件。
透過指標來呼叫函式指標 upcast 向上轉型(繼承類向基類的轉換稱為 upcast)呼叫的是虛擬函式為什麼父類指標可以指向子類?
子類繼承自父類,子類也屬於A的型別。
最後透過一個例子來體會一下吧:
class Shape{public: virtual void draw() = 0;};class Rectangle : public Shape{ void draw() { cout << "rectangle" << endl; }};class Circle : public Shape{ void draw() { cout << "circle" << endl; }};class Triangle : public Shape{ void draw() { cout << "triangle" << endl; }};int main(int argc, char const *argv[]){ vector<Shape*> v; v.push_back(new Rectangle()); v.push_back(new Circle()); v.push_back(new Triangle()); for (Shape* p : v) { p->draw(); } return 0;}
不對的地方也請指出來,大家一起學習進步。