使用基類的指針或者引用在運行期執行正確的操作這種行為我們稱之為多態。
從這個基本的概念,我們可以簡單的推斷出多態所需要的關鍵技術之一便是繼承,關於繼承在第三章已經接觸,不過並沒有真正的深入,那麼除了繼承之外支撐多態行為的便是關鍵字virtual啦。那麼,在介紹virtual之前我們先來看看下面這個模型:
//+----------------------------
class Ios{};
class IStream : public virtual Ios{};
class OStream : public virtual Ios{};
class IOStream : public IStream,public OStream{};
//+----------------------------
這是典型的鑽石模型,他結構如下(由於家裡電腦沒有安裝viso,所以這裡的模型圖都是用紙繪製出來的,粗糙了點):
下面我們來討論IOStream的各個部分的大小:
//+-----------------------------
std::cout< std::cout< std::cout< std::cout< //+----------------------------- 如果我們不執行上面代碼的話我們能夠知道答案嗎?好吧,我們簡單的分析一下: 首先,Ios是一個空類,所以我們可以認為他的大小是0,但是……但是以前的都是廢話,我們可以想想一下: //+---------------------------- Ios os; std::cout< //+--------------------------- 就比如上面的操作,當我們想對一個沒有數據成員的對象取地址時,如果我們認為他的大小是0的,那麼這裡是不是非法呢?事實上我們這樣做是沒問題的,也就是說這種操作是合法,那麼問題來了,既然合法,那我們還能夠認為這個空類的大小是0嗎?當然不是,事實是這樣,當一個類為空類——也就是沒有任何數據成員的類,編譯器會他安插一個char進去,所以他的大小應該為1。 對於IStream的大小,想要分析那就稍微麻煩點了,在分析他大小之前我們先來說說虛基類的概念,虛基類不同於普通的基類,虛基類屬於一個共享對象,所以在IStream的數據中並沒有基類的對象,而是包含一個指向基類對象的指針,該模型如下: 在明白了虛基類這個概念之後我們進一步分析IStream的大小就比較容易了,首先IStream的對象裡面包含了一個指針,該指針指向Ios對象,在win32平臺下,一個指針的大小是4,這裡需要注意,雖然IStream也是一個空類,但是此處我們有了一個指向基類對象的指針,所以就不需要在安插一個char,所以IStream的大小便是4。 同理OStream的大小也是4。 對於IOStream而言,這裡他的大小也就是IStream的大小加上OStream的大小,所以IOStream的大小便是8. 我們上面的模型只是基本的繼承,不具有多態性質,所以我們將問題稍微的複雜話一下,讓他具有多態性質: //+---------------------- class Ios{ public: virtual Ios& operator< virtual Ios& operator>>(int&){} }; class IStream : public virtual Ios{}; class OStream : public virtual Ios{}; class IOStream : public IStream,public OStream{}; //+----------------------- 我們現在再來分析一下各部分的大小,首先還是Ios,此處Ios依舊沒有數據成員,那麼我們是不是任務他的大小是1呢?嗯,這裡就複雜了,首先我們可以肯定他的大小不是1,那麼是多少呢?這裡我們就來挖掘多態背後的真相。 當一個類裡面至少擁有一個虛函數時,那麼編譯器就會為該類生成一張虛函數表——vtbl,而該類的對象將會有持有一個指向該該表的指針——vptr。那麼現在我們先來看看編譯器是如何調用普通成員函數的: //+-------------------------- class B { public: void Fun(){} }; int main(){ B b; b.Fun(); } //+------------------------- 如上,當我們定義出一個B的對象後,我們調用Fun函數,編譯背後的操作如下: //+------------------------- 首先編譯器會將成員函數Fun改成一個普通的外部函數函數: void B_Fun(B* const this); // B_Fun 不一定是這個名字,編譯器有他自己的命名規則,這裡僅僅作為一個參考 於是 b.Fun() ==> B_Fun(&b) //+------------------------- 從上面的過程我們可以看到使用class的封裝和C語言的直接函數調用比起並沒有帶來任何效率上的損失。也就是說: //+-------------------------- void Fun(B* const this); void B::Fun(); B b; b.Fun(); Fun(&b); //+-------------------------- 上面的兩種執行效率是一樣的。 那麼如果成員函數有關鍵字virtual修飾的時候編譯器還會這樣將他改寫為普通的外部函數嗎?答案是否定,對於有virtual關鍵字修飾的成員數,編譯器會將他放進虛函數表中,添加一個索引,使用chunk技術(該技術有些複雜,目的是為了提高調用效率,所以這裡不細說),比如: //+--------------------------- class B{ public: virtual void Fun(){} }; int main(){ B b; b.Fun(); } //+---------------------------- 上面的b.Fun()的調用實際上將是執行下面的代碼: //+---------------------------- (*b.vptr[0])(&b); //+---------------------------- 此處的vptr是就虛函數指針,由於他指向的是一張虛函數表,B只有一個虛函數,就是索引為0的位置。 到了這一步,我們離揭開多態的技術內幕也僅僅只有一步之遙了,OK,那麼我們現在來分析一下Ios的大小,我們上面說了,此時引入虛函數的Ios的大小已經不再是1,由於他是空類,所以他的大小為一個指針大小,該指針指向Ios的虛函數表,該虛函數表中有兩個slot,分別是operator<>的函數指針,所以此處的Ios的大小是4。 知道Ios的大小是4之後我們來計算IStream的大小,由於IStream是虛列繼承至Ios,所以他的大小不僅包含了Ios的大小,同時還有一個Ios的指針,模型如下,如果他的大小是8。 同理OStream的大小也是8,得出IStream和OStream的大小之後我們來看看IOStream的大小,那麼弱弱的問一下,IOStream的大小是16嗎?答案同樣是否定,上面我們說了,對於虛基類整個對象中只有一個對象實例存在,所以對於IOStream來說,並不是IStream的大小加上OStream的大小這麼簡單,因為這裡僅僅只有一個Ios對象存在,而一個Ios的大小是4,所以這裡IOStream的大小為12,模型如下: 到了這裡,我們可以繼續深入啦: //+---------------------------- class Ios{ public: virtual Ios& operator< virtual Ios& operator>>(int&){} }; class IStream : public virtual Ios{ public: Ios& operator>>(int&){} }; class OStream : public virtual Ios{ public: Ios& operator< }; class IOStream : public IStream,public OStream{}; //+-------------------------- 這裡我們真正的引入了多態,在徹底揭開他背後的神秘面紗之前我們先來看看構造函數都做些啥,首先從簡單的Ios說起: //+-------------------------- Ios* Ios:Ios(Ios* const this){ this->vptr = vbtl; return this; } //+--------------------------
上面的代碼不能當真,可以把他當作偽碼,這段代碼實在構造一個對象的時候編譯器擴展出來的,我們是不需要關心,這是編譯器會為我們做的,所以當我們在完成一個對象構造之後我們就擁有一張行為正確的虛函數表,在執行期就能夠得到正確行為,當然由於Ios本身就沒有繼承,所以這無關緊要,那麼對於IStream的構造函數就比較麻煩一些,大致會擴展成如下:
//+--------------------------
IStream* IStream::IStream(IStream* const this,bool is_most_derived)
{
if(is_most_derived != false)
this->Ios::Ios(); // 構造基類
this->vptr = vbtl;// 設置虛函數表
this->Ios.vptr = vbtl_Ios; // vbtl是修改後的基類的虛函數表,對於這裡來說是將slot2的地址換成&IStream::operator>>
return this;
}
//+--------------------------
當然這是虛繼承的情況下會這麼做,如果不是虛繼承的時候就相對簡單一些,比如:
//+--------------------------
class B {
public:
void Fun(){}
};
class D : public B {
public:
void Fun(){}
};
Dd構造函數的擴展如下
D* D::D(D* const this)
{
this->B::B();
this->vptr = vbtl;
this->vptr_B = vbtl_B;
return this;
}
//+------------------------
同理,我們知道了OStream的構造過程,那麼對於IOStream的呢?他應該怎麼構造呢?其實也是大同小異,如下:
//+------------------------
IOStream* IOStream::IOStream(IOStream* const this,bool is_most_derived)
{
if(is_most_derived != false)
this->Ios::Ios(); // 構造共享部分基類
this->IStream::IStream(false);
this->OStream::OStream(false);
// 初始化vptr
return this;
}
//+-------------------------
到了這裡,多態背後的神秘面紗是不是已經被徹底揭開了呢?所謂多態就是根據基類持有的虛函數指針找到對應的虛函數表,從中執行指定函數指針然後得到正確的行為,對於構造函數的執行,都是先執行基類的構造函數,最後一層層的執行上來,最終執行到自己的構造函數,每一個構造函數中都有對基類的vptr進行修改,所以基類的vptr最終都是指向了最新的vbtl,這就是多態的秘密,而從這個結論中我們還得到一個鐵律,那就是在構造函數中調用虛函數實際上等同於調用普通函數,因為在當前的構造函數我們所能夠拿到最新的vbtl是當前類的vbtl,並非是最後的vbtl,所以我們所執行的函數就是當前類的函數,所以不論是是否為虛函數,那麼他的效果都如果非虛函數,當然調用的過程可以有所區別:
//+-----------------------------
class B{
public:
B(){
fun();
}
virtual void fun(){}
};
其中的構造函數將會被擴展如下形式:
B* B::B(B* const this)
{
this->vptr = vbtl;
(*this->vptr[0])(this);
return this;
}
//+--------------------------------
ok,到此處,對於多態,想必大家都已經有了深入的認識,如果覺得還不夠,那麼大家可以去研究一下《深度探索C++對象模型》一書。
每天會更新論文和視頻,還有如果想學習c++知識在晚上8.30免費觀看這個直播:https://ke.qq.com/course/131973#tuin=b52b9a80
閱讀更多 IT布丁老師 的文章