詳解C++中的多態和虛函數
- 2021 年 7 月 7 日
- 筆記
一、將子類賦值給父類
在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) 中分析過類對象的內存模型,對象的內存只包含了成員變量,因此對象之間的賦值時成員變量的賦值,而成員函數不存在賦值的問題。 但是子類中的成員變量不僅包含父類中繼承的變量,也包含自己定義的變量,那麼在將子類賦值給父類的過程中,父類中沒有對應的內存空間,所以子類自己定義的變量會被捨棄。即在子類賦值給父類的過程中,父類只拿回屬於自己的那一部分,如下圖所示:
由於成員函數不存在對象的內存模型中,所以 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個位元組的地址,存的是每個虛函數的入口地址,這個就是虛指針。下圖中簡單地描述了一下帶有虛函數的類的內部結構:
從上圖中可以看到,子類繼承了父類並重寫了父類中的虛函數後,虛函數表的內部會更新為子類中的成員函數地址。所以在發生多態時(父類的引用指向了子類對象),會從虛函數表中找到對應子類對象的函數入口地址。