詳解C++中的多型和虛擬函式

ZhiboZhao 發表於 2021-07-07
C++

一、將子類賦值給父類

在C++中經常會出現資料型別的轉換,比如 int-float等,這種轉換的前提是編譯器知道如何對資料進行取捨。類其實也是一種資料型別,也可以發生資料轉換,但是這種轉換隻有在 子類-父類 之間才有意義。並且只能將子類賦值給父類,子類的物件賦值給父類的物件,子類的指標賦值給父類的指標,子類的引用賦值給父類的引用。這在C++中稱為向上轉型。相反的稱為向下轉型,但是向下轉型有風險,本文只介紹向上轉型。

1.1 將子類物件賦值給父類物件

下面我們通過一個具體地例項來看一下:

class people{
protected:
	string name;
	int age;
public:
	people(string name, int age); // 有參建構函式宣告
	void display();
};
people::people(string name, int age){	// 有參建構函式定義
	this->name = name;
	this->age = age;
}
void people::display(){
	cout << "name: " << name << "\tage: " << age << "\t是個無業遊民。" << endl;
}

class teacher : public people{
private:
	int salary;	// 子類中不僅包含從父類中繼承的 name 和 age,還有自己本身的 salary
public:
	teacher(string name, int age, int salary);	// 子類中的有參建構函式宣告
	void display();
};

// 顯示呼叫父類中的有參建構函式來初始化從父類中繼承的 name 和 age 成員
teacher::teacher(string name, int age, int salary):people(name, age){
	this->salary = salary;
}
void teacher::display(){
	cout << "name: " << name << "\tage: " << age << "\t是個教師,收入為:" << salary << endl;
}
people p("張三", 24);	// 建立一個父類物件
p.display();	
teacher t("李四", 25, 5000);	// 建立一個子類物件
t.display();

p=t;	// 將子類物件賦值給父類物件
p.display();	// 顯示覆制後的父類物件

在上述例子中,首先定義了一個父類 people 和其子類 teacher,並將子類物件 t 賦值給父類物件 p,輸出結果如下:

name: 張三      age: 24 是個無業遊民。	// 沒有賦值之前的父類輸出
name: 李四      age: 25 是個教師,收入為:5000	// 沒有賦值之前的子類輸出
name: 李四      age: 25 是個無業遊民。	// 將子類賦值給父類之後的父類輸出

通過結果我們可以發現,將子類賦值給父類物件之後,父類物件相應的成員變數改變了,但是成員函式依舊沒有改變,還是父類中的成員函式。因為賦值的本質就是將現有的資料寫入已經分配好的記憶體中,我們在 詳解C++中繼承的基本內容 - ZhiboZhao - 部落格園 (cnblogs.com) 中分析過類物件的記憶體模型,物件的記憶體只包含了成員變數,因此物件之間的賦值時成員變數的賦值,而成員函式不存在賦值的問題。 但是子類中的成員變數不僅包含父類中繼承的變數,也包含自己定義的變數,那麼在將子類賦值給父類的過程中,父類中沒有對應的記憶體空間,所以子類自己定義的變數會被捨棄。即在子類賦值給父類的過程中,父類只拿回屬於自己的那一部分,如下圖所示:

詳解C++中的多型和虛擬函式

由於成員函式不存在物件的記憶體模型中,所以 p.display() 呼叫的永遠都是父類的 display()函式,即:物件之間的賦值不會影響成員函式,也不會影響 this 指標。

1.2 將子類指標賦值給父類指標

下面我們還根據上面的例子來解析一下指標之間的賦值:

people* p = new people("張三", 24);	// 建立指向父類物件的指標 p 
p->display();	// 顯示父類物件成員
cout << "p的地址為:" << p << endl;	// 輸出指標 p,即父類物件的地址

teacher* t = new teacher("李四", 25, 5000);	// 建立指向子類物件的指標 t 
t->display();	// 顯示子類物件成員
cout << "t的地址為:" << t << endl;	// 輸出指標 t,即子類物件的地址

p=t;	// 將子類物件指標賦值給父類物件指標
p->display();	// 顯示賦值後的父類物件成員
cout << "p的地址為:" << p << endl;	// 輸出賦值後的指標 p,即賦值後的父類物件地址

輸出結果為:

name: 張三      age: 24 是個無業遊民。	// 沒有賦值之前的父類輸出
p的地址為:014663B0	// 沒有賦值之前的父類物件地址
    
name: 李四      age: 25 是個教師,收入為:5000	// 沒有賦值之前的子類輸出
t的地址為:0146BA50	// 沒有賦值之前的子類物件地址
    
name: 李四      age: 25 是個無業遊民。	// 將子類指標賦值給父類指標之後的父類輸出
p的地址為:0146BA50	// 賦值之後的父類地址

通過輸出結果我們發現,通過指標賦值的方式與物件賦值的方式得到的輸出結果一致,即 p.display() 始終呼叫的都是父類中的成員函式。然而與物件變數之間的賦值不同的是,指標賦值其實只是改變了指標的指向,並沒有拷貝物件的成員,也沒有改變物件的資料。

1.3 將子類引用賦值給父類引用

在文章 C++中指標與引用詳解 - ZhiboZhao - 部落格園 (cnblogs.com) 中我們詳細解釋了C++中指標與引用的關係,因此我們可以大致得出結論:物件之間引用賦值的結果與物件之間指標賦值的結果時一致的,為了驗證猜想,我們定義如下例項:

