C++_多型(深入理解虛擬函式表)

2021-05-15 05:00:11

1. 什麼是多型

多型的概念:通俗來說,就是多種形態,具體點就是去完成某個行為,當不同的物件去完成時會產生出不同的狀態。
比如買票這個行為,當普通人買票時,是全價買票;學生買票時,是半價買票;軍人買票時是優先買票

2. 怎麼構成多型

在這裡插入圖片描述

在這裡插入圖片描述
並沒有構成多型,形參p物件,全部呼叫了Person類的成員函數。

在這裡插入圖片描述

2.1 多型與重寫

這時候就需要使用虛擬函式來構成多型。
梳理一下,多型的條件:

  1. 繼承類中,需要對虛擬函式進行重寫。
  2. 基礎類別的指標或者參照都去呼叫這個虛擬函式

而重寫的條件
3. 父子類中的函數都是虛擬函式。
4. 函數名引數返回值都要相同(有一個例外,那就是協變,基礎類別的虛擬函式返回基礎類別指標或參照。派生類指標或參照返回派生類指標或參照)

在這裡插入圖片描述
在這裡插入圖片描述
在這裡插入圖片描述

原本都會指向Person類的成員函數,但是當繼承類中對虛擬函式進行重寫,基礎類別的指標或參照去呼叫這個虛擬函式。此時這個指標或參照指向誰就呼叫誰的虛擬函式。這個基礎類別是個相對基礎類別。
即不滿足多型,p呼叫函數,p是什麼型別就呼叫哪個型別的函數。而滿足多型,基礎類別的指標或參照指向誰,就呼叫誰的虛擬函式。

但是注意,派生類中的虛擬函式可以不用寫virtual。會繼承下來,但是我覺得這是C++不嚴格的地方。顯示的帶上virtual更好。

2.2 虛擬函式重寫的兩個例外

2.2.1 協變

虛擬函式的返回值可以不相同,但是必須滿足,基礎類別的虛擬函式返回值為基礎類別的指標或參照,派生類的虛擬函式返回值為派生類的指標或參照。我們稱它為協變

2.2.2 解構函式

派生類的解構函式和基礎類別的解構函式實際是構成隱藏的,是因為編譯器將解構函式全部定義為destruct。解構函式沒有返回值,沒有引數,引數名相同。最好將基礎類別的解構函式定義為虛擬函式,那麼派生類的解構函式也會成為虛擬函式(最好加上virtual,不加也可以),構成重寫。

為什麼一定需要構成重寫呢?

假如有這樣一種場景
在這裡插入圖片描述
person型別的指標,指向一個student物件。那麼就會發生記憶體漏失

將函數宣告為虛擬函式,子類進行重寫。
在這裡插入圖片描述
此時不再看P的型別,而是看p指向的是什麼物件,然後呼叫student的解構函式,然後自動呼叫基礎類別的解構函式。

3. C++11中的兩個關鍵字

3.1 final

修飾一個函數,表示該函數不能被重寫(我們不寫,子類會預設帶上)
在這裡插入圖片描述
修飾一個類,表示該類不能被繼承
在這裡插入圖片描述

3.2 override

檢查派生類是否重寫了某個虛擬函式,如果沒有則報錯

在這裡插入圖片描述

4. 對比過載,重寫,重定義

在這裡插入圖片描述

5. 抽象類

5.1 抽象類無法範例化物件

在虛擬函式的後面寫上 =0 ,則這個函數為純虛擬函式。包含純虛擬函式的類叫做抽象類,抽象類是不能範例化出物件的。
在這裡插入圖片描述
在這裡插入圖片描述

而派生類直接繼承也不行(直接繼承你也是抽象類),必須在對它純虛擬函式進行重寫之後才能範例化出物件。
什麼適合被範例化出抽象類呢?人,植物,車,一些很寬泛的概念
,他們這個抽象類中有一些基本的概念,人的職業,植物的類別,車的品牌。然後讓派生類繼承,實現出具體的行為(人->教師,植物->牡丹,車->賓士)。

5.2 抽象類可以定義指標或者參照。

在這裡插入圖片描述
而這樣的行為也可以展現多型。因為派生類對函數完成了重寫,基礎類別的指標或者參照呼叫。
在這裡插入圖片描述

