文章目錄
- 多態
- 虛函數
- 重寫
- 重定義(參數不同)
- 協變(返回值不同)
- 析構函數重寫(函數名不同)
- final和override
- 重載、重寫、重定義
- 抽象類
- 多態的原理
- 虛函數
- 常見問題解析
- 虛函數表
多態
一種事物,多種形態。換言之,對于同一個行為,不同的對象去完成就會產生不同的結果。
多態的構成條件
多態是繼承體系中的一個行為,如果要在繼承體系中構成多態,需要滿足兩個條件:
-
必須通過基類的指針or引用調用虛函數
-
被調用的函數必須是虛函數,并且派生類必須要對繼承的基類的虛函數進行重寫
虛函數
虛函數就是被 virtual
修飾的類成員函數 (這里的 virtual
和虛繼承的 virtual
雖然是同一個關鍵字,但是作用不一樣)。
- 任何構造函數之外的非靜態函數都可以是虛函數。
- 關鍵字
virtual
只能出現在類內部的聲明語句之前而不能用于類外部的函數定義。 - 如果基類把一個函數聲明成虛函數,則該函數在派生類中隱式地也是虛函數。
重寫
一般情況下,當派生類中有一個和基類完全相同的虛函數(函數名、返回值、參數完全相同),則說明子類的虛函數重寫了基類的虛函數。
class Human
{
public:virtual void print(){cout << "i am a human" << endl;}
};class Student : public Human
{
public:virtual void print(){cout << "i am a student" << endl;}
};void ShowIdentity(Human &human) // 形參是基類引用,構成多態
{human.print(); // 被調用的函數是虛函數
}int main()
{Human h;Student s;ShowIdentity(h); ShowIdentity(s);
}
通常如果不滿足函數名、返回值、參數完全相同,則不構成重寫,即無法實現多態。但也有例外:
重定義(參數不同)
參數不同則會變成重定義:
class Base{
public:virtual void Show(int n = 10)const{ // 提供缺省參數值std::cout << "Base:" << n << std::endl;}
};class Base1 : public Base{
public:virtual void Show(int n = 20)const{ // 重新定義繼承而來的缺省參數值std::cout << "Base1:" << n << std::endl;}
};int main(){Base* p1 = new Base1; p1->Show(); return 0;
}
如果子類重寫了缺省值,此時的子類的缺省值是無效的,使用的還是父類的缺省值。
因為虛函數是動態綁定,而缺省值是靜態綁定。
- 對于
p1
,他的靜態類型即指針的類型——Base
,所以這里的缺省值是Base
的缺省值。 - 而動態類型也就是指向的對象是
Base1
,所以這里調用的虛函數則是Base1
中的虛函數。 - 調用了
Base1
中的虛函數,Base
中的缺省值,因此輸出Base1:10
。
或者可以更簡單的一句話描述,虛函數的重寫只重寫函數實現,不重寫缺省值。
動態綁定和靜態綁定
- 對象的靜態類型:對象在聲明時采用的類型。是在編譯期確定的。(比如上面的
p1
,Base
是靜態類型,指向的對象的類型Base1
是動態類型) - 對象的動態類型:目前所指對象的類型。是在運行期決定的。
對象的動態類型可以更改,但是靜態類型無法更改。
- 靜態綁定:綁定的是對象的靜態類型,發生在編譯期。
- 動態綁定:綁定的是對象的動態類型,發生在運行期。
協變(返回值不同)
當基類和派生類的返回值類型不同時,如果基類對象返回基類的 引用or指針
,派生類對象返回的是派生類的 引用or指針
,也能實現多態。這樣實現多態的方式叫協變。
class Human
{
public:virtual Human& print(){cout << "i am a human" << endl;return *this;}
};class Student : public Human
{
public:virtual Student& print(){cout << "i am a student" << endl;return *this;}
};
但如果返回類型不是對應類的 指針or引用
,則不足以構成協變:
析構函數重寫(函數名不同)
在特定條件下,函數名不同也能實現多態,最好的例子是析構函數,編譯器為了讓析構函數實現多態,會將它的名字處理成destructor
,也就是說,實際上析構函數的函數名也是“相同的”,其多態實現遵循重寫的規定。
class Human
{
public:~Human(){cout << "~Human()" << endl;}
};class Student : public Human
{
public:~Student(){cout << "~Student()" << endl;}
};
可以看到,如果不構成多態,那么指針是什么類型的,就會調用什么類型的析構函數。那么,如果派生類的析構函數中有資源釋放的操作,而這里卻沒有釋放掉那些資源,就會導致內存泄漏的問題。
所以為了防止這種情況,必須要將析構函數定義為虛函數。這也就是編譯器將析構函數重命名為 destructor
的原因:
final和override
final
和 override
是 C++11
中提供給用戶用來檢測是否進行重寫的兩個關鍵字。
final
使用 final
修飾的基類虛函數不能被重寫。
如果虛函數不想被派生類重寫,就可以用 final
來修飾這個虛函數:
override
override
關鍵字是用來檢測派生類虛函數是否構成重寫的關鍵字。C++11
允許派生類顯式地注明它覆蓋了繼承基類的虛函數。
在我們寫代碼的時候難免會出現些小錯誤,如 基類虛函數沒有 virtual
或者 派生類虛函數名拼錯
等問題,這些問題不會被編譯器檢查出來,發生錯誤時也很難一下子鎖定,所以 C++
增添了 override
這一層保險,當修飾的虛函數不構成重寫時就會編譯錯誤。
具體做法是在:
- 形參列表后面
- 或者
const
成員函數的const
關鍵字后面 - 或者 引用成員函數的引用限定符后面
加一個關鍵字 override
。
下例中,基類虛函數沒有 virtual
因此會報錯:
重載、重寫、重定義
重載:
- 在同一作用域
- 函數名相同,參數的類型、順序、數量不同。
- 重載不一定要求返回值相同:參數相同、返回值不同不構成重載;參數、返回值都不同則構成重載。(會發現返回值不同是否構成重載還是看參數相同與否……)
重寫(覆蓋):
- 作用域不同,一個在基類一個在派生類。
- 函數名,參數,返回值必須相同(協變和析構函數除外)
- 基類和派生類必須都是虛函數(派生類可以不加
virtual
,基類的虛函數屬性可以繼承,但是最好要加上virtual
)
考慮這樣一個問題,下面有幾個虛函數:
正確答案是 3
個,A 中的 fun1
,B 中的 fun1
、fun2
。原因就如第三點所說,基類的虛函數屬性可以繼承 ,但是如果有 C類
繼承了 B類
,且也有一個 沒有virtual關鍵字的 void fun1(); 函數
,該函數并不是虛函數,因為 B類
的 fun1
并沒有顯式聲明 virtual
屬性。
而形如 fun2
這樣的,子類是虛函數而父類沒有 virtual
屬性的,父類的 fun2
不是虛函數,虛函數不具備對稱性。
重定義(隱藏):
- 作用域不同,一個在基類一個在派生類
- 函數名相同
- 派生類和基類同名函數如果不構成重寫那就是重定義
重定義無法覆蓋虛函數,只能覆蓋普通函數,但是父類被覆蓋的普通函數可以通過作用域運算符調用:
class A
{
public:virtual void f2(){cout << "A.f2()" << endl;}
};
class B :public A {
public:void f2(int){cout << "B.f2(int)" << endl;}
};
class C:public B{
public:// C類中的兩個f2函數互相構成重載,但又分別構成重定義和重寫void f2() { // 重寫了A類中的虛函數f2()cout << "C.f2()" << endl;}void f2(int) { // 重定義了B類中的普通函數f2(int)cout << "C.f2(int)" << endl;}
};
抽象類
如果在虛函數的后面加上 =0
,并且不進行實現,這樣的虛函數就叫做純虛函數。而包含純虛函數的類,也叫做抽象(基)類或者接口類。抽象類不能實例化出對象,因為他所具有的信息不足以描述一個對象,派生類繼承后也只有在重寫純虛函數后才能實例化出對象。
我們也可以對純虛函數提供定義,不過函數體必須在類的外部。
抽象類就像是一個藍圖,為派生類描述好一個大概的架構,派生類必須實現完這些架構,至于要在這些架構上面做些什么,增加什么,就屬于派生類自己的問題。
class Human
{
public:virtual void print() = 0;
};class Student : public Human
{
public:virtual void print(){cout << "i am a student" << endl;}
};class Teacher : public Human
{
public:virtual void print(){cout << "i am a teacher" << endl;}
};void ShowIdentity(Human& human)
{human.print();
}
- 普通函數的繼承是一種實現繼承,派生類繼承了基類函數,可以使用函數,繼承的是函數的實現。
- 虛函數的繼承是一種接口繼承,派生類繼承的是基類虛函數的接口,目的是為了重寫,達成多態,所以如果不實現多態,不要把函數定義成虛函數。
多態的原理
虛函數
class Human
{
public:virtual void print(){cout << "i am a human" << endl;}virtual void test1(){cout << "1test1" << endl;}void test2(){cout << "1test1" << endl;}int _age;
};class Student : public Human
{
public:virtual void print() {cout << "i am a student" << endl;}void test2(){cout << "2test2" << endl;}int _stuNum;
};
我們創建一個 Human
類對象 h
,觀察它的大小,按理來說應該輸出 4
,因為它只有一個 int類型
的數據成員,但是結果卻是 8
。
可以看到奇怪的是除了 _age
之外,還有個 void**(void*類型的指針,注意不是數組)
類型的 _vfptr
,這個 _vfptr
也被稱為虛函數表指針,其指向了一個函數指針數組,這個函數指針數組也就是虛函數表,其中的每一個成員指針指向的都是虛函數,而不是虛函數的 test2
則沒有被放入表中。
此時再創建一個
Student
類的對象s
,進一步觀察:
我們可以看到,如果派生類實現了某個虛函數的重寫,那么在派生類的虛函數表中,重寫的虛函數就會覆蓋掉原有的函數,如Student::print
。而沒有完成重寫的 test1
則依舊保留著從基類繼承下來的虛函數 Human::test1
。
總結
- 派生類會繼承基類的虛函數表,如果派生類完成了重寫,則會將重寫的虛函數覆蓋掉原有的函數。
- 指針或引用指向哪一個對象,就調用對象中虛函數表中對應位置的虛函數,來實現多態。
繼續分析構成多態的另一個條件,為什么必須要指針或者引用才能構成多態?
- 如果將派生類對象賦值給基類對象,會因為對象切割,導致他的內存布局整個被修改,完全轉換為基類對象的類型,虛函數表也與基類相同,所以不能實現多態。
- 如果用基類指針或者引用指向派生類對象,他們的內存布局是兼容的,不會像賦值一樣改變派生類對象的內存結構,所以派生類對象的虛函數表得到了保留,所以他可以通過訪問派生類對象的虛函數表來實現多態。
總結一下派生類虛函數表的生成過程:
- 首先派生類會將基類的虛函數表拷貝過來
- 如果派生類完成了對虛函數的重寫,則用重寫后的虛函數覆蓋掉虛函數表中繼承下來的基類虛函數
- 如果派生類自己又新增了虛函數,則添加在虛函數表的最后面
常見問題解析
內聯函數可以是虛函數嗎?
不可以,內聯函數沒有地址,無法放進虛函數表中。
靜態成員函數可以是虛函數嗎?
不可以,靜態成員函數沒有 this指針
,無法訪問虛函數表。
構造函數可以是虛函數嗎?
不可以,虛函數表指針也是對象的成員之一,是在構造函數初始化列表初始化時才生成的
析構函數可以是虛函數嗎?
可以,上面有寫,最好把基類析構函數聲明為虛函數,防止使用基類指針或者引用指向派生類對象時,派生類的析構函數沒有調用,可能導致內存泄漏。
對象訪問虛函數快還是普通函數快?
- 如果不構成多態,虛函數和普通函數的訪問是一樣快的,都是直接在編譯時從符號表中找到函數的地址后調用。
- 如果構成多態,調用虛函數就得在運行期到虛函數表中查找,就會導致速度變慢,所以普通函數更快一些。
虛函數表
從上面的觀察可以看出來,虛函數存于虛函數表中,那么虛函數表又存儲在哪里呢?
虛函數表在編譯階段生成,存儲于代碼段。
詳情可以看這篇博客。
注意:
- 同一個類的不同實例(對象)共用同一份虛函數表。
- 子類
特有的虛函數
會加在父類虛函數表
中的父類虛函數的后面
。 - 如果子類繼承多個父類、且這些父類都有虛函數表,
子類特有的虛函數
加在第一個父類的虛函數表
中。 - 如果子類繼承多個父類、但只有部分父類有虛函數表,
子類特有的虛函數
加在第一個有虛函數表的父類
的虛函數表
中。 - 如果子類繼承多個父類、且這些父類都沒有虛函數表,子類會自己創建一個虛函數表來存儲特有的虛函數。