people p("張三", 24);
teacher t("李四", 25, 5000);	// 建立了兩個物件

people &rp = p;	// 分別建立指向物件的引用
teacher &rt = t;

rp.display();	// 顯示賦值前引用的成員變數
rt.display();

rp = rt;	// 引用賦值
rp.display();	// 顯示賦值後的成員變數

輸出結果如下:

name: 張三      age: 24 是個無業遊民。
name: 李四      age: 25 是個教師,收入為:5000
name: 李四      age: 25 是個無業遊民。

二、多型的產生原因與實現

通過上一小節,我們可以發現:編譯器通過 指標(引用或者物件)來訪問成員變數,指標(引用或者物件)指向哪個物件就使用哪個物件的資料;編譯器通過指標(引用或者物件)的型別來訪問成員函式,指標(引用或者物件)屬於哪個類的型別就使用哪個類的函式。但是從直觀上來講,如果指標指向了派生類物件,那麼就應該使用派生類的成員變數和成員函式,這符合人們的思維習慣。但是上節的執行結果卻告訴我們,當基類指標 p 指向派生類 teacher 的物件時,雖然使用了 teacher 的成員變數,但是卻沒有使用它的成員函式,換句話說,通過基類指標只能訪問派生類的成員變數,但是不能訪問派生類的成員函式。

為了消除這種尷尬,讓基類指標能夠訪問派生類的成員函式,C++ 增加了虛擬函式(Virtual Function)。使用虛擬函式非常簡單,只需要在函式宣告前面增加 virtual 關鍵字。

我們將父類 person中的 display 函式改寫為虛擬函式,然後測試一下輸出結果:

class people{
protected:
	string name;
	int age;
public:
	people(string name, int age);
	virtual void display();	// 將父類中的display函式改寫為虛擬函式
};
people::people(string name, int age){
	this->name = name;
	this->age = age;
}
void people::display(){
	cout << "name: " << name << "\tage: " << age 
		<< "\t是個無業遊民。" << endl;
}
people* p = new people("張三", 24);	// 建立指向父類物件的指標 p 
p->display();	// 顯示父類物件成員

teacher* t = new teacher("李四", 25, 5000);	// 建立指向子類物件的指標 t 
t->display();	// 顯示子類物件成員

p=t;	// 將子類物件指標賦值給父類物件指標
p->display();	// 顯示賦值後的父類物件成員

輸出結果為:

name: 張三      age: 24 是個無業遊民。
name: 李四      age: 25 是個教師,收入為:5000
name: 李四      age: 25 是個教師,收入為:5000

有了虛擬函式,基類指標指向基類物件時就使用基類的成員(包括成員函式和成員變數),指向派生類物件時就使用派生類的成員。換句話說,基類指標可以按照基類的方式來做事,也可以按照派生類的方式來做事, 它有多種形態,或者說有多種表現方式,這種現象稱為多型(Polymorphism)

C++提供多型的目的是:可以通過基類指標對所有派生類(包括直接派生和間接派生)的成員變數和成員函式進行“全方位”的訪問,尤其是成員函式。如果沒有多型,我們只能訪問成員變數。上面我們提到過,通過指標呼叫成員函式時會根據指標的型別來判斷呼叫哪個類的成員函式,但是對於虛擬函式而言,其呼叫時根據指標的指向來確定的,即指標指向哪個類的物件,就呼叫哪個類的虛擬函式。

總的來說,多型的使用條件是:父類的指標或者引用指向子類物件。

三、多型的記憶體模型

我們都知道,類內的普通成員函式並不佔用物件的記憶體空間,當物件呼叫普通成員函式的時候,編譯器預設的將物件的地址作為函式引數的一部分(this 指標),從而找到對應的成員函式。所以個人觀點認為,當父類物件的指標指向子類物件時,再呼叫父類的成員函式,編譯器還是會把父類的 this 指標作為引數傳遞給成員函式,因此呼叫的還是父類的函式。那麼多型的實現基於虛擬函式,而虛擬函式的呼叫根據指標的指向來確定,那麼虛擬函式的記憶體模型是怎樣的呢?

我們再來看一個之前例子:

class A{
public:
	void show();	// A中只定義了普通成員函式
};
class B{
public:
	virtual void show();	// B中定義了虛擬函式
};
A a;
B b;
cout << "a 佔的記憶體空間為:" << sizeof(a) << endl;
cout << "b 佔的記憶體空間為:" << sizeof(b) << endl;

輸出結果為:

a 佔的記憶體空間為:1
b 佔的記憶體空間為:4

通過對比發現:A 中的普通成員函式只是佔了預先分配的一個位元組,而 B中的虛擬函式卻佔了4個位元組的地址,存的是每個虛擬函式的入口地址,這個就是虛指標。下圖中簡單地描述了一下帶有虛擬函式的類的內部結構:

詳解C++中的多型和虛擬函式

從上圖中可以看到,子類繼承了父類並重寫了父類中的虛擬函式後,虛擬函式表的內部會更新為子類中的成員函式地址。所以在發生多型時(父類的引用指向了子類物件),會從虛擬函式表中找到對應子類物件的函式入口地址。