5.3 介面繼承與實現繼承

抽象類體現了介面繼承,介面繼承,當純虛擬函式是一個宣告的時候,我主要繼承你的,返回值,函數名,引數。

而實現繼承就是,你是一個完整的函數,我繼承你就是為了你裡面的實現,我不需要在寫,直接複用你的。

6. 多型的底層實現

在這裡插入圖片描述
32位元下,預設對齊數為4,此時類有一個虛擬函式表指標,還有一個int型別變數,所以sizeof大小為8。

6.1 虛擬函式表

6.1.1 父類別中的虛擬函式表

在這裡插入圖片描述

此時vfptr陣列指標,指向一個虛擬函式表,這個虛擬函式表實際上是一個陣列,他是一個虛擬函式指標陣列,即 陣列裡面儲存著指標,指標指向一個個函數。

當物件沒有初始化的時候,虛擬函式表也沒有初始化,說明物件裡面的虛擬函式表,是在物件初始化的地方才初始化的。他早早就已經建立,初始化就是把陣列首元素的值給你就好了。
在這裡插入圖片描述
時刻記住,虛擬函式表是一個指標陣列,一個個指標指向了程式碼段中的虛擬函式。func4不是虛擬函式,所以不在表內。
在這裡插入圖片描述
透過記憶體來看一下分佈
在這裡插入圖片描述
跟前面菱形繼承,沒有關係!!!!
菱形繼承是使用虛繼承來解決資料冗餘和二義性,使用虛基指標指向一個虛基表,虛基表裡面儲存著當前地址距離虛基礎類別物件的偏移量,讓原來的地址加偏移量就可以找到虛基礎類別物件。
而這裡的虛擬函式表指標,當物件初始化的時候,虛擬函式表也才會初始化,而且虛擬函式表只有一張。很顯然他是和虛擬函式一樣,都存在程式碼段中。

可以通過列印地址,來驗證
在這裡插入圖片描述
理論上可以 (int)b 但是不支援
在這裡插入圖片描述
可以看出顯然是和程式碼段更加接近。
雖然他是在物件初始化的時候初始化,那麼他是在什麼時候生成的呢,在編譯的時候生成的。

6.1.2 子類中的虛擬函式表

同一個型別用一張虛擬函式表,這個沒有問題。所以子類也是獨有一個虛擬函式表。需要注意的是,假如你多繼承,那就是繼承多張。繼承是複用,而不是共用。
在這裡插入圖片描述
所以可以這麼理解,子類直接將整個虛擬函式表深拷貝下來。當有虛擬函式被重寫,直接在上面覆蓋掉原來的虛擬函式地址。沒有被覆蓋的就留下。所以重寫是語法上的概念,而覆蓋是系統底層的概念。那假如子類一個虛擬函式都沒有重寫呢?雖然虛擬函式的地址都沒變,但是還會單獨生成一個虛擬函式表。

梳理一下,派生類虛表的生成過程,父類別中有虛擬函式,所以子類先會單獨生成一個虛擬函式表,然後深拷貝下來,假如子類重寫了某個虛擬函式,將重寫後的虛擬函式地址覆蓋原來的地址。沒有重寫的地址就不變。

7. 多型的原理

怎麼實現的多型呢?子類中重寫了父類別的虛擬函式,指向或參照子類物件的父類別的指標或參照呼叫這個虛擬函式。

為什麼麼非得是指標或參照呢?

當你是一個普通物件。那肯定是什麼型別就呼叫哪個型別的函數。

A a 或 B b

即使發生切片 ,由於a物件裡面永遠是基礎類別的虛擬函式表,他想實現多型都沒處實現

A a=b

而基礎類別型別指標或參照,指向一個子類物件,這時看到的就是子類物件的虛擬函式表。這樣就能呼叫子類的虛擬函式。

透過組合檢視
在這裡插入圖片描述

在這裡插入圖片描述
那麼假如有多個虛擬函式,我呼叫了不同的虛擬函式,底層是怎麼實現的呢?
也很簡單,按照順序+4位元組即可找到。而他的地址就是按宣告的順序放著的。
在這裡插入圖片描述

