目錄
多態的概念
多態的定義及實現
1.虛函數
2.?多態的實現
2.1.多態構成條件
2.2.虛函數重寫的兩個例外
(1)協變(基類與派生類虛函數返回值類型不同)
(2)析構函數的重寫(基類與派生類析構函數的名字不同)
2.3.多態的實現
2.4.多態在析構函數中的應用
2.5.多態構成條件的題目分析
接口繼承和實現繼承
C++11 override 和 final
1.final:修飾虛函數,表示該虛函數不能再被重寫
2. override: 檢查派生類虛函數是否重寫了基類某個虛函數,如果沒有重寫就會編譯報錯。
重載、重寫(覆蓋)、隱藏(重定義)的對比
抽象類
注意事項:C++的多態 - 上、下文章中涉及的所有代碼都是在在vs2022下的x86程序中執行的。如果要其他平臺下,部分代碼需要改動。比如:如果是x64程序,則需要考慮指針是8bytes問題等等。
多態的概念
(1)概念:在 C++ 中,多態是一種面向對象編程的特性,它允許不同類型的對象對相同的消息或者相同的函數接口調用做出不同的響應。簡單來說,就是用基類的指針或引用來調用不同派生類中重寫的虛函數,根據指針或引用所指向的實際對象類型來決定調用哪個派生類的函數版本,從而實現不同的行為。
通俗來說,多態就是多種形態,具體點就是去完成某個行為,當不同的對象去完成時會產生出不同的狀態。
(2)多態案例
- 案例1:比如,在一個游戲中有不同角色,如戰士、法師和盜賊。當執行 “攻擊” 操作時,戰士可能會使用近戰武器進行強力的物理攻擊,法師會釋放魔法技能進行遠程的魔法攻擊,盜賊則可能會利用敏捷的身手進行偷襲攻擊。這里 “攻擊” 是相同的操作,但不同角色(不同類型的對象)執行該操作時有著不同的攻擊方式和效果,這也是多態的表現。每個角色都根據自身的特點和能力來實現 “攻擊” 這個行為,從而呈現出多種不同的攻擊形態。
- 案例2:在現實生活中,購車票存在多種不同的情況,這很好地體現了多態的概念。
購車票是一個普遍的行為,但不同身份的人購車票會有不同的結果和方式,這就是多態的表現。比如,普通人購車票,通常是按照正常的票價支付,完成購票流程,這是一種常見的 “形態”。而學生購車票時,因為學生身份的特殊性,往往可以享受半價優惠,他們在購票時出示學生證等相關證件,就可以以較低的價格買到車票,這是與普通人不同的 “形態”。另外,軍人購車票又有不同,軍人可能會享受優先購票的待遇,在購票時無需像普通人那樣排隊等待,而是可以直接到專門的窗口優先辦理購票手續,這又是一種不同的 “形態”。
同樣是購車票這個行為,由于購票人的身份不同,導致了不同的行為表現和結果,這就是多態。它體現了在不同的對象上執行相同的操作(購車票),卻會因為對象的不同特征(身份)而產生不同的具體行為和結果。
多態的定義及實現
1.虛函數
虛函數定義:把關鍵字virtual
加在類成員函數的前面,該類成員函數即為虛函數。例如:
#include <iostream>
using namespace std;class Person
{
public:virtual void BuyTicket() { cout << "買票-全價" << endl; }
};
虛函數與虛繼承雖共用virtual
關鍵字,但二者并無直接關聯。虛繼承主要用于解決菱形繼承中的數據冗余和二義性問題;而虛函數是為實現多態,當多種if-else
分支處理復雜時,虛函數可更方便地實現不同對象做同一操作時產生不同結果。
2.?多態的實現
2.1.多態構成條件
(1)多態實現的兩個必要條件:虛函數重寫、父類指針 / 引用調用虛函數。
①虛函數重寫:子類和父類中同名成員函數滿足 “三同”(函數名、參數類型、返回值類型相同),且父類函數聲明為虛函數。子類重寫父類虛函數時,即使子類函數前不加virtual
關鍵字(因為繼承關系,子類函數仍保持虛函數屬性,但不規范,不建議),也構成重寫。
②多態實現的調用規則
在滿足多態的兩個必要條件(虛函數重寫、父類指針 / 引用調用虛函數 )后,多態實現遵循以下調用規則:
- 當父類指針 / 引用指向父類對象時,使用父類指針 / 引用調用虛函數,調用的是父類對象的虛函數。
- 當父類指針 / 引用指向子類對象時,使用父類指針 / 引用調用虛函數,調用的是子類對象重寫后的虛函數。
#include <iostream>
using namespace std;//定義一個父類 Person
class Person
{
public://定義一個虛函數 BuyTicket,用于表示買票行為,默認全價virtual void BuyTicket() { cout << "買票-全價" << endl; }
};//定義一個子類 Student,繼承自 Person 類
class Student : public Person
{
public://1. 在繼承體系中,如果沒有多態特性,當子類和父類有同名成員函數時,// 子類的成員函數會隱藏父類的成員函數,即父類的同名成員函數被屏蔽。//2. 在多態的情況下,若子類和父類的成員函數滿足函數名、參數列表、返回值類型都相同,// 并且父類的該函數為虛函數,那么子類的同名函數會重寫(覆蓋)父類的虛函數。//3. 注意:函數重載要求函數在同一作用域內,而子類和父類屬于不同作用域,// 所以子類和父類的同名函數不構成重載關系。//4. 這里子類的虛函數 BuyTicket() 重寫(覆蓋)了父類 Person 的同名虛函數 BuyTicket()。// 雖然在重寫父類虛函數時,子類的虛函數不加 virtual 關鍵字也能構成重寫// (因為繼承后父類的虛函數屬性會被繼承下來),但這種寫法不規范,不建議使用。virtual void BuyTicket() { cout << "買票-半價" << endl; }//void BuyTicket() { cout << "買票-半價" << endl; }
};//多態的條件:
//1、虛函數的重寫 -- 三同(函數名、參數列表、返回值類型相同),且父類的函數為虛函數。
//解析:虛函數的重寫是指父類和子類的同名成員函數,父類的函數前加上 virtual 關鍵字使其
//成為虛函數,子類的同名函數也滿足相同的函數名、參數列表和返回值類型,此時子類的函數會
//重寫(覆蓋)父類的虛函數。
//2、通過父類指針或者引用去調用虛函數。
//解析:即不管是調用父類還是子類的同名虛函數,都必須使用父類指針或引用來調用。// 定義一個函數 Func,參數為父類的引用
// 注意:這里使用父類引用是為了實現多態
void Func(Person& p)
{//1. 在沒有多態特性時,無論 Func 函數的形參 Person& p 接收的是子類對象還是父類對象,//p.BuyTicket() 都會調用父類的成員函數 BuyTicket()。//2. 在多態的情況下,//當 Func 函數的形參 Person& p 接收子類對象時,p.BuyTicket()會調用子類的//虛函數 BuyTicket();//當接收父類對象時,p.BuyTicket() 會調用父類的虛函數 BuyTicket()。//總的來說,根據傳入對象的不同,調用不同的虛函數,體現了多態性。p.BuyTicket();
}int main()
{//創建一個父類對象Person ps;//創建一個子類對象Student st;//調用 Func 函數,傳入父類對象,會調用父類的虛函數Func(ps); //調用 Func 函數,傳入子類對象,會調用子類的虛函數Func(st); return 0;
}
(2)父類對象與多態
父類對象無法實現多態。當不滿足多態的兩個必要條件中的任何一個時,就無法實現多態。例如,若父類函數不是虛函數,或者沒有通過父類指針 / 引用調用函數,都不能實現多態。
2.2.虛函數重寫的兩個例外
虛函數的重寫(覆蓋):派生類中有一個跟基類完全相同的虛函數(即派生類虛函數與基類虛函數的
返回值類型、函數名字、參數列表完全相同),稱子類的虛函數重寫了基類的虛函數。
(1)協變(基類與派生類虛函數返回值類型不同)
解析:派生類重寫基類虛函數時,與基類虛函數返回值類型不同。即基類虛函數返回基類對象的指
針或者引用,派生類虛函數返回派生類對象的指針或者引用時,稱為協變。
注意事項:
- 在實際編程中,協變的應用場景相對較少。
- 重寫虛函數存在協變返回類型的情況時,要求父類和子類的虛函數返回值類型必須都為指針類型或都為引用類型,并且返回的指針或引用所指向的類型要具有繼承關系,即一個是另一個的父類或子類類型。若父類與子類的虛函數僅函數名和參數列表相同,但返回值類型不同,且返回值類型并非父子關系的指針或引用 ,編譯器通常會報錯,因為這不符合重寫規則。
- 即便子類與父類的
operator=
賦值重載函數均返回引用,且僅函數名相同、參數不同,它們與虛函數重寫(多態)通常也并無關聯。這是因為賦值重載函數并不支持虛函數的特性,無法利用多態機制在運行時根據對象的實際類型來調用合適的賦值操作。虛函數重寫是實現多態的重要手段,其核心在于通過基類指針或引用,在運行時根據實際對象類型調用相應的派生類函數。而賦值重載函數有其特殊性,當在子類中顯式實現operator=
賦值重載函數時,可能會復用父類的operator=
賦值重載函數,這只是代碼復用的一種方式,并非基于虛函數重寫的多態調用。因為賦值重載函數不會被聲明為虛函數,也就不會參與到虛函數的機制中。編譯器在編譯時就確定了要調用的賦值重載函數,而不是在運行時根據對象的實際類型來動態選擇。
代碼:?
#include <iostream>
using namespace std;// 多態的條件:
// 1、虛函數的重寫 -- 三同(函數名、參數、返回值)
// -- 例外(協變):返回值可以不同,必須是父子關系指針或者引用
// -- 例外:子類虛函數可以不加virtual
// 2、父類指針或者引用去調用虛函數//父類
class A{};
//子類
class B : public A {};//父類
class Person
{
public:virtual Person* BuyTicket()//virtual A* BuyTicket(){ cout << "買票-全價" << endl;return nullptr;}
};//子類
class Student : public Person
{
public://注意:協變對于子、父類虛函數返回值類型必須都為指針類型或都為引用類型,//并且返回的指針或引用所指向的類型要具有繼承關系,即一個是另一個的父類或子類類型。//協變//父Person 、子Student類虛函數返回值類型是父子關系指針(即A*、B*),則滿足協變//要求,則父、子類虛函數構成重寫關系,滿足多態條件。//virtual B* BuyTicket()//協變//父Person 、子Student類虛函數返回值類型是父子關系指針(即Person*、Student*),//則滿足協變要求,則父、子類虛函數構成重寫關系,滿足多態條件。virtual Student* BuyTicket(){ cout << "買票-半價" << endl;return nullptr;}
};void Func(Person* p)
{//1、不滿足多態 -- 看調用者的類型,調用這個類型的成員函數//2、滿足多態 -- 看指向的對象的類型,調用這個類型的成員函數//調用傳入指針p所指向對象的BuyTicket函數,實現多態調用p->BuyTicket(); //釋放指針p指向的動態分配的對象,防止內存泄漏delete p;
}int main()
{//傳入動態創建的Person對象地址給Func函數,調用Person類的BuyTicket函數Func(new Person);//傳入動態創建的Student對象地址給Func函數,調用Student類的BuyTicket函數Func(new Student);return 0;
}
(2)析構函數的重寫(基類與派生類析構函數的名字不同)
解析:如果基類的析構函數為虛函數,此時派生類析構函數只要定義,無論是否加virtual關鍵字,
都與基類的析構函數構成重寫,雖然基類與派生類析構函數名字不同。雖然函數名不相同,
看起來違背了重寫的規則,其實不然,這里可以理解為編譯器對析構函數的名稱做了特殊處
理,編譯后析構函數的名稱統一處理成destructor。
代碼:
2.3.多態的實現
1.滿足多態的情況
當滿足多態條件時,調用類成員函數會依據調用者(父類指針或引用)指向的對象類型,調用該對象類型對應的成員函數。具體有以下兩種類型:
(1)類型 1:在子、父類中,同類成員函數均使用virtual
關鍵字聲明為虛函數。
解析:若不滿足多態條件,使用父類指針或引用調用類成員函數時,會按照指針或引用本身的類型來調用,而非依據其所指向對象的類型。例如,若父類指針指向子類對象,但相關函數不滿足多態條件,調用函數時將調用父類的函數版本,而非子類重寫后的版本。
#include <iostream>
using namespace std;//多態的條件:
//1、虛函數的重寫 -- 三同(函數名、參數、返回值)
//2、父類指針或者引用去調用虛函數//父類
class Person
{
public://虛函數virtual void BuyTicket(){cout << "買票-全價" << endl;}
};//子類
class Student : public Person
{
public://虛函數//子類和父類的BuyTicket函數滿足“三同”(函數名、參數、返回值),構成重寫關系,//即子類的BuyTicket函數是對父類BuyTicket函數的重寫。virtual void BuyTicket(){cout << "買票-半價" << endl;}
};//定義函數Func,接受Person類的引用作為參數
void Func(Person& p) //這里的p是父類Person的引用,作為調用者
{//多態的調用規則://1、不滿足多態時 -- 依據調用者(即p)本身的類型,調用該類型的成員函數//2、滿足多態時 -- 根據調用者(p)指向的對象的類型,調用對應類型的成員函數p.BuyTicket();//由于子、父類的BuyTicket函數滿足多態條件,則p.BuyTicket()的調用結果取決于調用者p實際指向的對象類型://- 當Func函數的實參傳入父類對象(如下面main函數中的Func(ps) )時,p指向父類對象,// 此時p.BuyTicket()調用的是父類Person的BuyTicket成員函數,輸出“買票-全價”。//- 當Func函數的實參傳入子類對象(如下面main函數中的Func(st) )時,p指向子類對象,// 此時p.BuyTicket()調用的是子類Student的BuyTicket成員函數,輸出“買票-半價”。
}int main()
{Person ps; //創建父類Person對象psStudent st; //創建子類Student對象stFunc(ps); //將父類對象ps作為實參傳給Func函數,調用父類的BuyTicket函數Func(st); //將子類對象st作為實參傳給Func函數,調用子類的BuyTicket函數return 0;
}
(2)類型 2:僅父類的同類成員函數使用virtual
關鍵字聲明為虛函數,默認子類三同(函數名、參數列表和返回值類型均相同,返回值存在協變特殊情況)成員函數與父類函數構成重寫關系。
①結論:當父類成員函數聲明為虛函數,子類存在 “三同”(函數名、參數列表、返回值類型,返回值在協變情況下可不同)成員函數時,即便子類函數未顯式使用virtual
關鍵字,也構成重寫關系并能實現多態。
- 原因解析:在繼承體系中,子類繼承父類的函數聲明。重寫時,子類針對從父類繼承的函數聲明,僅重新編寫函數體。所以只要父類 “三同” 成員函數為虛函數,子類相應函數即便未顯式聲明
virtual
,也繼承虛函數屬性。
②注意事項:
- 在 C++ 中,重寫關系僅存在于虛函數間。只有子類與父類成員函數滿足函數名、返回值類型(含協變情況)、參數列表都相同,且父類函數為虛函數時,才構成重寫。普通成員函數即便滿足 “三同”,也不構成重寫。
- 重寫體現接口繼承。即子類重寫父類虛函數時,繼承函數名、參數列表及返回值類型(協變情況允許特定調整),只需重新實現函數體邏輯。
- 重寫基類虛函數時,派生類虛函數雖可不加
virtual
關鍵字構成重寫(因繼承保持虛函數屬性),但不規范,不建議采用。
③案例1
②案例2:使用多態解決動態子類對象生命周期結束后,可以正常調用子類析構函數釋放動態子類對象成員變量占用的資源。(注意:案例2的詳細介紹參考2.4.多態在析構函數中的應用)
#include <iostream>
using namespace std;//多態的條件:
//1、虛函數的重寫 -- 三同(函數名、參數、返回值)
//2、父類指針或者引用去調用虛函數
class Person
{
public://虛函數BuyTicket,用于表示買票全價的操作virtual void BuyTicket(){cout << "買票-全價" << endl;}//虛函數析構函數~Person,用于釋放Person對象相關資源virtual ~Person(){cout << "~Person()" << endl;}//若父類析構函數未聲明為虛函數,子、父類析構函數不構成重寫關系,不滿足多態條件。//當父類指針p指向動態子類對象時,執行delete p釋放資源,因為不滿足多態時,//調用者指針p本身的類型決定調用的析構函數類型,所以delete p不會調用子類對象析構函數,//而是調用父類類型的析構函數,釋放子類對象中父類部分占用的資源。//若動態子類對象特有成員存在占用資源情況,會存在內存泄漏風險。
};class Student : public Person
{
public://子類的BuyTicket函數雖未顯式使用virtual關鍵字,但默認繼承父類虛函數屬性,//子、父類的BuyTicket函數構成重寫關系,滿足多態條件。void BuyTicket(){cout << "買票-半價" << endl;}//子類的析構函數~Student()雖未顯式使用virtual關鍵字,但默認繼承父類析構函數的虛函數屬性,//子、父類析構函數構成重寫關系,滿足多態條件。~Student(){cout << "~Student()" << endl;}
};void Func(Person* p)//調用者指針p類型是父類類型
{p->BuyTicket();//1、不滿足多態 -- 看調用者的類型,調用這個類型的成員函數 //2、滿足多態 -- 看調用者指向的對象的類型,調用這個對象類型的成員函數delete p;//3.若父類析構函數聲明為虛函數,滿足多態,當實參傳動態父類對象,//父類指針Person* p指向父類對象,delete p會調用父類對象的析構函數p->~Person();//若父類析構函數未聲明為虛函數,delete p直接調用父類析構函數//4.若父類析構函數聲明為虛函數,滿足多態,當實參傳動態子類對象,//父類指針Person* p指向子類對象,delete p會調用子類對象的析構函數p->~Student();//若父類析構函數未聲明為虛函數,delete p只會調用父類析構函數,可能導致內存泄漏
}int main()
{Func(new Person);//傳動態父類對象Func(new Student);//傳動態子類對象return 0;
}
2.不滿足多態的情況
當不滿足多態條件時,調用類成員函數依據調用者(父類指針、引用或對象)本身的類型,調用該類型的成員函數。具體如下:
(1)類型 1:若子、父類成員函數滿足 “三同”,但父類 “三同” 成員函數不是虛函數,則子、父類 “三同” 成員函數不構成重寫關系,無法實現多態。
解析:在不滿足多態的情況下,當使用父類指針或引用去調用成員函數時,調用的是依據指針或引用本身類型所對應的函數版本,而不是根據它們所指向對象的類型來調用。例如,當父類指針指向子類對象時,由于父類的 “三同” 成員函數不是虛函數,所以依然會調用父類中該成員函數的版本,而不會調用子類中具有相同函數名、參數類型和返回值類型的函數版本,因此無法體現多態性。
(2)類型 2:即使子、父類 “三同” 成員函數聲明為虛函數且構成重寫關系,若通過父類對象調用虛函數,也無法實現多態。
解析:多態依賴父類指針或引用,在運行時依據實際指向對象類型動態綁定函數。而父類對象調用函數時,編譯器在編譯階段就確定調用父類自身函數版本,不會根據對象實際類型動態選擇。
2.4.多態在析構函數中的應用
在 C++ 的繼承體系中,當涉及子類和父類的賦值轉換,尤其是動態子類對象地址賦值給父類指針時,析構函數的調用會出現特定問題。多態機制在解決這類問題上發揮著關鍵作用。
(1)正常繼承場景下析構函數調用情況
在 C++ 繼承體系里,當子類對象生命周期結束(如出作用域) ,其析構過程有序進行。先是子類析構函數被調用,用以釋放子類特有的成員變量所占用資源,像子類中動態分配的內存、打開的文件等。隨后,編譯器自動調用父類析構函數,釋放子類對象從父類繼承而來成員占用的資源。這確保了對象創建時獲取的資源能完整、正確地釋放。
(2)無多態時的內存泄漏隱患
問題描述:在繼承體系中,若未使用多態機制,當動態分配的子類對象通過父類指針來管理時,會出現資源釋放不完全的問題。具體表現為,當使用delete
釋放父類指針(指向子類對象)時,僅會調用父類的析構函數,而子類的析構函數不會被調用。這就導致子類對象中特有的成員所占用的資源無法得到釋放,最終引發內存泄漏 。
原理剖析:在 C++ 中,delete
操作符調用析構函數的行為在沒有多態的情況下是基于指針的靜態類型決定的。編譯器在編譯階段就確定了析構函數的調用,它依據的是指針聲明時的類型,而非指針實際指向對象的類型。因此,當父類指針指向子類對象時,普通的delete
操作只會觸發父類析構函數的調用,這是導致上述問題的根本原因。
#include <iostream>
using namespace std;//定義父類Person
class Person
{
public://Person類的析構函數,用于釋放Person類對象占用的資源~Person(){cout << "~Person()" << endl;}
};//定義子類Student,公有繼承自Person類
class Student : public Person
{
public://Student類的析構函數,用于釋放Student類對象特有的資源,以及繼承自父類的資源~Student(){cout << "~Student()" << endl;}
};int main()
{//創建一個Person類對象,并用父類指針p1指向它。Person* p1 = new Person;//創建一個Student類對象,并用父類指針p2指向它。這里涉及到對象切片,父類指針只能訪問子類對象中從父類繼承的部分。Person* p2 = new Student;//釋放p1指向的內存。在沒有多態機制時,delete根據指針類型調用析構函數,p1是Person*類型,所以調用Person類的析構函數。delete p1;//釋放p2指向的內存。在沒有多態機制時,delete同樣根據指針類型調用析構函數,p2是Person*類型,只會調用Person類的析構函數。//若Student類存在特有的資源(如動態分配的內存)需要釋放,這種情況下就會導致內存泄漏,因為沒有調用Student類的析構函數。delete p2;//代碼解析://在C++中,對于析構函數,在編譯層面會進行一些處理,即在繼承體系下,子類和父類的析構函數名稱會統一處理為destructor。// 1. delete p1; 等價于先調用析構函數 p1->~Person() ,然后調用 operator delete(p1) 釋放內存。// 1.1 在沒有多態的情況下,指針的類型決定了調用析構函數的類型。因為p1是父類Person類型的指針,所以p1->~Person() 調用的是父類的析構函數。// 2. delete p2; 等價于先調用析構函數 p2->~Person() ,然后調用 operator delete(p2) 釋放內存。// 2.1 在沒有多態的情況下,同樣因為p2是父類Person類型的指針,p2->~Person() 調用的是父類的析構函數,而不是子類Student的析構函數。// 2.2 問題分析:在常規情況下,delete操作符會根據指針的靜態類型來調用相應的析構函數。當父類指針指向父類對象時,這種方式沒有問題,// 因為調用的就是父類的析構函數來釋放資源。但當父類指針指向子類對象時,如果僅調用父類析構函數,而子類對象又有自己特有的資源需要釋放(例如子類中動態分配的內存等),// 就會導致內存泄漏。// 我們期望的是,無論指針本身類型是什么,當指針指向父類對象時,調用父類析構函數;當指針指向子類對象時,調用子類析構函數。// 為實現這個期望,可以利用多態特性。在繼承中,析構函數雖然名字不同(~Person() 和 ~Student() ),但在滿足一定條件下可以實現多態。// 因為析構函數沒有返回值和參數,若將父類析構函數聲明為虛函數,子類析構函數會自動成為虛函數(即使沒有顯式加上virtual關鍵字),這就滿足了虛函數重寫的條件之一。// 同時,通過父類指針指向子類對象并調用析構函數,滿足了多態的另一個條件(父類指針/引用調用虛函數)。// 當滿足多態的兩個必要條件后,delete父類指針指向子類對象時,會調用子類的析構函數;delete父類指針指向父類對象時,會調用父類的析構函數。// 總的來說,在沒有多態的情況下,delete操作按指針的靜態類型調用析構函數;當子、父類析構函數實現成多態后,delete操作按指針實際指向的對象類型調用析構函數。
}
(3)使用多態解決上述問題
多態解決問題的原理:多態實現要求父類析構函數聲明為虛函數,而子類析構函數會自動重寫(覆蓋)父類虛析構函數。這樣在運行時,系統會根據指針實際指向對象的類型(而非指針靜態類型)決定調用哪個析構函數。當父類指針指向子類對象,并且使用delete
釋放該指針時,首先會調用子類的析構函數,釋放子類特有的資源,然后再調用父類的析構函數,釋放從父類繼承的資源,從而確保資源的完全釋放,避免內存泄漏。
可以看到,當ptr2
(父類指針指向子類對象)被釋放時,先調用了子類的析構函數~Student()
?,再調用了父類的析構函數~Person()
?,成功解決了資源釋放不完全的問題。
2.5.多態構成條件的題目分析
(1)多態構成條件概述
多態是 C++ 中一項重要特性,其構成需滿足特定條件:
- 虛函數重寫:子、父類需滿足 “三同” 規則,即函數名、參數類型、返回值類型相同(特殊情況如協變返回值等除外 ),且成員函數需聲明為虛函數。在參數方面,僅需參數類型一致,參數名與缺省值可以不同。這一規則確保了子類函數能夠正確覆蓋父類虛函數,為多態的實現奠定基礎。
- 父類指針或引用調用:通過父類指針或引用去調用子類重寫的虛函數,才能觸發多態行為。這是因為在運行時,程序依據指針或引用所指向對象的實際類型(而非指針本身的靜態類型 )來決定調用哪個函數版本,從而實現動態綁定。
(2)注意事項
- 滿足多態條件時,子類虛函數會繼承父類虛函數的接口規范,并重寫其函數體實現邏輯。當父類指針或引用指向子類對象,并使用該指針/引用調用虛函數時,表面上是使用父類虛函數的接口(函數聲明:包括函數名、參數類型、缺省值、返回值類型等 ),但實際執行的是子類虛函數的函數體。這是因為在繼承關系中,子類重寫的虛函數替換了父類虛函數在虛函數表中的位置,運行時根據對象實際類型從虛函數表中找到并調用子類虛函數。
- 當使用子類對象、子類指針或子類引用單獨調用子類虛函數時,調用的是子類虛函數自身的接口(函數聲明),并且執行的也是子類虛函數自身的函數體。
(3)案例
①代碼分析(構成多態)
#include <iostream>
using namespace std;//注意:三同規則里,僅需參數類型一致,而參數名、缺省值可不同。// 多態的條件:
// 1、虛函數的重寫 -- 三同(函數名、參數、返回值)
// -- 例外(協變):返回值可以不同,必須是父子關系指針或者引用
// -- 例外:子類虛函數可以不加virtual
// 2、父類指針或者引用去調用虛函數//父類
class A
{
public://虛函數 func,這里的缺省參數 val = 1。//實際上,編譯器會將其處理為 virtual void func(A* this, int val = 1)virtual void func(int val = 1){cout << "A->" << val << endl;}//虛函數 test,編譯器會將其處理為 virtual void test(A* this)//指向子類對象的實參子類指針B* p,形參父類指針A* this。virtual void test(){//調用規則:// 1、不滿足多態 -- 看調用者的類型,調用這個類型的成員函數// 2、滿足多態 -- 看指向的對象的類型,調用這個類型的成員函數//代碼解析://1.p->test() 調用時,test() 傳參存在子類指針賦值給父類指針 this 的情況://指向子類對象的實參子類指針B* p,形參父類指針A*this指向子類對象中父類B部分,總的來說,形參父類指針A*this向子類對象。func();//2. 這里調用 func() 等價于父類指針 A* this->func(),且該父類指針 A* this 指向子類對象。//由于子類 B 和父類 A 的虛函數 func() 構成重寫關系,并且是通過父類指針 A* this 調用虛函數,滿足多態條件。//因此,func() <=> A* this->func() 表面上調用的是子類虛函數 void B::func(int val = 0) 的函數接口,//但實際上,在多態調用時,缺省參數使用的是父類虛函數的缺省參數。//也就是說,最終調用的是父類虛函數的接口(包括函數名、參數類型、缺省值、返回值類型等)void A::func(int val = 1),//執行的是子類虛函數的函數體(即實現邏輯)。所以最終整個程序的打印結果是 B->1。//原因:子類虛函數會繼承父類虛函數的接口規范,并重寫其函數體實現邏輯。當父類指針或引用指向子類對象,并使用該指針/引用調用虛函數時,//表面上是使用父類虛函數的接口(函數聲明:包括函數名、參數類型、缺省值、返回值類型等 ),但實際執行的是子類虛函數的函數體。}
};//子類
class B : public A
{
public://子類成員函數 func() 默認繼承父類虛函數屬性。//由于子類和父類的虛函數 func() 構成重寫關系,子類虛函數 func() 繼承了父類虛函數 func() 的函數聲明 virtual void func(int val = 1),//子類虛函數 func() 只是重寫了父類虛函數 virtual void func(int val = 1) 的函數體實現邏輯。//缺省參數的值在多態調用時使用父類的缺省值,只有在使用非多態方式(如子類對象直接調用)時,才使用子類的缺省值。//總結:當使用父類引用/指針調用虛函數實現多態時,調用的是子類重寫的函數體,但缺省參數使用父類虛函數的缺省參數。//當不使用多態方式調用時,子類函數使用自己的缺省參數。//成員函數func(虛函數),編譯器會將其處理為 virtual void func(B* this, int val = 0)void func(int val = 0) {cout << "B->" << val << endl;}
};int main(int argc, char* argv[])
{//子類指針B* p = new B;//注:p->test()傳參調用過程中涉及子類指針賦值給父類指針情況(即子類對象和父類對象賦值轉換過程)p->test();//調用 p->test() 等價于 p->test(p),實參是子類 B* p 指針,該指針 p 指向子類對象。//在 test 函數內部調用 func 函數時,由于滿足多態條件,會調用子類的 func 函數體,但使用父類的缺省參數。return 0;
}//整個程序最終打印結果:B->1
②代碼變形(不構成多態)
解析:最終輸出B->0
?,這是因為在test
函數中調用func
函數時,由于不滿足多態條件,編譯器按照普通的函數調用規則,根據調用者(這里是子類B
)的類型來確定調用的函數,所以調用的是子類B
自己定義的 B::func
函數,其缺省參數為0
?,因此輸出B->0
?。
接口繼承和實現繼承
-
普通函數繼承:在繼承體系中,普通函數繼承意味著父類將自身成員函數的函數接口(函數聲明)與函數體實現邏輯,完整地傳遞給子類。換言之,子類獲取了父類成員函數的全部內容,如同將父類的成員函數原樣復制到子類之中。
-
多態下的接口繼承:在多態機制下,接口繼承指父類僅將其虛函數的函數接口(函數聲明)傳遞給子類。子類依據自身需求,對繼承而來的父類虛函數進行重寫,即重新實現函數體的邏輯。通過這種接口繼承與重寫的方式,不同的子類能夠基于父類虛函數所提供的相同接口,根據各自的特定需求和業務邏輯,給出差異化的行為實現,進而實現運行時多態,使得程序在運行時可以依據實際的對象類型來調用相應子類的函數,增強了代碼的靈活性和可擴展性。
-
總結:普通函數的繼承是一種實現繼承,派生類繼承了基類函數,可以使用函數,繼承的是函數的實現。虛函數的繼承是一種接口繼承,派生類繼承的是基類虛函數的接口,目的是為了重寫,達成多態,繼承的是接口。所以如果不實現多態,不要把函數定義成虛函數。
C++11 override 和 final
1.final:修飾虛函數,表示該虛函數不能再被重寫
(1)final功能:修飾虛函數時,表示該虛函數不能再被重寫;修飾類時,該類無法被繼承,這樣的類被稱為最終類。
(2)注意事項
- 當
final
修飾父類虛函數時,該虛函數仍然可以被子類繼承,但是子類不能對其進行重寫。 - 虛函數重寫是實現多態的必要條件之一。借助多態,我們能通過父類指針或引用,在運行時根據對象的實際類型,調用相應子類的函數,極大地增強了代碼的靈活性與擴展性。由于多態依賴于虛函數重寫,在實際開發場景中,為充分發揮多態優勢,滿足多樣化的業務需求,很少限制父類虛函數被子類重寫。
(3)案例
①不能被繼承的類
#include <iostream>
using namespace std;class A final
{
public:static A CreateObj() {return A();}private:A() {}
};//以下代碼會導致編譯錯誤,因為A類使用了final修飾,不能被繼承
//class B : public A {};int main()
{A::CreateObj();return 0;
}
②不能被重寫的虛函數
#include <iostream>
using namespace std;class Car
{
public:virtual void Drive() final{}
};class Benz :public Car
{
public://錯誤示例,試圖重寫被final修飾的虛函數//virtual void Drive() //{// cout << "Benz-舒適" << endl;//}
};int main()
{return 0;
}
2. override: 檢查派生類虛函數是否重寫了基類某個虛函數,如果沒有重寫就會編譯報錯。
(1)解析
override
?是 C++11 引入的一個關鍵字,主要應用于虛函數重寫的場景。其功能是顯式地指明派生類中的成員函數是對基類虛函數的重寫。
在使用時,將?override
?關鍵字添加在派生類成員函數聲明的后面。這樣做的目的是讓編譯器對該成員函數進行嚴格檢查,判斷其是否滿足對基類虛函數重寫的條件。這些條件包括函數名、參數列表和返回值類型(存在協變的特殊情況 )等方面與基類虛函數一致。
如果派生類的成員函數聲明后帶有?override
?關鍵字,但卻不滿足對基類虛函數重寫的條件,那么在編譯階段,編譯器就會報錯。通過這種方式,override
?關鍵字可以有效地避免因疏忽或錯誤導致的重寫不準確問題,提高代碼的可靠性和健壯性。同時,在閱讀和維護代碼時,override
?關鍵字也能讓開發者更清晰地了解成員函數之間的重寫關系,增強代碼的可讀性。
(2)注意事項
- 與不加?
override
?情況對比:如果派生類函數重寫基類虛函數但不使用?override
?關鍵字,雖然也能構成重寫(滿足重寫條件時 ),但缺乏編譯器的嚴格檢查。例如,若后續修改基類虛函數,導致派生類函數不再滿足重寫條件,編譯器不會報錯,可能在運行時出現意外行為。而使用?override
?關鍵字能在編譯階段就發現這類問題。 - 與?
final
?關鍵字對比:final
?關鍵字用于阻止虛函數被進一步重寫,即標記為?final
?的基類虛函數在派生類中不能再被重寫;而?override
?是用于確保派生類函數是對基類虛函數的正確重寫。
重載、重寫(覆蓋)、隱藏(重定義)的對比
virtual 關鍵字對同名函數關系的影響?
- 情況 1:僅父類 “三同” 成員函數為虛函數:若只有父類的 “三同” 成員函數使用?
virtual
?關鍵字聲明為虛函數,但子類的 “三同” 成員函數未使用?virtual
?關鍵字聲明,它會繼承父類該 “三同” 虛函數的虛函數屬性。在此情況下,子類和父類的 “三同” 成員函數仍然構成重寫(覆蓋)關系,而非隱藏(重定義)關系。此時,當通過父類指針或引用調用該函數時,會在運行時根據指向對象的實際類型決定調用子類還是父類的函數。 - 情況 2:僅子類 “三同” 成員函數為虛函數:若父類的 “三同” 成員函數未使用?
virtual
?關鍵字聲明為虛函數,而只有子類的 “三同” 成員函數使用?virtual
?關鍵字聲明為虛函數,那么子類和父類的這對 “三同” 成員函數僅構成隱藏(重定義)關系,并不會構成重寫(覆蓋)關系。這是因為重寫依賴于父類虛函數機制,父類函數未被聲明為虛函數,就無法實現運行時根據對象實際類型調用函數,只能在編譯時根據調用者的類型確定調用的函數。
抽象類
(1)抽象類概念:在 C++ 中,在虛函數聲明后加上?= 0
?,該函數就成為純虛函數 ,包含純虛函數的類被稱為抽象類(也叫接口類 )。抽象類不能實例化出對象。
(2)純虛函數定義格式
抽象類是一種特殊的類,用于表示面向對象編程中的抽象概念。抽象類中至少包含一個純虛函數,純虛函數在類中僅作聲明,無具體函數體實現,其聲明格式通常為?virtual 返回值類型 函數名(參數列表) = 0;
?。注意:純虛函數是一種特殊的虛函數。
//抽象類(注:不能實例化出對象)
//注意:一個類型在現實中沒有對應的實體,我們就可以把這個類型定義為抽象類
class Car
{
public://純虛函數 -- 抽象類 -- 不能實例化出對象virtual void Drive() = 0;//注意:純虛函數不用寫實現,只需寫函數聲明即可。
};
注意事項
- 純虛函數與抽象類關系:在虛函數后面寫上?
= 0
?,該函數即為純虛函數,包含純虛函數的類是抽象類,抽象類不能實例化對象。派生類繼承抽象類后,若不重寫純虛函數,派生類也會成為抽象類,無法實例化;只有重寫所有純虛函數,派生類才能實例化。純虛函數規范了派生類必須進行重寫,體現了接口繼承特性。 - 聲明與實現:在類中聲明純虛函數時,無需提供函數體實現,僅在虛函數聲明后加?
= 0
?表明其為純虛函數。若要實現純虛函數的具體功能,需在派生類中對其進行重寫,給出具體的函數體實現。 - 純虛函數的意義:使用純虛函數定義抽象類,當一個類在現實中無對應實體,且不想讓其實例化對象(僅為被復用而定義 )時,可讓該類包含純虛函數成為抽象類。
- 抽象類應用場景:當一個類型在現實中沒有對應的實體時,可將其定義為抽象類。例如,“Person”(人)可作為抽象概念,不同的 “Student”(學生)、“Worker”(工人)等具體類型可繼承自 “Person”;“Fruit”(水果)也是抽象類,“Apple”(蘋果)、“Banana”(香蕉)等具體水果類可繼承自 “Fruit” 。
(3)抽象類的特點:抽象類無法實例化
①子類未重寫父類純虛函數的情況:若子類沒有重寫父類的純虛函數,會繼承父類的純虛函數,導致該子類同樣成為抽象類。由于抽象類不能創建對象實例,因此該子類也無法實例化。
②子類重寫父類純虛函數的情況:當子類重寫父類的純虛函數時,子類重寫的虛函數就不再是純虛函數。這意味著子類不再包含純虛函數,不再是抽象類,因而可以實例化出對象 。
(4)純虛函數的意義
在 C++ 中,純虛函數的重要意義在于強制子類對其進行重寫。當一個類包含至少一個純虛函數時,這個類就被定義為抽象類,抽象類無法實例化對象。只有當子類重寫了父類的所有純虛函數后,子類才不再是抽象類,從而能夠實例化對象。從設計角度來看,實例化對象是使用類的常見方式,因此實現純虛函數并讓子類可以實例化對象,才能真正發揮子類類型的作用。
(5)override 與純虛函數的區別
- override 關鍵字:
override
?是在子類中使用的關鍵字 ,主要作用是檢查子類是否正確重寫了父類的虛函數。當在子類的函數聲明后使用?override
?時,編譯器會進行檢查,如果該函數沒有正確重寫父類的虛函數(例如函數名、參數列表、返回類型等不匹配 ),編譯器會報錯。override
?本身并不影響函數的功能實現,它只是一種安全機制,幫助開發者避免因疏忽導致的錯誤。 - 純虛函數:純虛函數是在父類中使用的概念,通過在虛函數聲明后加上?
= 0
?來定義。純虛函數沒有具體的實現,它的存在使得父類成為抽象類。純虛函數的目的是間接強制子類重寫這些函數,因為只有子類重寫了所有純虛函數,子類才能實例化對象。當然,子類也可以選擇不重寫父類的純虛函數,但這樣一來,子類也會成為抽象類,無法實例化對象。 - 總結:
override
?關鍵字主要用于檢查子類對父類虛函數的重寫情況,提高代碼的安全性和可維護性;而純虛函數則是從設計層面強制子類實現特定的功能,它以一種間接的方式強制子類重寫父類純虛函數,以保證子類可以實例化并使用。