目錄
一、多態的概念
二、多態的定義及實現
1.多態的構成條件?
?2.虛函數
3.虛函數的重寫?
4.例題理解(超級重要,強烈建議做一下)
?5.C++11 override和 final
6.重載、覆蓋(重寫)、隱藏(重定義)的對比
三、抽象類
1.概念?
2.接口繼承與實現繼承
四、多態的原理
1.虛函數表?
2.原理
3.靜態綁定與動態綁定
?五、單繼承和多繼承關系的虛函數表
1.單繼承中的虛函數表
?2.多繼承中的虛函數表
l六、一些經典的面試問題
一、多態的概念
多態用通俗的話來說就是多種形態,具體點就是去完成某個行為,當不同的對象去完成時會產生出不同的狀態。
舉個栗子:買票
普通人買票時,是全價買票;學生買票時,是半價買票;軍人買票時是優先買票。
二、多態的定義及實現
1.多態的構成條件?
首先,多態是在不同繼承關系的類對象,去調用同一函數,產生了不同的行為。
比如Student繼承了 Person。Person對象買票全價,Student對象買票半價。
在繼承中構成多態有兩個條件:
1.必須通過基類的指針和引用調用虛函數
2.被調用的函數必須是虛函數 ,且派生類必須對基類的虛函數進行重寫
例:
class Person
{public:virtual void BuyTicket(){ cout << "買票-全價" << endl;}
};class Student : public Person
{
public:virtual void BuyTicket(){ cout << "買票-半價" << endl;}
};// 多態
// 條件1:虛函數的重寫 -> 父子類中兩個虛函數,三同(函數名、參數、返回)
// 條件2:父類指針或引用去調用虛函數
void Func(Person& p)
{p.BuyTicket();
}int main()
{Person ps;Student st;Func(ps);Func(st);return 0;
}
?2.虛函數
被virtual修飾的類成員函數稱為虛函數
class Person
{public:virtual void BuyTicket(){ cout << "買票-全價" << endl;}
};
3.虛函數的重寫?
虛函數的重寫(或者覆蓋):派生類中有一個跟基類完全相同的虛函數(返回類型、函數名、參數列表完全相同),則稱子類的虛函數重寫了基類的虛函數
class Person
{public:virtual void BuyTicket(){ cout << "買票-全價" << endl;}
};class Student : public Person
{
public:virtual void BuyTicket(){ cout << "買票-半價" << endl;}
};
注:在重寫基類虛函數時,派生類的虛函數在不加virtual關鍵字時,雖然也可以構成重寫(因為繼承后基類的虛函數被繼承下來了在派生類依舊保持虛函數屬性),但是該種寫法不是很規范,不建議這樣使用
虛函數重寫的兩個特殊:
1.協變(基類與派生類虛函數返回值類型不同)
即基類虛函數返回基類對象的指針或者引用,派生類虛函數返回派生類對象的指針或者引用時,稱為協變
class A
{};class B : public A
{};class Person
{
public:virtual A* f(){ return new A;}
};class Student : public Person
{
public:virtual B* f(){ return new B;}
};
2.析構函數的重寫(基類與派生類析構函數的名字不同)
如果基類的析構函數為虛函數,此時派生類析構函數只要定義,無論是否加virtual關鍵字, 都與基類的析構函數構成重寫,雖然基類與派生類析構函數名字不同。
雖然函數名不相同, 看起來違背了重寫的規則,其實不然,這里可以理解為編譯器對析構函數的名稱做了特殊處理,編譯后析構函數的名稱統一處理成destructor。
class Person
{
public:virtual ~Person() { cout << "~Person()" << endl; }
};
class Student : public Person
{
public:virtual ~Student() { cout << "~Student()" << endl; }
};
// 只有派生類Student的析構函數重寫了Person的析構函數,下面的delete對象調用析構函
//數,才能構成多態,才能保證p1和p2指向的對象正確的調用析構函數。
int main()
{Person* p1 = new Person;Person* p2 = new Student;delete p1;delete p2;return 0;
}
4.例題理解(超級重要,強烈建議做一下)
class A
{
public:virtual void func(int val = 1) { std::cout << "A->" << val << std::endl;}virtual void test() { func(); }
};class B : public A
{
public:void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};int main(int argc, char* argv[])
{B* p = new B;p->test();return 0;
}
A:A->0? ? B:B->1? ?C:A->1? ?D:B->0? ?E:編譯出錯? ?F:以上都不正確?
烙鐵們猜猜選哪個??
?首先這是一個多態調用,看起來不太像,但是它滿足多態調用的兩個條件
1.被調用的函數必須是虛函數 ,且派生類必須對基類的虛函數進行重寫?:func()是虛函數重寫
2.通過基類的指針和引用調用虛函數:首先派生類B中繼承了test(),可以調用test(),然后再test()中調用func(),但是在這個test()中調用func函數的this指針是A*還是B*呢?答案是A*,當this是A*時即滿足通過基類指針或引用調用虛函數,可以構成多態,為什么呢?因為在派生類B中調用test函數時,實則是調用基類A中的test函數
這里我們可以了解一個知識:子類繼承父類的成員函數只是說子類可以調用父類的函數,并不是拷貝一份在子類里
函數的調用是先子后父,先看看子類中有沒有,沒有再去父類中找,因此滿足第二個條件
此時我想大多數烙鐵都會說答案是 D,但是答案是B
這里有一個很隱晦的知識點:在多態調用中(記住是在多態調用中,不是普通函數調用),虛函數的重寫是重寫它的實現,但用的是基類函數的聲明
就像這樣:
然后變成下面這樣:?
?
所以答案選B,出題人真是太心機了?
?5.C++11 override和 final
C++中對函數重寫的要求比較嚴格,有些情況下由于疏忽,可能會導致函數名字母次序寫反或其他錯誤而無法構成重寫,而這種錯誤在編譯期間是不會報出的,只有在程序運行時沒有得到預期結果才會發現,因此:C++11提供了override和final兩個關鍵字,可以幫助用戶檢測是否重寫
?1.final:修飾虛函數,表示虛函數不能被重寫
class Car
{
public:virtual void Drive() final {}
};
class Benz :public Car
{
public:virtual void Drive() { cout << "小米su7" << endl; }
};
2.override: 檢查派生類虛函數是否重寫了基類某個虛函數,如果沒有重寫編譯報錯
class Car {
public:virtual void Drive() {}
};
class Benz :public Car {
public:virtual void Drive() override { cout << "Benz-舒適" << endl; }
};
6.重載、覆蓋(重寫)、隱藏(重定義)的對比
1,重載首先必須在同一個作用域中,而隱藏和重寫在不同類的作用域中
2,隱藏只要函數名相同即可,重寫則要是虛函數,且是三同(返回類型,參數類型,函數名)
3,重寫相當于隱藏的一個分支
?
三、抽象類
1.概念?
在虛函數的后面寫上 =0 ,則這個函數為純虛函數,包含純虛函數的類叫做抽象類(也叫接口類)
抽象類不能實例化出對象。派生類繼承后也不能實例化出對象,只有重寫純虛函數,派生類才能實例化出對象。
純虛函數規范了派生類必須重寫,另外純虛函數更體現出了接口繼承。
class Car
{
public:virtual void Drive() = 0;
};
class Benz :public Car
{
public:virtual void Drive(){cout << "Benz" << endl;}
};
class Xiaomisu7 :public Car
{
public:virtual void Drive(){cout << "xiaomisu7" << endl;}
};
void Test()
{Car* pBenz = new Benz;pBenz->Drive();Car* xiaomisu7 = new Xiaomisu7;xiaomisu7->Drive();
}
子類不實現父類所有的純虛函數,則子類還屬于抽象類,仍然不能實例化對象?
2.接口繼承與實現繼承
普通函數的繼承是一種實現繼承,派生類繼承了基類函數,可以使用函數,繼承的是函數的實 現。
虛函數的繼承是一種接口繼承,派生類繼承的是基類虛函數的接口,目的是為了重寫,達成多態,繼承的是接口。所以如果不實現多態,不要把函數定義成虛函數。
四、多態的原理
1.虛函數表?
觀察下面代碼:
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:int _b = 1;
};
int main()
{Base a;return 0;
}
通過觀察測試我們發現b對象中除了_b成員,還多一個__vfptr放在對象的前面(注意有些平臺可能會放到對象的最后面,這個跟平臺有關),對象中的這個指針我們叫做虛函數表指針(v代表virtual,f代表function)。
一個含有虛函數的類中都至少都有一個虛函數表指針,因為虛函數的地址要被放到虛函數表中,虛函數表也簡稱虛表,那么派生類中這個表放了些什么呢?我們接著往下分析?
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 << "Derive1::Func1()" << endl;}
private:int _d = 2;
};int main()
{Base a;Derive d;return 0;
}
通過觀察和測試,我們發現:
1. 派生類對象d中也有一個虛表指針,d對象由兩部分構成,一部分是父類繼承下來的成員,虛表指針也就是存在部分的另一部分是自己的成員。
2. 基類b對象和派生類d對象虛表是不一樣的,這里我們發現Func1完成了重寫,所以d的虛表中存的是重寫的Derive::Func1,所以虛函數的重寫也叫作覆蓋,覆蓋就是指虛表中虛函數的覆蓋。重寫是語法的叫法,覆蓋是原理層的叫法。
3. 另外Func2繼承下來后是虛函數,所以放進了虛表,Func3也繼承下來了,但是不是虛函數,所以不會放進虛表。
4. 虛函數表本質是一個存虛函數指針的指針數組,一般情況這個數組最后面放了一個nullptr。
5. 總結一下派生類的虛表生成:
a.先將基類中的虛表內容拷貝一份到派生類虛表中
b.如果派生類重寫了基類中某個虛函數,用派生類自己的虛函數覆蓋虛表中基類的虛函數
c.派生類自己新增加的虛函數按其在派生類中的聲明次序增加到派生類虛表的最后。
6. 這里還有一個烙鐵們很容易混淆的問題:虛函數存在哪的?虛表存在哪的?
答:虛函數存在虛表,虛表存在對象中。注意上面的回答的錯的。
但是很多童鞋都是這樣深以為然的。注意虛表存的是虛函數指針,不是虛函數,虛函數和普通函數一樣的,都是存在代碼段的,只是他的指針又存到了虛表中
另外對象中存的不是虛表,存的是虛表指針。那么虛表存在哪的呢?實際我們去驗證一下會發現vs下是存在代碼段的
?7.內存:內存對齊,多個虛函數會被放在一個虛表中,由一個虛表指針指向
8.多繼承的時候,就會可能有多張虛表
9.父類對象的虛表與子類對象的虛表沒有任何關系,這是兩個不同的對象
10.虛表是在編譯期間生成的
11.一個類的不同對象共享該類的虛表,可以自行寫代碼驗證之
2.原理
了解了虛函數表,下面我們來具體分析一下多態的原理
還是以買票舉例:
class Person
{
public:virtual void BuyTicket(){ cout << "買票-全價" << endl;}
};
class Student : public Person
{
public:virtual void BuyTicket(){cout << "買票-半價" << endl;}
};void Func(Person& p)
{p.BuyTicket();
}int main()
{Person Mike;Func(Mike);Student Johnson;Func(Johnson);return 0;
}
?
1. 觀察下圖的紅色箭頭我們看到,p是指向mike對象時,p->BuyTicket在mike的虛表中找到虛函數是Person::BuyTicket。
2. 觀察下圖的藍色箭頭我們看到,p是指向johnson對象時,p->BuyTicket在johson的虛表中找到虛函數是Student::BuyTicket。
3. 這樣就實現出了不同對象去完成同一行為時,展現出不同的形態
4.要了解的是滿足多態的函數調用,不是在編譯時確定的,是運行起來以后到對象的中取找的。不滿足多態的函數調用時編譯時確認好的。
總之多態調用時在運行時在虛函數表找函數的地址,進行調用,所以指向父類調的是父類的虛函數,指向子類調用的是子類的虛函數,而普通調用時是在編譯時,通過調用者類型確定函數地址
3.靜態綁定與動態綁定
?1. 靜態綁定又稱為前期綁定(早綁定),在程序編譯期間確定了程序的行為,也稱為靜態多態, 比如:函數重載,總之就是在編譯時確定函數的地址,在編譯時時實現多態,本質是函數名修飾規則
2. 動態綁定又稱后期綁定(晚綁定),是在程序運行期間,根據具體拿到的類型確定程序的具體行為,調用具體的函數,也稱為動態多態。總之就是在運行時確定函數的地址,在運行時實現多態
?五、單繼承和多繼承關系的虛函數表
1.單繼承中的虛函數表
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;
};int main()
{Base b;Derive d;return 0;
}
下圖中的監視窗口中我們發現看不見func3和func4。這里是編譯器的監視窗口故意隱藏了這兩個函數,也可以認為是他的一個小bug。
Base類對象和Derive類對象前4個字節存儲的都是虛表的地址,只是各自指向各自的虛表
我們可以將虛表中的函數打印出來,取出b、d對象的頭4bytes,就是虛表的指針,前面我們說了虛函數表本質是一個存虛函數指針的指針數組,這個數組最后面放了一個nullptr,具體代碼就不寫了,主要是觀察?
?2.多繼承中的虛函數表
class Base1
{
public:virtual void func1() { cout << "Base1::func1" << endl; }virtual void func2() { cout << "Base1::func2" << endl; }
private:int b1;
};
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;
};int main()
{Derive d;return 0;
}
同上打印一下
??
?可以看出,多繼承派生類的未重寫的虛函數放在第一個繼承基類部分的虛函數表中
就像這樣
l六、一些經典的面試問題
1. 什么是多態?
可以從動態多態和靜態多態兩個角度去講
?靜:靜態多態是編譯時的多態,例如函數重載,會自動匹配類型,本質是函數名修飾規則用修飾? ?后的函數名字去符號表里去找函數的地址
?動:運行時到指向對象的虛表里去找?
2. 什么是重載、重寫(覆蓋)、重定義(隱藏)? 參考上述博客
3. 多態的實現原理?多態調用時在運行時在虛函數表找函數的地址,進行調用,所以指向父類調的是父類的虛函數,指向子類調用的是子類的虛函數(具體參考上述博客)
4. inline函數可以是虛函數嗎?
可以,不過編譯器就忽略inline屬性,這個函數就不再是 inline,因為虛函數要放到虛表中去。
5. 靜態成員可以是虛函數嗎?
不能,因為靜態成員函數沒有this指針,使用類型::成員函數的調用方式無法訪問虛函數表,所以靜態成員函數無法放進虛函數表。
6. 構造函數可以是虛函數嗎?
不能,因為對象中的虛函數表指針是在構造函數初始化列表階段才初始化的。
7. 析構函數可以是虛函數嗎?什么場景下析構函數是虛函數?
可以,并且最好把基類的析構函數定義成虛函數
8. 對象訪問普通函數快還是虛函數更快?
首先如果是普通對象,是一樣快的。
如果是指針對象或者是引用對象,則調用的普通函數快,因為構成多態,運行時調用虛函數需要到虛函數表中去查找。
9. 虛函數表是在什么階段生成的,存在哪的?
虛函數表是在編譯階段就生成的,一般情況下存在代碼段(常量區)
10.抽象類的作用?
抽象類強制重寫了虛函數,另外抽象類體現出了接口繼承關系。