高唐網(wǎng)站建設(shè)電子商務(wù)網(wǎng)站建設(shè)的步驟
文章目錄
- 1. 多態(tài)的概念
- 2. 多態(tài)的定義及實現(xiàn)
- 🍑 多態(tài)的構(gòu)成條件
- 🍑 虛函數(shù)
- 🍑 虛函數(shù)的重寫
- 🍑 虛函數(shù)重寫的兩個例外
- 🍑 C++11的override 和 final
- 🍑 重載、覆蓋(重寫)、隱藏(重定義)的對比
- 3. 抽象類
- 🍑 接口繼承和實現(xiàn)繼承
- 4. 多態(tài)的原理
- 🍑 虛函數(shù)表
- 🍑 多態(tài)的原理
- 🍑 動態(tài)綁定與靜態(tài)綁定
- 5. 單繼承和多繼承關(guān)系的虛函數(shù)表
- 🍑 單繼承中的虛函數(shù)表
- 🍑 多繼承中的虛函數(shù)表
- 🍑 菱形繼承和菱形虛擬繼承
- 6. 繼承和多態(tài)常見的面試問題
- 🍑 概念查考
- 🍑 問答題
1. 多態(tài)的概念
多態(tài)的概念:通俗來說,就是多種形態(tài),具體點就是去完成某個行為,當不同的對象去完成時會產(chǎn)生出不同的狀態(tài)。
(1)示例一
比如春節(jié)回家買票這個行為,當普通人買票時,是全價買票;學(xué)生買票時,是半價買票;軍人買票時是優(yōu)先買票。
不同身份的人去買票,所產(chǎn)生的行為是不同的,這就是所謂的多態(tài)。
(2)示例二
為了爭奪在線支付市場,支付寶年底經(jīng)常會做誘人的掃紅包 - 支付 - 給獎勵金的活動。
那么大家想想為什么有人掃的紅包又大又新鮮 8 塊、10 塊…,而有人掃的紅包都是 1 毛,5 毛…。其實這背后也是一個多態(tài)行為。
支付寶首先會分析你的賬戶數(shù)據(jù),比如你是新用戶、比如你沒有經(jīng)常支付寶支付等等,那么你需要被鼓勵使用支付寶,那么就你 掃碼金額 = random()%99
;
比如你經(jīng)常使用支付寶支付或者支付寶賬戶中常年沒錢,那么就不需要太鼓勵你去使用支付寶,那么就你 掃碼金額 = random()%1
;
總結(jié)一下:同樣是掃碼動作,不同的用戶掃得到的不一樣的紅包,這也是一種多態(tài)行為。
2. 多態(tài)的定義及實現(xiàn)
🍑 多態(tài)的構(gòu)成條件
多態(tài)是在不同繼承關(guān)系的類對象,去調(diào)用同一函數(shù),產(chǎn)生了不同的行為。
比如 Student
繼承了 Person
。Person
對象買票全價,Student
對象買票半價。
那么在繼承中要構(gòu)成多態(tài)還有兩個條件:
(1)必須通過基類的指針或者引用調(diào)用虛函數(shù)
(2)被調(diào)用的函數(shù)必須是虛函數(shù),且派生類必須對基類的虛函數(shù)進行重寫(重寫有三同:函數(shù)名、參數(shù)、返回值)
🍑 虛函數(shù)
虛函數(shù):即被 virtual
修飾的類成員函數(shù)稱為虛函數(shù)。
class Person {
public:virtual void BuyTicket() { cout << "買票-全價" << endl; }
};
🍑 虛函數(shù)的重寫
虛函數(shù)的重寫(覆蓋):派生類中有一個跟基類完全相同的虛函數(shù)(即派生類虛函數(shù)與基類虛函數(shù)的返回值類型、函數(shù)名字、參數(shù)列表完全相同),稱子類的虛函數(shù)重寫了基類的虛函數(shù)。
下面代碼中,Person
是基類,Student
和 Soldier
是派生類,它們分別繼承了 Person
類,并且重寫了基類的虛函數(shù)
// 基類
class Person {
public:Person(const char* name):_name(name){}// 虛函數(shù)virtual void BuyTicket() { cout << _name << "Person:買票-全價 100¥" << endl; }protected:string _name;
};// 派生類 - 學(xué)生
class Student : public Person {
public:Student(const char* name):Person(name){}// 虛函數(shù) + 函數(shù)名/參數(shù)/返回值 ==> 重寫/覆蓋virtual void BuyTicket() { cout << _name << " Student:買票-半價 50 ¥" << endl; }
};// 派生類 - 軍人
class Soldier : public Person {
public:Soldier(const char* name):Person(name){}// 虛函數(shù) + 函數(shù)名/參數(shù)/返回值 ==> 重寫/覆蓋virtual void BuyTicket() { cout << _name << " Soldier:優(yōu)先買預(yù)留票-88折 88 ¥" << endl; }
};
思考一下:三個類里面都有 BuyTicket
,那么會不會構(gòu)成隱藏呢?當然不會!
我們這里是:虛函數(shù)+相同的函數(shù)名、相同的參數(shù)、相同的返回值,那么就構(gòu)成覆蓋或者重寫。意思就是子類里面的覆蓋了父類里面的相同函數(shù)!
如果我要去調(diào)用基類的虛函數(shù)怎么辦呢?有兩種方法!
(1)父類指針去調(diào)用虛函數(shù)
// 父類指針去調(diào)用虛函數(shù)
void Pay(Person* ptr)
{ptr->BuyTicket();
}int main()
{int option = 0;cout << "=======================================" << endl;do {cout << "請選擇身份:";cout << "1、普通人 2、學(xué)生 3、軍人" << endl;cin >> option;cout << "請輸入名字:";string name;cin >> name;switch (option){case 1:{Person p(name.c_str());Pay(&p); // 傳地址break;}case 2:{Student s(name.c_str());Pay(&s);break;}case 3:{Soldier s(name.c_str());Pay(&s);break;}default:cout << "輸入錯誤,請重新輸入" << endl;break;}cout << "=======================================" << endl;} while (option != -1);return 0;
}
我們運行以后可以看到,當你選擇不同的身份時,會去調(diào)用不同的買票函數(shù),產(chǎn)生的金額也是不一樣的,所以實現(xiàn)了函數(shù)調(diào)用的多種形態(tài)。
(1)父類引用去調(diào)用虛函數(shù)
// 父類引用去調(diào)用虛函數(shù)
void Pay(Person& ptr)
{ptr.BuyTicket();
}int main()
{int option = 0;cout << "=======================================" << endl;do {cout << "請選擇身份:";cout << "1、普通人 2、學(xué)生 3、軍人" << endl;cin >> option;cout << "請輸入名字:";string name;cin >> name;switch (option){case 1:{Person p(name.c_str());Pay(p); // 這里就不能傳地址了break;}case 2:{Student s(name.c_str());Pay(s);break;}case 3:{Soldier s(name.c_str());Pay(s);break;}default:cout << "輸入錯誤,請重新輸入" << endl;break;}cout << "=======================================" << endl;} while (option != -1);return 0;
}
當然,運行結(jié)果和上面也是一樣的:
注意:在重寫基類虛函數(shù)時,派生類的虛函數(shù)在不加 virtual
關(guān)鍵字時,雖然也可以構(gòu)成重寫,因為繼承后基類的虛函數(shù)被繼承下來了在派生類依舊保持虛函數(shù)屬性。但是該種寫法不是很規(guī)范,不建議這樣使用!
🍑 虛函數(shù)重寫的兩個例外
(1)協(xié)變(基類與派生類虛函數(shù)返回值類型不同)
派生類重寫基類虛函數(shù)時,與基類虛函數(shù)返回值類型不同。即基類虛函數(shù)返回基類對象的指針或者引用,派生類虛函數(shù)返回派生類對象的指針或者引用時,稱為協(xié)變。
下面代碼中,首先 A
是基類,B
繼承了 A
是派生類;同樣 Person
是基類,Student
繼承了 Person
是派生類。
在 Person
中的虛函數(shù) fun
的返回值類型是基類 A
對象的指針,在派生類 Student
當中的虛函數(shù) fun
的返回值類型是派生類 B
對象的指針。
那么此時是可以認為派生類 Student
的虛函數(shù)重寫了基類 Person
的虛函數(shù)。
// 基類
class A{};// 派生類
class B : public A {};// 基類
class Person {
public:virtual A* f() { cout << "virtual A* Person::f()" << endl;return new A; }
};// 派生類
class Student : public Person {
public:virtual B* f() {cout << "virtual B* Student::f()" << endl;return new B; }
};int main()
{Person p;Student s;Person* ptr = &p;ptr->f();ptr = &s;ptr->f();return 0;
}
我們運行可以看到,當 Person
指針指向的是基類對象時,調(diào)用的是基類的虛函數(shù);當 Person
指針指向的是派生類對象時,調(diào)用的是派生類的虛函數(shù)。
注意,虛函數(shù)重寫對返回值要求有一個例外:協(xié)變時,必須是父子關(guān)系指針或者引用。
也就是說返回值不管是指針還是引用,A
和 B
必須是父子關(guān)系!
還記得我們上面說的派生類的虛函數(shù)在不加 virtual
關(guān)鍵字時,也可以構(gòu)成重寫嗎?
// 基類
class A{};// 派生類
class B : public A {};// 基類
class Person {
public:virtual A* f() { cout << "virtual A* Person::f()" << endl;return new A; }
};// 派生類
class Student : public Person {
public:B* f() {cout << "virtual B* Student::f()" << endl;return new B; }
};int main()
{Person p;Student s;Person* ptr1 = &p;ptr1->f();Person* ptr2 = &s;ptr2->f();return 0;
}
此時,子類虛函數(shù)沒有寫 virtual
,f()
依舊時虛函數(shù),因為它先繼承了父類函數(shù)接口聲明,運行以后結(jié)果也是正確的:
注意:不推薦這種寫法,我們自己寫的時候子類虛函數(shù)也寫上 virtual
。
(2)析構(gòu)函數(shù)的重寫(基類與派生類析構(gòu)函數(shù)的名字不同)
如果基類的析構(gòu)函數(shù)為虛函數(shù),此時派生類析構(gòu)函數(shù)只要定義,無論是否加 virtual
關(guān)鍵字,都與基類的析構(gòu)函數(shù)構(gòu)成重寫,即使基類與派生類析構(gòu)函數(shù)名字不同。
下面代碼中,基類 Person
和 派生類 Student
都沒有加 virtual
,那么此時構(gòu)成的關(guān)系就是 隱藏關(guān)系(也叫重定義)
// 基類
class Person {
public:virtual ~Person() { cout << "~Person()" << endl; }
};// 派生類
class Student : public Person {
public:virtual ~Student() { cout << "~Student()" << endl; }
};int main()
{Person p;Student s;return 0;
}
運行以后可以看到,先調(diào)用派生類 Student
對象自己的析構(gòu)函數(shù),然后 Student
會自動調(diào)用基類 Person
的析構(gòu)函數(shù)清理基類成員,最后基類 Person
對象再調(diào)用自己的析構(gòu)函數(shù)。
如果基類 Person
析構(gòu)函數(shù)加了 virtual
,那么此時關(guān)系就變了,從 重定義(隱藏)關(guān)系 變成了 重寫(覆蓋)關(guān)系。
// 基類
class Person {
public:virtual ~Person() { cout << "~Person()" << endl; }
};// 派生類
class Student : public Person {
public:~Student(){ cout << "~Student()" << endl; }
};
雖然,它們打印的結(jié)果還是一樣滴。
雖然函數(shù)名不相同,看起來違背了重寫的規(guī)則,其實不然,這里可以理解為編譯器對析構(gòu)函數(shù)的名稱做了特殊處理,編譯后析構(gòu)函數(shù)的名稱統(tǒng)一處理成 destructor
。
那么場景下才需要子類的析構(gòu)函數(shù)也寫上 virtual
呢?
假設(shè)有這么一個場景:分別 new
一個父類對象和子類對象,并均用父類指針指向它們,然后分別用 delete
調(diào)用析構(gòu)函數(shù)并釋放對象空間。
// 基類
class Person {
public:~Person() { cout << "~Person()" << endl; }
};// 派生類
class Student : public Person {
public:~Student(){ cout << "~Student()" << endl; }
};int main()
{Person* p1 = new Person;Person* p2 = new Student;delete p1;delete p2;return 0;
}
如果不加 virtual
,就可能會導(dǎo)致內(nèi)存泄漏,因為此時 delete p1
和 delete p2
都是調(diào)用的父類的析構(gòu)函數(shù):
只有派生類 Student
的析構(gòu)函數(shù)重寫了 Person
的析構(gòu)函數(shù),下面的 delete
對象調(diào)用析構(gòu)函數(shù),才能構(gòu)成多態(tài),才能保證 p1
和 p2
指向的對象正確的調(diào)用析構(gòu)函數(shù)。
// 基類
class Person {
public:virtual ~Person() { cout << "~Person()" << endl; }
};// 派生類
class Student : public Person {
public:virtual ~Student(){ cout << "~Student()" << endl; }
};int main()
{Person* p1 = new Person;Person* p2 = new Student;delete p1;delete p2;return 0;
}
可以看到,p1
調(diào)用父類的析構(gòu)函數(shù),p2
調(diào)用子類的析構(gòu)函數(shù),是一種多態(tài)行為。
🍑 C++11的override 和 final
從上面示例中可以看出,C++ 對函數(shù)重寫的要求比較嚴格,但是有些情況下由于疏忽,可能會導(dǎo)致函數(shù)名字母次序?qū)懛炊鵁o法構(gòu)成重載,而這種錯誤在編譯期間是不會報出的,只有在程序運行時沒有得到預(yù)期結(jié)果再來進行 debug
會得不償失,因此,C++11 提供了 override
和 final
兩個關(guān)鍵字,可以幫助用戶檢測是否重寫。
(1) final:修飾虛函數(shù),表示該虛函數(shù)不能再被重寫
代碼示例
// 基類
class Car
{
public:// 被final修飾,該虛函數(shù)不能再被重寫virtual void Drive() final {}
};// 子類
class Benz :public Car
{
public:virtual void Drive() { cout << "Benz-舒適" << endl; }
};int main()
{return 0;
}
基類 Car
的虛函數(shù) Drive()
被 final
修飾后就不能再被重寫了,派生類若是重寫了基類的 Drive()
函數(shù)則編譯報錯。
(2)override: 檢查派生類虛函數(shù)是否重寫了基類某個虛函數(shù),如果沒有重寫編譯報錯。
代碼示例
// 基類
class Car {
public:virtual void Drive() {}
};// 派生類
class Benz :public Car {
public:// 子類完成了父類虛函數(shù)的重寫,編譯通過virtual void Drive() override { cout << "Benz-舒適" << endl; }
};// 派生類
class BMW :public Car {
public:// 子類沒有完成了父類虛函數(shù)的重寫,編譯報錯void Drive(int i) override{cout << "Benz-好開" << endl;}
};int main()
{return 0;
}
派生類 Benz
和 BMW
的虛函數(shù) Drive
被 override
修飾,編譯時就會檢查子類的這兩個 Drive
函數(shù)是否重寫了父類的虛函數(shù),如果沒有重寫就會編譯報錯。
🍑 重載、覆蓋(重寫)、隱藏(重定義)的對比
總結(jié)一下這三者的含義:
3. 抽象類
在虛函數(shù)的后面寫上 = 0
,則這個函數(shù)為純虛函數(shù)。包含純虛函數(shù)的類叫做抽象類(也叫接口類),抽象類不能實例化出對象。
代碼示例
//抽象類(接口類)
class Car
{
public://純虛函數(shù)virtual void Drive() = 0;
};int main()
{Car c; //抽象類不能實例化出對象,errorreturn 0;
}
派生類繼承后也不能實例化出對象,只有重寫純虛函數(shù),派生類才能實例化出對象。
//抽象類(接口類)
class Car
{
public://純虛函數(shù)virtual void Drive() = 0;
};//派生類
class Benz : public Car
{
public://重寫純虛函數(shù)virtual void Drive(){cout << "Benz-舒適" << endl;}
};//派生類
class BMV : public Car
{
public://重寫純虛函數(shù)virtual void Drive(){cout << "BMV-操控" << endl;}
};int main()
{//派生類重寫了純虛函數(shù),可以實例化出對象Benz b1;BMV b2;//不同對象用基類指針調(diào)用Drive函數(shù),完成不同的行為Car* p1 = &b1;Car* p2 = &b2;p1->Drive(); //Benz-舒適p2->Drive(); //BMV-操控return 0;
}
運行結(jié)果
純虛函數(shù)規(guī)范了派生類必須重寫,另外純虛函數(shù)更體現(xiàn)出了接口繼承。
抽象類既然不能實例化出對象,那抽象類存在的意義是什么?
- 抽象類可以更好的去表示現(xiàn)實世界中,沒有實例對象對應(yīng)的抽象類型,比如:植物、人、動物等。
- 抽象類很好的體現(xiàn)了虛函數(shù)的繼承是一種接口繼承,強制子類去重寫純虛函數(shù),因為子類若是不重寫從父類繼承下來的純虛函數(shù),那么子類也是抽象類也不能實例化出對象。
🍑 接口繼承和實現(xiàn)繼承
- 實現(xiàn)繼承: 普通函數(shù)的繼承是一種實現(xiàn)繼承,派生類繼承了基類函數(shù)的實現(xiàn),可以使用該函數(shù),繼承的是函數(shù)的實現(xiàn)。
- 接口繼承: 虛函數(shù)的繼承是一種接口繼承,派生類繼承的是基類虛函數(shù)的接口,目的是為了重寫,達成多態(tài),繼承的是接口。
建議: 所以如果不實現(xiàn)多態(tài),就不要把函數(shù)定義成虛函數(shù)。
4. 多態(tài)的原理
🍑 虛函數(shù)表
下面是??家坏拦P試題:sizeof(Base)
是多少?
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:int _b = 1;
};
通過觀察測試,我們發(fā)現(xiàn) Base 類實例化的對象 b 的大小是 8 個字節(jié)。
b 對象當中除了 _b
成員外,實際上還有一個 _vfptr
放在對象的前面(有些平臺可能會放到對象的最后面,這個跟平臺有關(guān))。
對象中的這個指針叫做虛函數(shù)表指針,簡稱虛表指針,虛表指針指向一個虛函數(shù)表,簡稱虛表,每一個含有虛函數(shù)的類中都至少都有一個虛表指針。
因為虛函數(shù)的地址要被放到虛函數(shù)表中,虛函數(shù)表也簡稱虛表。
那么虛函數(shù)表中到底放的是什么?我們接著往下分析
下面代碼中 Base 類有三個成員函數(shù),其中 Func1 和 Func2 是虛函數(shù),Func3 是普通成員函數(shù),子類 Derive 當中僅對父類的 Func1 函數(shù)進行了重寫。
//父類
class Base
{
public://虛函數(shù)virtual void Func1(){cout << "Base::Func1()" << endl;}//虛函數(shù)virtual void Func2(){cout << "Base::Func2()" << endl;}//普通成員函數(shù)void Func3(){cout << "Base::Func3()" << endl;}
private:int _b = 1;
};//子類
class Derive : public Base
{
public://重寫虛函數(shù)Func1virtual void Func1(){cout << "Derive::Func1()" << endl;}
private:int _d = 2;
};int main()
{Base b;Derive d;return 0;
}
通過調(diào)試可以發(fā)現(xiàn),父類對象 b
和基類對象 d
當中除了自己的成員變量之外,父類和子類對象都有一個虛表指針,分別指向?qū)儆谧约旱奶摫怼?/p>
實際上虛表當中存儲的就是虛函數(shù)的地址,因為父類當中的 Func1 和 Func2 都是虛函數(shù),所以父類對象 b
的虛表當中存儲的就是虛函數(shù) Func1 和 Func2 的地址。
而子類雖然繼承了父類的虛函數(shù) Func1 和 Func2,但是子類對父類的虛函數(shù) Func1 進行了重寫,因此,子類對象 d
的虛表當中存儲的是父類的虛函數(shù) Func2 的地址和重寫的Func1的地址。這就是為什么虛函數(shù)的重寫也叫做覆蓋,覆蓋就是指虛表中虛函數(shù)地址的覆蓋,重寫是語法的叫法,覆蓋是原理層的叫法。
其次需要注意的是:Func2 是虛函數(shù),所以繼承下來后放進了子類的虛表,而 Func3 是普通成員函數(shù),繼承下來后不會放進子類的虛表。此外,虛函數(shù)表本質(zhì)是一個存虛函數(shù)指針的指針數(shù)組,一般情況下會在這個數(shù)組最后放一個 nullptr。
總結(jié)一下,派生類的虛表生成步驟如下:
- 先將基類中的虛表內(nèi)容拷貝一份到派生類的虛表。
- 如果派生類重寫了基類中的某個虛函數(shù),則用派生類自己的虛函數(shù)地址覆蓋虛表中基類的虛函數(shù)地址。
- 派生類自己新增加的虛函數(shù)按其在派生類中的聲明次序增加到派生類虛表的最后。
這里還有很容易混淆的問題:虛函數(shù)存在哪的?虛表存在哪的?
虛表實際上是在構(gòu)造函數(shù)初始化列表階段進行初始化的,注意虛表當中存的是虛函數(shù)的地址不是虛函數(shù),虛函數(shù)和普通函數(shù)一樣,都是存在 代碼段 的,只是它的地址又存到了虛表當中。另外,對象中存的不是虛表而是指向虛表的指針。
我們可以通過下面這段代碼判斷虛表是存在哪里的。
int main()
{Base b;Base* p = &b;printf("vfptr:%p\n", *((int*)p)); int i = 0;printf("棧上地址:%p\n", &i); printf("數(shù)據(jù)段地址:%p\n", &j); int* k = new int;printf("堆上地址:%p\n", k); const char* cp = "hello world";printf("代碼段地址:%p\n", cp); return 0;
}
可以看到,代碼當中打印了對象 b
當中的虛表指針,也就是虛表的地址,可以發(fā)現(xiàn)虛表地址與代碼段的地址非常接近,由此我們可以得出虛表實際上是存在代碼段的。
🍑 多態(tài)的原理
上面分析了這個半天了那么多態(tài)的原理到底是什么?
我們還是拿買票這個代碼來說明:
// 父類
class Person {
public:virtual void BuyTicket() { cout << "買票-全價" << endl; }int _p = 1;
};// 子類
class Student : public Person {
public:virtual void BuyTicket() { cout << "買票-半價" << endl; }int _s = 2;
};// 調(diào)用函數(shù)
void Func(Person& p)
{p.BuyTicket();
}int main()
{Person Mike;Func(Mike);Student Johnson;Func(Johnson);return 0;
}
為什么當父類 Person 指針指向的是父類對象 Mike 時,調(diào)用的就是父類的 BuyTicket,當父類 Person 指針指向的是子類對象 Johnson 時,調(diào)用的就是子類的 BuyTicket?
通過調(diào)試可以發(fā)現(xiàn),對象 Mike 中包含一個成員變量 _p 和一個虛表指針,對象 Johnson 中包含兩個成員變量 _p 和 _s 以及一個虛表指針,這兩個對象當中的虛表指針分別指向自己的虛表。
圍繞此圖分析便可得到多態(tài)的原理:
- p 是指向 Mike 對象時,
p->BuyTicket
在 Mike 的虛表中找到虛函數(shù)是Person::BuyTicket
。 - p 是指向 Johnson 對象時,
p->BuyTicket
在 Johson 的虛表中找到虛函數(shù)是Student::BuyTicket
。
這樣就實現(xiàn)出了不同對象去完成同一行為時,展現(xiàn)出不同的形態(tài)。
現(xiàn)在想想多態(tài)構(gòu)成的兩個條件,一是完成虛函數(shù)的重寫,二是必須使用父類的指針或者引用去調(diào)用虛函數(shù)。
必須完成虛函數(shù)的重寫是因為我們需要完成子類虛表當中虛函數(shù)地址的覆蓋,那 為什么必須使用父類的指針或者引用去調(diào)用虛函數(shù)呢?為什么使用父類對象去調(diào)用虛函數(shù)達不到多態(tài)的效果呢?
使用父類指針或者引用時,實際上是一種切片行為,切片時只會讓父類指針或者引用得到父類對象或子類對象中切出來的那一部分。
因此我們現(xiàn)在對代碼進行一下修改,當我們把父類和子類對象直接賦值給 p1 和 p2 時,再去調(diào)用,會發(fā)生什么呢?
int main()
{Person Mike;Student Johnson;Johnson._p = 3; //以便觀察是否完成切片Person p1 = Mike;Person p2 = Johnson;p1.BuyTicket();p2.BuyTicket();return 0;
}
可以看到并沒有實現(xiàn)多態(tài),因為 p1 和 p2 調(diào)用虛函數(shù)時,p1 和 p2 通過虛表指針找到的虛表是不一樣的,最終調(diào)用的函數(shù)也是不一樣的。
使用父類對象時,切片得到部分成員變量后,會調(diào)用父類的拷貝構(gòu)造函數(shù)對那部分成員變量進行拷貝構(gòu)造,而拷貝構(gòu)造出來的父類對象 p1 和 p2 當中的虛表指針指向的都是父類對象的虛表。因為同類型的對象共享一張?zhí)摫?#xff0c;他們的虛表指針指向的虛表是一樣的。
對象切片的時候,子類只會拷貝成員給父類對象,不會拷貝虛表指針,否則拷貝就混亂了,所以父類對象中到底是父類的虛表指針還是子類的虛表指針,是都有可能的,那么是去調(diào)用父類的虛函數(shù)還是子類的虛函數(shù)就不確定!
因此,我們用 p1 和 p2 調(diào)用虛函數(shù)時,p1 和 p2 通過虛表指針找到的虛表是一樣的,最終調(diào)用的函數(shù)也是一樣的,也就無法構(gòu)成多態(tài)。
總結(jié)一下:
- 構(gòu)成多態(tài),指向誰就調(diào)用誰的虛函數(shù),跟對象有關(guān)。
- 不構(gòu)成多態(tài),對象類型是什么就調(diào)用誰的虛函數(shù),跟類型有關(guān)。
🍑 動態(tài)綁定與靜態(tài)綁定
靜態(tài)綁定: 靜態(tài)綁定又稱為前期綁定(早綁定),在程序編譯期間確定了程序的行為,也成為靜態(tài)多態(tài),比如:函數(shù)重載。
動態(tài)綁定: 動態(tài)綁定又稱為后期綁定(晚綁定),在程序運行期間,根據(jù)具體拿到的類型確定程序的具體行為,調(diào)用具體的函數(shù),也稱為動態(tài)多態(tài)。
對于下面這段代碼,我們可以通過查看匯編的方式進一步理解靜態(tài)綁定和動態(tài)綁定。
//父類
class Person
{
public:virtual void BuyTicket(){cout << "買票-全價" << endl;}
};//子類
class Student : public Person
{
public:virtual void BuyTicket(){cout << "買票-半價" << endl;}
};
我們?nèi)羰前凑障旅娣绞秸{(diào)用 BuyTicket 函數(shù),則不構(gòu)成多態(tài),函數(shù)的調(diào)用是在編譯時確定的。
int main()
{Student Johnson;Person p = Johnson; //不構(gòu)成多態(tài)p.BuyTicket();return 0;
}
將調(diào)用函數(shù)的那句代碼翻譯成匯編就只有以下兩條匯編指令,也就是直接調(diào)用的函數(shù)。
而我們?nèi)羰前凑杖缦路绞秸{(diào)用 BuyTicket 函數(shù),則構(gòu)成多態(tài),函數(shù)的調(diào)用是在運行時確定的。
int main()
{Student Johnson;Person& p = Johnson; //構(gòu)成多態(tài)p.BuyTicket();return 0;
}
相比不構(gòu)成多態(tài)時的代碼,構(gòu)成多態(tài)時調(diào)用函數(shù)的那句代碼翻譯成匯編后就變成了八條匯編指令,主要原因就是我們需要在運行時,先到指定對象的虛表中找到要調(diào)用的虛函數(shù),然后才能進行函數(shù)的調(diào)用。
這樣就很好的體現(xiàn)了靜態(tài)綁定是在編譯時確定的,而動態(tài)綁定是在運行時確定的。
5. 單繼承和多繼承關(guān)系的虛函數(shù)表
需要注意的是在單繼承和多繼承關(guān)系中,下面我們?nèi)リP(guān)注的是派生類對象的虛表模型,因為基類
的虛表模型前面我們已經(jīng)看過了,沒什么需要特別研究的。
🍑 單繼承中的虛函數(shù)表
以下列單繼承關(guān)系為例,我們來看看基類和派生類的虛表模型。
// 父類
class Base {
public:virtual void func1() { cout << "Base::func1" << endl; }virtual void func2() { cout << "Base::func2" << endl; }
private:int a;
};// 子類
class Derive :public Base {
public:virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func3" << endl; }virtual void func4() { cout << "Derive::func4" << endl; }
private:int b;
};
其中,基類和派生類對象的虛表模型如下:
在單繼承關(guān)系當中,派生類的虛表生成過程如下:
- 繼承基類的虛表內(nèi)容到派生類的虛表。
- 對派生類重寫了的虛函數(shù)地址進行覆蓋,比如 func1。
- 虛表當中新增派生類當中新的虛函數(shù)地址,比如 func3 和 func4。
但是,通過監(jiān)視窗口我們發(fā)現(xiàn)看不見 func3 和 func4。這里是編譯器的監(jiān)視窗口故意隱藏了這兩個函數(shù),也可以認為是他的一個小 bug。那么我們?nèi)绾尾榭?d 的虛表呢?
(1)使用內(nèi)存監(jiān)視窗口
使用內(nèi)存監(jiān)視窗口看到的內(nèi)容是最真實的,我們調(diào)出內(nèi)存監(jiān)視窗口,然后輸入派生類對象當中的虛表指針,即可看到虛表當中存儲的四個虛函數(shù)地址。
(2)使用代碼打印虛表內(nèi)容
我們可以使用以下代碼,打印上述基類和派生類對象的虛表內(nèi)容,在打印過程中可以順便用虛函數(shù)地址調(diào)用對應(yīng)的虛函數(shù),從而打印出虛函數(shù)的函數(shù)名,這樣可以進一步確定虛表當中存儲的是哪一個函數(shù)的地址。
代碼示例
// 取內(nèi)存值,打印并調(diào)用,確認是否是func4
typedef void(*VFPTR) ();// 打印虛表
void PrintVTable(VFPTR vTable[])
{// 依次取虛表中的虛函數(shù)指針打印并調(diào)用。調(diào)用就可以看出存的是哪個函數(shù)cout << " 虛表地址:" << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){printf(" 第%d個虛函數(shù)地址:0X%x --> ", i, vTable[i]);VFPTR f = vTable[i];f();}cout << endl;
}int main()
{Base b;Derive d;VFPTR * vTableb = (VFPTR*)(*(int*)&b);PrintVTable(vTableb);VFPTR* vTabled = (VFPTR*)(*(int*)&d);PrintVTable(vTabled);return 0;
}
我這里稍微解釋一下打印虛表的代碼:
- 思路:取出 b、d 對象的頭 4bytes,就是虛表的指針,前面我們說了虛函數(shù)表本質(zhì)是一個存虛函數(shù)指針的指針數(shù)組,這個數(shù)組最后面放了一個 nullptr
- 先取 b 的地址,強轉(zhuǎn)成一個
int*
的指針 - 再解引用取值,就取到了 b 對象頭 4bytes 的值,這個值就是指向虛表的指針
- 再強轉(zhuǎn)成
VFPTR*
,因為虛表就是一個存 VFPTR 類型(虛函數(shù)指針類型)的數(shù)組 - 虛表指針傳遞給 PrintVTable 進行打印虛表
- 需要說明的是這個打印虛表的代碼經(jīng)常會崩潰,因為編譯器有時對虛表的處理不干凈,虛表最后面沒有放 nullptr,導(dǎo)致越界,這是編譯器的問題。
運行結(jié)果如下:
模型圖如下:
🍑 多繼承中的虛函數(shù)表
以下列多繼承關(guān)系為例,我們來看看基類和派生類的虛表模型。
// 父類1
class Base1 {
public:virtual void func1() { cout << "Base1::func1" << endl; }virtual void func2() { cout << "Base1::func2" << endl; }
private:int _b1;
};// 父類2
class Base2 {
public:virtual void func1() { cout << "Base2::func1" << endl; }virtual void func2() { cout << "Base2::func2" << endl; }
private:int _b2;
};// 多繼承子類
class Derive : public Base1, public Base2 {
public:virtual void func1() { cout << "Derive::func1" << endl; }virtual void func3() { cout << "Derive::func3" << endl; }
private:int _d1;
};
其中,兩個基類的虛表模型如下:
而派生類的虛表模型就不那么簡單了,派生類的虛表模型如下:
在多繼承關(guān)系當中,派生類的虛表生成過程如下:
- 分別繼承各個基類的虛表內(nèi)容到派生類的各個虛表當中。
- 對派生類重寫了的虛函數(shù)地址進行覆蓋(派生類中的各個虛表中存有該被重寫虛函數(shù)地址的都需要進行覆蓋),比如 func1。
- 在派生類第一個繼承基類部分的虛表當中新增派生類當中新的虛函數(shù)地址,比如 func3。
這里在調(diào)試時,在某些編譯器下也會出現(xiàn)顯示不全的問題,此時如果我們想要看到派生類對象完整的虛表也是用那兩種方法。
(1)使用內(nèi)存監(jiān)視窗口
直接調(diào)用內(nèi)存窗口查看:
(2)使用代碼打印虛表內(nèi)容
需要注意的是,我們在派生類第一個虛表地址的基礎(chǔ)上,向后移 sizeof(Base1)
個字節(jié)即可得到第二個虛表的地址。
// 取內(nèi)存值,打印并調(diào)用,確認是否是func4
typedef void(*VFPTR) ();// 打印虛表
void PrintVTable(VFPTR vTable[])
{// 依次取虛表中的虛函數(shù)指針打印并調(diào)用。調(diào)用就可以看出存的是哪個函數(shù)cout << " 虛表地址:" << vTable << endl;for (int i = 0; vTable[i] != nullptr; ++i){printf(" 第%d個虛函數(shù)地址:0X%x --> ", i, vTable[i]);VFPTR f = vTable[i];f();}cout << endl;
}int main()
{Base1 b1;Base2 b2;VFPTR* vTableb1 = (VFPTR*)(*(int*)&b1); PrintVTable(vTableb1); // 打印基類對象b1的虛表地址及其內(nèi)容VFPTR* vTableb2 = (VFPTR*)(*(int*)&b2);PrintVTable(vTableb2); // 打印基類對象b2的虛表地址及其內(nèi)容Derive d;VFPTR* vTableb3 = (VFPTR*)(*(int*)&d);PrintVTable(vTableb3); // 打印派生類對象d的第一個虛表地址及其內(nèi)容VFPTR* vTableb4 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));PrintVTable(vTableb4); // 打印派生類對象d的第二個虛表地址及其內(nèi)容return 0;
}
運行結(jié)果如下:
觀察下圖可以看出:多繼承派生類的未重寫的虛函數(shù)放在第一個繼承基類部分的虛函數(shù)表中:
🍑 菱形繼承和菱形虛擬繼承
實際中我們不建議設(shè)計出菱形繼承及菱形虛擬繼承,一方面太復(fù)雜容易出問題,另一方面這樣的模型,訪問基類成員有一定得性能損耗。所以菱形繼承、菱形虛擬繼承我們的虛表我們就不看了,一般我們也不需要研究清楚,因為實際中很少用。
但是這里可以給大家推薦兩篇文章:
- C++ 虛函數(shù)表解析
- C++ 對象的內(nèi)存布局
6. 繼承和多態(tài)常見的面試問題
🍑 概念查考
-
下面哪種面向?qū)ο蟮姆椒梢宰屇阕兊酶挥?#xff1f;
A. 繼承 B. 封裝 C. 多態(tài) D. 抽象 -
什么是面向?qū)ο蟪绦蛟O(shè)計語言中的一種機制。這種機制實現(xiàn)了方法的定義與具體的對象無關(guān),而對方法的調(diào)用則可以關(guān)聯(lián)于具體的對象。
A. 繼承 B. 模板 C. 對象的自身引用 D. 動態(tài)綁定 -
面向?qū)ο笤O(shè)計中的繼承和組合,下面說法錯誤的是?
A. 繼承允許我們覆蓋重寫父類的實現(xiàn)細節(jié),父類的實現(xiàn)對于子類是可見的,是一種靜態(tài)復(fù)用,也稱為白盒復(fù)用
B. 組合的對象不需要關(guān)心各自的實現(xiàn)細節(jié),之間的關(guān)系是在運行時候才確定的,是一種動態(tài)復(fù)用,也稱為黑盒復(fù)用
C. 優(yōu)先使用繼承,而不是組合,是面向?qū)ο笤O(shè)計的第二原則
D. 繼承可以使子類能自動繼承父類的接口,但在設(shè)計模式中認為這是一種破壞了父類的封裝性的表現(xiàn) -
以下關(guān)于純虛函數(shù)的說法,正確的是?
A. 聲明純虛函數(shù)的類不能實例化對象 B. 聲明純虛函數(shù)的類是虛基類
C. 子類必須實現(xiàn)基類的純虛函數(shù) D. 純虛函數(shù)必須是空函數(shù) -
關(guān)于虛函數(shù)的描述正確的是?
A. 派生類的虛函數(shù)與基類的虛函數(shù)具有不同的參數(shù)個數(shù)和類型 B. 內(nèi)聯(lián)函數(shù)不能是虛函數(shù)
C. 派生類必須重新定義基類的虛函數(shù) D. 虛函數(shù)可以是一個static型的函數(shù) -
關(guān)于虛表說法正確的是?
A. 一個類只能有一張?zhí)摫?br /> B. 基類中有虛函數(shù),如果子類中沒有重寫基類的虛函數(shù),此時子類與基類共用同一張?zhí)摫?br /> C. 虛表是在運行期間動態(tài)生成的
D. 一個類的不同對象共享該類的虛表 -
假設(shè) A 類中有虛函數(shù),B 繼承自 A,B 重寫 A 中的虛函數(shù),也沒有定義任何虛函數(shù),則
A. A類對象的前4個字節(jié)存儲虛表地址,B類對象前4個字節(jié)不是虛表地址
B. A類對象和B類對象前4個字節(jié)存儲的都是虛基表的地址
C. A類對象和B類對象前4個字節(jié)存儲的虛表地址相同
D. A類和B類虛表中虛函數(shù)個數(shù)相同,但A類和B類使用的不是同一張?zhí)摫?/p> -
下面程序輸出結(jié)果是什么?
#include <iostream>
using namespace std;class A
{
public:A(char* s) { cout << s << endl; }~A() {};
};
class B : virtual public A
{
public:B(char* s1, char* s2):A(s1){cout << s2 << endl;}
};
class C : virtual public A
{
public:C(char* s1, char* s2):A(s1){cout << s2 << endl;}
};
class D : public B, public C
{
public:D(char* s1, char* s2, char* s3, char* s4):B(s1, s2), C(s1, s3), A(s1){cout << s4 << endl;}
};
int main()
{D* p = new D("class A", "class B", "class C", "class D");delete p;return 0;
}
A. class A class B class C class D B. class D class B class C class A
C. class D class C class B class A D. class A class C class B class D
- 多繼承中指針偏移問題?下面說法正確的是?
#include <iostream>
using namespace std;class Base1
{
public:int _b1;
};class Base2
{
public:int _b2;
};class Derive : public Base1, public Base2
{
public:int _d;
};int main()
{Derive d;Base1* p1 = &d;Base2* p2 = &d;Derive* p3 = &d;return 0;
}
A. p1 == p2 == p3
B. p1 < p2 < p3
C. p1 == p3 != p2
D. p1 != p2 != p3
- 以下程序輸出結(jié)果是什么?
#include <iostream>
using namespace std;class A
{
public:virtual void func(int val = 1){cout << "A->" << val << endl;}virtual void test(){func();}
};class B : public A
{
public:void func(int val = 0){cout << "B->" << val << endl;}
};int main()
{B* p = new B;p->test();return 0;
}
A. A->0
B. B->1
C. A->1
D. B->0
E. 編譯出錯 F. 以上都不正確
答案如下:
🍑 問答題
(1)什么是多態(tài)?
多態(tài)是指不同繼承關(guān)系的類對象,去調(diào)用同一函數(shù),產(chǎn)生了不同的行為。多態(tài)又分為靜態(tài)的多態(tài)和動態(tài)的多態(tài)。
(2)什么是重載、重寫(覆蓋)、重定義(隱藏)?
重載是指兩個函數(shù)在同一作用域,這兩個函數(shù)的函數(shù)名相同,參數(shù)不同。
重寫(覆蓋)是指兩個函數(shù)分別在基類和派生類的作用域,這兩個函數(shù)的函數(shù)名、參數(shù)、返回值都必須相同(協(xié)變例外),且這兩個函數(shù)都是虛函數(shù)。
重定義(隱藏)是指兩個函數(shù)分別在基類和派生類的作用域,這兩個函數(shù)的函數(shù)名相同。若兩個基類和派生類的同名函數(shù)不構(gòu)成重寫就是重定義。
(3)多態(tài)的實現(xiàn)原理?
構(gòu)成多態(tài)的父類對象和子類對象的成員當中都包含一個虛表指針,這個虛表指針指向一個虛表,虛表當中存儲的是該類對應(yīng)的虛函數(shù)地址。
因此,當父類指針指向父類對象時,通過父類指針找到虛表指針,然后在虛表當中找到的就是父類當中對應(yīng)的虛函數(shù);
當父類指針指向子類對象時,通過父類指針找到虛表指針,然后在虛表當中找到的就是子類當中對應(yīng)的虛函數(shù)。
(4)inline 函數(shù)可以是虛函數(shù)嗎?
我們知道內(nèi)聯(lián)函數(shù)是會在調(diào)用的地方展開的,也就是說內(nèi)聯(lián)函數(shù)是沒有地址的,但是內(nèi)聯(lián)函數(shù)是可以定義成虛函數(shù)的,當我們把內(nèi)聯(lián)函數(shù)定義虛函數(shù)后,編譯器就忽略了該函數(shù)的內(nèi)聯(lián)屬性,這個函數(shù)就不再是內(nèi)聯(lián)函數(shù)了,因為需要將虛函數(shù)的地址放到虛表中去。
(5)靜態(tài)成員可以是虛函數(shù)嗎?
靜態(tài)成員函數(shù)不能是虛函數(shù),因為靜態(tài)成員函數(shù)沒有this指針,使用類型 ::
成員函數(shù)的調(diào)用方式無法訪問虛表,所以靜態(tài)成員函數(shù)無法放進虛表。
(6)構(gòu)造函數(shù)可以是虛函數(shù)嗎?
構(gòu)造函數(shù)不能是虛函數(shù),因為對象中的虛表指針是在構(gòu)造函數(shù)初始化列表階段才初始化的。
(7)析構(gòu)函數(shù)可以是虛函數(shù)嗎?
析構(gòu)函數(shù)可以是虛函數(shù),并且最后把基類的析構(gòu)函數(shù)定義成虛函數(shù)。若是我們分別 new 一個父類對象和一個子類對象,并均用父類指針指向它們,當我們使用 delete 調(diào)用析構(gòu)函數(shù)并釋放對象空間時,只有當父類的析構(gòu)函數(shù)是虛函數(shù)的情況下,才能正確調(diào)用父類和子類的析構(gòu)函數(shù)分別對父類和子類對象進行析構(gòu),否則當我們使用父類指針 delete 對象時,只能調(diào)用到父類的析構(gòu)函數(shù)。
(8)對象訪問普通函數(shù)快還是虛函數(shù)更快?
對象訪問普通函數(shù)比訪問虛函數(shù)更快,若我們訪問的是一個普通函數(shù),那直接訪問就行了,但當我們訪問的是虛函數(shù)時,我們需要先找到虛表指針,然后在虛表當中找到對應(yīng)的虛函數(shù),最后才能調(diào)用到虛函數(shù)。
(9)虛函數(shù)表是在什么階段生成的?存在哪的?
虛表是在構(gòu)造函數(shù)初始化列表階段進行初始化的,虛表一般情況下是存在代碼段(常量區(qū))的。
(10)C++菱形繼承的問題?虛繼承的原理?
菱形虛擬繼承因為子類對象當中會有兩份父類的成員,因此會導(dǎo)致數(shù)據(jù)冗余和二義性的問題。
虛繼承對于相同的虛基類在對象當中只會存儲一份,若要訪問虛基類的成員需要通過虛基表獲取到偏移量,進而找到對應(yīng)的虛基類成員,從而解決了數(shù)據(jù)冗余和二義性的問題。
(11)什么是抽象類?抽線類的作用?
抽象類很好的體現(xiàn)了虛函數(shù)的繼承是一種接口繼承,強制子類去抽象純虛函數(shù),因為子類若是不抽象從父類繼承下來的純虛函數(shù),那么子類也是抽象類也不能實例化出對象。
其次,抽象類可以很好的去表示現(xiàn)實世界中沒有示例對象對應(yīng)的抽象類型,比如:植物、人、動物等。