8. 靜態繫結與動態繫結

  1. 靜態繫結又稱為前期繫結(早繫結),在程式編譯期間確定了程式的行為,也稱為靜態多型,比如:函數過載
  2. 動態繫結又稱後期繫結(晚繫結),是在程式執行期間,根據具體拿到的型別確定程式的具體行為,呼叫具體的函數,也稱為動態多型。

所以什麼是多型呢?

多型分為靜態多型和動態多型,靜態多型編譯時就確定,動態多型是執行時在確定。

8.1 靜態多型

函數過載,例如

int i=1;
double j=1.1;
cout<<i;
cout<<j;

函數過載了double1型別和int型別,所以可以輸出不同型別。(當然,先實現了運運算元過載)。程式編譯期間確定了行為。

8.2 動態多型

子類對父類別的虛擬函式進行重寫,父類別的指標或參照呼叫這個虛擬函式。當程式執行起來時,才通過虛擬函式表來呼叫虛擬函式。

9. 多繼承中的虛擬函式表

單繼承中,剛才研究了,子類繼承父類別的虛擬函式表,假如有重寫了某個虛擬函式,會直接覆蓋掉地址,假如沒有重寫就保留。那麼假如子類自己新增了呢。

class Base
{
public:
	virtual void Func1()
	{
		cout << "Base::Func1()" << endl;
	}
	virtual void Func2()
	{
		cout << "Base::Func2()" << endl;
	}

private:
	int _b = 1;
	
};

class Derive:public Base
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}

	virtual void Func3()
	{
		cout << "Derive::Func3()" << endl;
	}
	void Func4()
	{
		cout << "Derive::Func4()" << endl;
	}
private:
	int _d = 2;

};

func1覆蓋,func2保留,自己寫的func3,func4在哪呢?
通過監視視窗看一下,好傢伙,func3,func4影子都沒有。
在這裡插入圖片描述
不用動腦子都知道,肯定是存在的,從記憶體角度看一下。
在這裡插入圖片描述
列印一下他的地址,但是該怎麼傳參呢。
在這裡插入圖片描述
然後main函數中可以呼叫,注意強轉
在這裡插入圖片描述

在這裡插入圖片描述
不太形象,怎麼能看出這是哪個函數呢?

其實我們既然拿到了函數的地址,那麼就可以突破限制,直接呼叫這個函數。(不在像以前一樣只有物件,或者其指標才能呼叫,那是語法上的概念)
在這裡插入圖片描述
兩個物件都呼叫一下,做個對比
在這裡插入圖片描述

在這裡插入圖片描述

在這裡插入圖片描述
這是子類新增的。

說清楚後,再來看多繼承下的虛擬函式表

#include<iostream>
using namespace std;
class Base
{
public:
	virtual void Func1()
	{
		cout << "Base::Func1()";
	}
	virtual void Func2()
	{
		cout << "Base::Func2()";
	}

private:
	int _b = 1;

};

class Base1
{
public:
	virtual void Func1()
	{
		cout << "Base1::Func1()";
	}
	virtual void Func2()
	{
		cout << "Base1::Func2()";
	}

private:
	int _b1 = 2;
};
class Derive :public Base,public Base1
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()";
	}

	virtual void Func3()
	{
		cout << "Derive::Func3()";
	}

private:
	int _d = 2;

};

int main()
{
	Base b;

	Base1 b1;

	Derive d;

	cout << sizeof(d) << endl;
	
	return 0;
}

先口算一下,d有多大。d繼承,b和b1的兩個虛擬函式表,8+8+4=20,對齊數為4所以直接放。
在這裡插入圖片描述

列印出來確實是20.

子類重寫了,func1,繼承了func2,自己寫的一個虛擬函式func3。

在這裡插入圖片描述
透過監視視窗看到

  1. func2處於下標1位置,一個是Base型別,一個是Base1型別,在兩個虛擬函式表中地址不同,這是正常的。

  2. 在前面我們知道由於子類自己寫的虛擬函式,但是監視視窗不顯示,繼承的func3,但是記憶體中是有對應地址的,第一個疑惑的點,那麼這個自己寫的虛擬函式地址會在這兩個虛擬函式表當中哪一個?還是都有呢?

  3. 第二個疑惑的點?子類重寫了繼承下來的func1,為啥是兩個地址,難道不該是一個地址直接覆蓋兩個虛擬函式表嗎?最詭異的是兩個地址,他還呼叫的是相同的函數
    在這裡插入圖片描述

