文章內的所有調試都是在vs2022下進行的,
部分小細節(jié)可能因編譯器不同存在差異。
多態(tài)的定義和實現(xiàn)
概念引入
對于一個火車票售票系統(tǒng),
可能會有多重角色,
比如普通成人類、學生類、軍人類、兒童類等等…
這些類可能都是從某個基類派生出來的,
而且每個類都有一個基本需求,就是買票,
所以對于同一個購票函數(shù)BuyTicket()
,
當不同的類去調用它時它應該執(zhí)行不同的功能,
比如成人要全價賣票,學生可以半價買票,軍人得優(yōu)先買票…
所以怎樣滿足這一需求呢?
通過多態(tài)的機制。
所以多態(tài)其實就是不同繼承關系的類實例化出來的對象去調用同一函數(shù),
最終用同一個函數(shù)了執(zhí)行不同的動作。
感覺其實有點兒函數(shù)重載的意味…
多態(tài)的構成條件
首先見一見多態(tài)是個什么情況。
這是一段代碼:
class Person
{
public:
virtual void BuyTicket()
{ cout << "原價買票" << endl; }
};
class Student : public Person
{
public:
virtual void BuyTicket()
{ cout << "半價買票" << endl; }
};
class Solider : public Person
{
public:
virtual void BuyTicket()
{ cout << "優(yōu)先買票" << endl; }
};
void QueryPriority(Person* p)
{ p->BuyTicket(); }
int main()
{
Person p;
QueryPriority(&p);
Student stu;
QueryPriority(&stu);
Solider solider;
QueryPriority(&solider);
return 0;
}
運行結果如下:
虛函數(shù)重寫
在講多態(tài)的構成條件之前要先引入一個虛函數(shù)的概念。
虛函數(shù)就是用virtual
修飾的函數(shù),
在繼承一文中已經初步見識過virtual
關鍵字了,
當時是用virtual
進行虛擬繼承,
在菱形繼承中避免數(shù)據(jù)二義性和冗余問題,
這里是用來修飾函數(shù)使之成為虛函數(shù),
作為多態(tài)的構成條件之一。
所以構成多態(tài)的第一個條件是被調用的函數(shù)必須是虛函數(shù),
在上面的例子中被調用的函數(shù)是BuyTicket()
函數(shù),
所以它要定義成虛函數(shù):
class Person
{
public:
virtual void BuyTicket()
{ cout << "原價買票" << endl; }
};
這樣的話BuyTicket()
函數(shù)會被繼承到派生類中,
我們當然可以不加virtual
,
直接在派生類中重載BuyTicket()
函數(shù),
此時派生類中就有了兩個BuyTicket()
函數(shù),
一個是派生類中重載的,一個是基類的,
這兩個函數(shù)是構成隱藏關系的。
于是就有了數(shù)據(jù)冗余和二義性…
而我們只想保留一份函數(shù),
基類對象調用時執(zhí)行基類中定義的行為,
派生類對象調用時執(zhí)行派生類中定義的行為。
顯然重載是完成不了這個任務的,
所以取而代之的就是重寫:
class Student : public Person
{
public:
virtual void BuyTicket()
{ cout << "半價買票" << endl; }
};
class Solider : public Person
{
public:
virtual void BuyTicket()
{ cout << "優(yōu)先買票" << endl; }
};
此前在基類和派生類中重名的成員變量或成員函數(shù)是構成隱藏/重定義關系的,
但那是對于普通函數(shù)而言,
滿足一定條件的虛函數(shù)則是會構成覆蓋/重寫關系,
意味著在派生類中只有這么一個函數(shù)存在,
繼承下來的基類的函數(shù)完全被覆蓋掉了。
上面說的滿足一定條件,
前提一定是虛函數(shù),
此外除了函數(shù)名相同,
函數(shù)的返回值類型和參數(shù)列表也要相同。
但是返回值類型相同還有例外,
基類與派生類的虛函數(shù)返回值類型可以不相同,
但一定要是繼承關系中基類或派生類的指針或引用,
舉個簡單的例子:
class Student : public Person
{
public:
virtual Student* BuyTicket()
{ cout << "半價買票" << endl; }
}
class Solider : public Person
{
public:
virtual Solider* BuyTicket()
{ cout << "優(yōu)先買票" << endl; }
}
這種情況下仍然構成多態(tài),
(這么雞肋的寫法應該沒人用吧)…
以上就是構成多態(tài)的其一條件:
被調用的函數(shù)必須是虛函數(shù),且派生類必須對基類的虛函數(shù)進行重寫。
通過基類的指針或者引用調用虛函數(shù)
諸如上面的多態(tài)調用:
Person p;
Person* ptr = &p;
ptr->BuyTicket();
Student stu;
ptr = &stu;
ptr->BuyTicket();
Solider solider;
ptr = &solider;
ptr->BuyTicket();
想要實現(xiàn)函數(shù)的多態(tài)調用,
首先函數(shù)一定是重寫過的虛函數(shù),
再就是要通過基類指針或者引用來調用。
至于為什么,在后面的多態(tài)原理細細闡述。
此前在繼承一文中提出過一個問題:
如果這么定義了一個對象(此處省略類的定義):
int main() { A* p = new B; delete p; return 0; }
此時運行結果如下:
此時只調用了A的析構,
對B的部分成員并沒有處理,
因此造成了內存泄漏!
那么現(xiàn)在就可以解決這個問題,
就是將析構函數(shù)定義成虛函數(shù),
在析構時會多態(tài)調用B類的析構函數(shù),
就不會發(fā)生內存泄漏了。
這里有一個細節(jié),
前面提到函數(shù)構成重寫的條件之一是函數(shù)名必須相同,
而析構函數(shù)顯然不符合這個條件,
但為什么又能實現(xiàn)重寫呢?
實際上編譯器偷偷對析構函數(shù)進行了處理,
統(tǒng)一將析構函數(shù)處理成同名函數(shù)。
override和final
C++對函數(shù)重寫的要求比較嚴格,
但是有些情況下由于疏忽,
可能會導致函數(shù)名字母次序寫反而無法構成重寫,
而這種錯誤在編譯期間是不會報出的,
只有在程序運行時沒有得到預期結果才來debug會得不償失,
因此,C++11提供了override
和final
兩個關鍵字,
可以幫助用戶檢測是否重寫。
final
:修飾虛函數(shù),表示該虛函數(shù)不能再被重寫
class Person { public: virtual void BuyTicket() final { cout << "原價買票" << endl; } }; class Student : public Person { public: virtual void BuyTicket() { cout << "半價買票" << endl; } };
override
: 檢查派生類虛函數(shù)是否重寫了基類某個虛函數(shù),如果沒有重寫編譯報錯
class Person { public: virtual void BuyTicket() { cout << "原價買票" << endl; } }; class Student : public Person { public: virtual void BuyTickte() override { cout << "半價買票" << endl; } };
上面故意錯把派生類中的函數(shù)名寫錯了。
抽象類
概念
在虛函數(shù)的后面寫上=0
,
則這個函數(shù)為純虛函數(shù)。
包含純虛函數(shù)的類叫做抽象類(也叫接口類),
抽象類不能實例化出對象。
派生類繼承后也不能實例化出對象,
只有重寫純虛函數(shù),派生類才能實例化出對象。
純虛函數(shù)規(guī)范了派生類必須重寫,
另外純虛函數(shù)更體現(xiàn)出了接口繼承。
class Person
{
public:
virtual void BuyTicket() = 0
{
cout << "原價買票" << endl;
}
void func()
{}
};
實現(xiàn)繼承和接口繼承
普通函數(shù)的繼承是一種實現(xiàn)繼承,
派生類繼承了基類函數(shù),可以使用函數(shù),
繼承的是函數(shù)的實現(xiàn)。
虛函數(shù)的繼承是一種接口繼承,
派生類繼承的是基類虛函數(shù)的接口,
目的是為了重寫,達成多態(tài),
繼承的是接口。
所以如果不實現(xiàn)多態(tài),
不要把函數(shù)定義成虛函數(shù)。
虛函數(shù)表
64位地址太長,
所以為了方便觀察,
下面統(tǒng)一換成32位地址。
現(xiàn)在定義一個基類:
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};
然后定義一個Base
對象,
那這個對象的對象模型是怎樣的呢?
相比于沒有虛函數(shù)的普通對象多了一個_vfptr
:
看樣子是一個數(shù)組,
數(shù)組元素都是指針,
那看來_vfptr
是一個指針數(shù)組:
這里顯示它有一個元素,
這個元素存放的是Func1
函數(shù)的地址。
多出來的這個_vfptr
是虛函數(shù)表指針,
完整應該叫做visual function table pointer,
虛函數(shù)表我們一般簡稱為虛表,
注意和繼承中的虛基表的概念區(qū)分開,
它存放的其實就是虛函數(shù)的地址,
注意虛函數(shù)并不存放在這兒,
虛函數(shù)和普通函數(shù)一樣是存放在代碼段的。
所以一個含有虛函數(shù)的類中都至少都有一個虛表指針。
單繼承中的虛表
我們再進一步看在繼承中虛表是怎樣的,
在上面代碼的基礎上我們再寫點東西:
class Base
{
public:
virtual void Func1()
{ cout << "Base::Func1()" << endl; }
virtual void Func2()
{ cout << "Base::Func2()" << endl; }
void Func3()
{ cout << "Base::Func3()" << endl; }
private:
int _b = 1;
};
class Derive : public Base
{
public:
virtual void Func1()
{ cout << "Derive::Func1()" << endl; }
virtual void Func4()
{ cout << "Derive::Func4()" << endl; }
private:
int _d = 2;
};
此時再看一下它們的對象模型有什么變化:
基類對象的結構還是原來那樣,
只不過虛表中多了一個指向Func2
函數(shù)的指針,
Func3
并不在這兒,因為它不是虛函數(shù)。
派生類繼承了基類的虛表,
但是存的指針有些變化,
可以看到_vfptr[0]
存放的是被重寫后的Func1
的地址。
所以就可以下一個簡單的結論:
派生類先繼承基類的虛表,
如果派生類重寫了基類中某個虛函數(shù),
用派生類自己的虛函數(shù)覆蓋虛表中基類的虛函數(shù)。
那派生類自己的虛函數(shù)呢?
按理來說派生類有虛函數(shù),
實例化出來的對象也應該有一個虛基表,
但是這里好像并不是這樣。
實際上,
派生類自己新增加的虛函數(shù),
會按其在派生類中的聲明次序增加到基類虛表的最后,
和基類共用一個虛表。
而這里繼承的基類的虛表沒有顯示出派生類的虛函數(shù),
這是編譯器的監(jiān)視窗口故意隱藏了這個函數(shù),
也可以認為是他的一個小bug。
那么我們如何查看d
的虛表呢?
打印虛表
我們既然有虛表指針_vfptr
,
那我們肯定就有辦法打印它指向的虛表的內容,
也就是各個虛函數(shù)的地址。
下面就來看一下怎樣獲取。
首先在對象的存儲結構中虛表指針存放在最上面,
也就是對象頭四個字節(jié)(64位指針是八個字節(jié)),
所以對象模型如下:
因為我們最終要訪問的指針類型是函數(shù)指針,
所以我們可以先typedef
一下這個函數(shù)指針類型:
typedef void(*VFPTR)()
我們先取d的地址:&d
,
&d
此時的類型是Derive*
我們需要對其進行類型轉換,
我們看到_vfptr
的類型是void**,
所以對其進行類型轉換:(void**)&d
,
void*
在32位平臺下是4個字節(jié),64位平臺下是8個字節(jié),
所以對void**
解引用就可以訪問頭4/8個字節(jié)的空間。
然后對其解引用找到虛表:*(void**)&d
,
此時就拿到了虛表指針,但它的類型是void*
,
而虛表是一個函數(shù)指針數(shù)組,
所以我們再做一次類型轉換就拿到了可以訪問數(shù)組元素的虛表指針:(VFPTR*)(*(void**)&d)
,
所以我們就可以通過下標訪問訪問到函數(shù)指針:((VFPTR*)(*(void**)&d))[0] -> Derive::Func1()
,
我們還可以通過拿到的函數(shù)指針調用函數(shù):((VFPTR*)(*(void**)&d))[0]()
。
所以現(xiàn)在我們可以通過下面的代碼遍歷虛表,
打印虛表中存放的函數(shù)指針,
并通過函數(shù)指針調用函數(shù),
看看是哪個函數(shù):
for (int i = 0; i < n; i++) // n是虛表中有幾個函數(shù)指針
{
cout << ((VFPTR*)(*(void**)&d))[i] << "->";
((VFPTR*)(*(void**)&d))[i]();
}
這里有三個虛函數(shù),所以n
就是3
,
運行結果如下:
驗證了此前所說的。
我們還可以看一下基類對象的虛表:
多繼承中的虛表
看下面的代碼:
class Base1
{
public:
virtual void func1()
{ cout << "Base1::func1" << endl; }
virtual void func2()
{ cout << "Base1::func2" << endl; }
private:
int _b1 = 1;
};
class Base2
{
public:
virtual void func1()
{ cout << "Base2::func1" << endl; }
virtual void func2()
{ cout << "Base2::func2" << endl; }
private:
int _b2 = 2;
};
class Derive : public Base1, public Base2
{
public:
virtual void func1()
{ cout << "Derive::func1" << endl; }
virtual void func3()
{ cout << "Derive::func3" << endl; }
private:
int _d1 = 3;
};
此時派生類是繼承了兩個基類的派生類,
那它現(xiàn)在的對象模型是怎樣的呢?
可以看到普通多繼承的場景下它完整繼承了兩個基類的虛表指針,
兩個基類中都有Func1
函數(shù),
可以看到此時Func1
函數(shù)都被重寫后的函數(shù)覆蓋了。
那派生類它自己的虛函數(shù)呢?
這里直接給出結論:多繼承派生類的虛函數(shù)放在第一個繼承基類部分的虛函數(shù)表中。
所以第一個虛表中存放了三個函數(shù)指針,
分別指向Derive::Func1()
,Base1::Func2()
,Derive::Func3()
,
第二個虛表中存放了兩個函數(shù)指針,
分別指向Derive::Func1()
,Base2::Func2()
。
所以對象模型如下:
可以通過打印虛表來驗證一下。
這里直接對d
取地址可以拿到Base1::_vfptr
,
但是要怎么拿到Base2::_vfptr
呢?
我們可以直接讓指針偏移sizeof(Base1)
個字節(jié),
也就是把&d
改為(char*)&d + sizeof(Base1)
,
結果如下:
虛表的存儲
我們現(xiàn)在再明確一下概念,
虛函數(shù)是函數(shù),
和普通函數(shù)一樣,
存放在代碼段。
虛表是一個指針數(shù)組,
存放指向虛函數(shù)的指針。
而類實例化出來的對象中存放的是一個虛表指針,
是指向虛表的指針。
所以對象中虛表指針存放在哪是很明確的,
就看對象存放在哪,
對象在棧上,那它的虛表指針也在棧上,
對象在堆上,那它的虛表指針就在堆上。
那問題來了,虛表存在哪呢?
我們可以通過一個簡單的比對來看看:
Derive d;
Derive* pd = new Derive;
cout << "棧: " << &d << endl;
Derive* pd = new Derive;
cout << "堆: " << pd << endl;
cout << "代碼段: " << ((VFPTR*)(*(void**)&d))[0] << endl;
cout << "d的虛表地址: " << (void*)*(void**)&d << endl;
cout << "pd的虛表地址: " << (void*)*(void**)pd << endl;
棧、堆、代碼段上的空間都是連續(xù)的,
我們我們可以將虛表的地址和它們進行比較:
通過對比可以發(fā)現(xiàn)虛表是存放在代碼段的,
而且無論是臨時對象還是動態(tài)開辟的對象,
都是共用一個虛表。
當一個c++程序編譯成可執(zhí)行程序之后,
此時虛表已經形成了,
和函數(shù)一樣存放在代碼段。
當我們實例化對象時,
對應的構造函數(shù)會對對象的虛表指針進行初始化,
將虛表的地址寫入到虛表指針中,
所以虛表是編譯完就有了的,
而虛表指針是運行時才有的。
多態(tài)的原理
前面我們看了虛函數(shù)表,
那這個虛函數(shù)表和多態(tài)調用有什么密不可分的關系嗎?
下面以文章開頭的那段代碼為例進行講解:
class Person
{
public:
virtual void BuyTicket()
{ cout << "原價買票" << endl; }
};
class Student : public Person
{
public:
virtual void BuyTicket()
{ cout << "半價買票" << endl; }
};
class Solider : public Person
{
public:
virtual void BuyTicket()
{ cout << "優(yōu)先買票" << endl; }
};
void QueryPriority(Person* p)
{ p->BuyTicket(); }
int main()
{
Person p;
Student stu;
Solider solider;
QueryPriority(&p);
QueryPriority(&stu);
QueryPriority(&solider);
return 0;
}
首先我們有一個基類指針,
當我們使用這個指針去調用虛函數(shù)時,
會去訪問這個基類指針指向的對象的虛表指針,
然后通過虛表指針找到虛表,
在虛表中找到對應的函數(shù)然后調用。
如果調用的函數(shù)不存在于虛表中,
則會發(fā)生報錯,
最簡單的就是使用基類指針去調用派生類自己定義的虛函數(shù)。
在文章虛表的存儲部分最后說了,
對象的虛表指針是在構造函數(shù)中初始化的,是運行時才有的,
在程序運行期間,
根據(jù)具體拿到的類型確定程序的具體行為,
調用具體的函數(shù),
這就是所謂的動態(tài)綁定,也叫動態(tài)多態(tài)。
我們可以通過匯編代碼看一下普通調用和多態(tài)調用時的區(qū)別:
與動態(tài)綁定相對的是靜態(tài)綁定,也叫靜態(tài)多態(tài),
我們常用的函數(shù)重載就是一種靜態(tài)多態(tài),
是在在程序編譯期間確定了程序的行為。
幾個小問題
-
內聯(lián)函數(shù)(inline)可以是虛函數(shù)嗎?
可以。
不過編譯器就忽略inline屬性,
這個函數(shù)就不再是inline,
因為虛函數(shù)要放到虛表中去。
-
靜態(tài)成員可以是虛函數(shù)嗎?
不能。
因為靜態(tài)成員函數(shù)沒有this指針,
使用類型::成員函數(shù)的調用方式無法訪問虛函數(shù)表,
所以靜態(tài)成員函數(shù)無法放進虛函數(shù)表
-
構造函數(shù)可以是虛函數(shù)嗎?
不能。
因為對象中的虛函數(shù)表指針是在構造函數(shù)初始化列表階段才初始化的。
如果構造函數(shù)定義成虛函數(shù),
那想調用構造函數(shù)就要去虛函數(shù)表中尋找,
而虛表指針還沒有初始化,
就找不到構造函數(shù)了。
文章來源:http://www.zghlxwxcb.cn/news/detail-428451.html -
對象訪問普通函數(shù)快還是虛函數(shù)更快?
首先如果是通過"對象.函數(shù)"的方式去調用,
是一樣快的。
如果是指針對象或者是引用對象,
則調用的普通函數(shù)快,
因為調用虛函數(shù)時還需要先到虛函數(shù)表中去查找。
文章來源地址http://www.zghlxwxcb.cn/news/detail-428451.html
到了這里,關于【C++】面向對象之多態(tài)的文章就介紹完了。如果您還想了解更多內容,請在右上角搜索TOY模板網以前的文章或繼續(xù)瀏覽下面的相關文章,希望大家以后多多支持TOY模板網!