先解決第一個問題,在前面我們寫了一個列印虛擬函式表的函數,這裡在複用一下,看看他在哪?
在這裡插入圖片描述
可以看到,自己實現的那個虛擬函式,地址是放到了Base類的虛擬函式表之中。
而與此同時,第二個問題還是沒能得到解答,因為在記憶體中,重寫之後的Func1仍舊是兩個地址,但呼叫同一個。

然後在經過F10偵錯,執行的時候也確實是進入了同一個函數。

那在直接對它取地址,好傢伙,不得了了,3套地址。
在這裡插入圖片描述
所以這裡我們可以推理得,雖然虛擬函式表裡的地址不一樣,但是在組合層面他們會jmp到一個地址處完成對同一個函數的呼叫。

練習

第一題

在這裡插入圖片描述
B:虛擬函式表簡稱虛表,虛基表是為了解決菱形繼承引入的,裡面存的是偏移量
D正確,父類別和子類,甚至子類中沒有重寫任何虛擬函式,都會生成不同的虛擬函式表。

第二題

在這裡插入圖片描述
參數列初始化的順序是宣告的順序,因為是先繼承的B,在繼承的C。
假如先繼承的C,在繼承B,那麼就會列印 A C B D

第三題

在這裡插入圖片描述
p為B型別,當p去呼叫test,由於test沒有重寫,是直接繼承下來,所以裡面的函數原封不動,this指標型別仍然是A,此時把p賦值給A*的this,就相當於

在這裡插入圖片描述
那不是應該列印B->0嗎,錯,其實虛擬函式是一種介面繼承,他將你的引數,返回值,形參列表繼承下來所以B類中,形參的預設引數不起作用,還是A中的val=1,所以列印的是B->1。

問答題

  1. 行內函式可以是虛擬函式嗎?
    可以,雖然行內函式編譯時展開,他沒有地址。
    但是不好驗證的是,在預設debug版本不會展開,因為展開的話就不能偵錯了,內聯相比於宏的優點,就是可以偵錯,所以debug是有地址的,但是release版本下會展開,但又因為內聯只是一種建議,編譯器會取消內聯。通過組合可以看到release版本來應該展開的函數,卻有了地址。所以肯定是捨棄了優化。但是實際選擇題中還是根據其他選項的對錯,再來選擇。
  2. 靜態成員函數可以是虛擬函式嗎?
    這肯定是不可以的,虛擬函式是為多型而生,多型的其中之一條件就是,父類別的指標或參照去呼叫這個虛擬函式,連this指標都沒有,沒法呼叫。
  3. 建構函式可以是虛擬函式嗎?
    不可以,虛擬函式表在建構函式的參數列初始化。虛擬函式為多型而生,你想呼叫這個建構函式,但是物件都沒有初始化,虛擬函式表指標也沒有初始化,不能呼叫。
  4. 解構函式可以是虛擬函式嗎?
    解構函式儘量寫成虛擬函式,因為普通定義場景沒有問題,假如定義一個基礎類別指標,指向一個子類物件A* a=new B;delete a時不構成多型就只會呼叫A的解構函式,從而造成記憶體漏失。定義成虛擬函式,由於構成重寫(解構函式名destructor),基礎類別的指標呼叫,所以構成多型,從而去呼叫B的解構函式,而B的解構函式又會自動呼叫A的解構函式。所以就解決了記憶體漏失問題。
  5. 虛擬函式表是在哪個階段生成?
    編譯階段生成,儲存在程式碼段中,建構函式參數列中初始化。
  6. 隱藏,子類體現了實現繼承,多型中的重寫,子類體現了介面繼承。
  7. 在建構函式和解構函式中呼叫虛擬函式不會呈現多型性
    假如滿足多型的條件,也不會呈現多型性,因為,在基礎類別構造的時候,子類還沒有初始化,那麼子類的虛擬函式表也就沒有初始化。而在解構函式中呼叫虛擬函式,父類別解構函式什麼時候執行呢,子類解構函式執行後,那麼子類已經執行解構函式了,虛擬函式表也不在了,所以都不會呈現多型。