C++多型與虛擬函式詳解
1. 多型與虛擬函式快速入門
在講 C++繼承與派生時講到,基類的指標可以指向派生類物件,其使用的是派生類物件的資料,但呼叫的卻是根據指標的型別呼叫成員函式。
對此 C++ 提供了虛擬函式,當基類指標指向基類物件時,使用基類的成員(包括成員函式和成員變數),當基類指標指向派生類物件時,使用派生類的成員。也就是說,虛擬函式可以讓基類指標,真正按照指標所指物件的型別來操作,或者說有多種表現方式,這種現象稱為多型(Polymorphism)。
C++提供多型的目的是:可以通過基類指標對所有派生類(包括直接派生和間接派生)的成員變數和成員函式 進行“全方位”的訪問,尤其是成員函式。如果沒有多型,我們只能訪問成員變數。
同樣藉助引用也可以實現多型,引用本質上是對指標的封裝。
class People { public: People(string name, int age); virtual void display(); //宣告為虛擬函式 protected: string m_name; int m_age; }; People::People(string name, int age): m_name(name), m_age(age){} void People::display(){ cout << m_name << "今年" << m_age << "歲了,是個無業遊民。"<< endl; } class Teacher : public People { public: Teacher(string name, int age); virtual void display(); //宣告為虛擬函式 private: int m_salary; }; Teacher::Teacher(string name, int age, int salary): People(name, age), m_salary(salary){} void Teacher::display(){ cout<<m_name<<"今年"<<m_age<<"歲了,是一名教師,每月有"<<m_salary<<"元的收入。"<<endl; } int main() { People *p = new People("張三", 25); p -> display(); // 基類 People:張三今年 25 歲了,是個無業遊民。 p = new Teacher("李四", 45, 8200); p -> display(); // 派生類 Teacher:李四今年 45 歲了,是一名教師,每月有 8200 元的收入。 return 0; }
2. 虛擬函式注意事項
注意事項
- 可以只在函式的宣告處加 virtual 關鍵字,定義出可加可不加;
- 可以只將基類中的函式宣告為虛擬函式,派生類具有遮蔽關係的同名函式會自動成為虛擬函式。(只要派生類成員函式與基類名字一樣,就會造成遮蔽,遮蔽與引數無關);
- 如果基類定義了虛擬函式,派生類未重新定義此函式來遮蔽,將使用基類的虛擬函式;所以只有派生類的虛擬函式覆蓋基類的虛擬函式(過載,原型相同)才能構成多型;
- 建構函式不能是虛擬函式,因為建構函式不能繼承,且建構函式用於建立物件時初始化,在建構函式執行前物件未建立,虛擬函式表不存在。
- 解構函式可以宣告為虛擬函式,有時也必須宣告為虛擬函式。
構成多型的條件
- 存在繼承關係;
- 繼承關係中虛擬函式必須時覆蓋關係(原型相同);
- 通過基類指標呼叫虛擬函式
3. 虛解構函式的必要性
先看一個例子:
class Base{
public:
Base();
~Base();
protected:
string str;
};
// ... 省略函式定義
class Derived : public Base{
public:
Derived();
~Derived();
private:
string name;
};
// ...
int main(){
Base *pb = new Derived(); // 呼叫基類和派生類的建構函式
delete pb; // 只調用基類的解構函式。非虛擬函式,呼叫指標的型別的方法,使用指標所指型別的資料
cout << "------------------" << endl;
Derived *pd = new Derived(); // 呼叫基類和派生類的建構函式
delete pd; // 呼叫派生類和基類的解構函式,派生類解構函式始終會呼叫基類的解構函式。
return 0;
}
執行結果
Base constructor
Derived constructor
Base destructor
------------------
Base constructor
Derived constructor
Derived destructor
Base destructor
delete
pb 只會呼叫基類的解構函式,沒有呼叫派生類解構函式,會造成記憶體洩漏。
可以將基類的解構函式宣告為虛擬函式
class Base{
public:
Base();
virtual ~Base();
protected:
string str;
};
執行結果
Base constructor
Derived constructor
Derived destructor
Base destructor
------------------
Base constructor
Derived constructor
Derived destructor
Base destructor
將基類的解構函式宣告為虛擬函式後,派生類的解構函式也會自動成為虛擬函式,這時候基類指標會根據指標所指資料的實際型別來呼叫函式,所以此時會呼叫派生類的解構函式,繼而再呼叫基類的解構函式。派生類解構函式始終會呼叫基類的解構函式。
大部分情況都應該將基類的解構函式宣告為虛擬函式。
4. 純虛擬函式和抽象類
C++ 中可以將虛擬函式宣告為純虛擬函式,語法格式為:
virtual 返回值型別 函式名 (函式引數) = 0;
= 0 只起形式作用,告訴編譯系統這是純虛擬函式。
包含純虛擬函式的類稱為抽象類,無法例項化。
// 線,抽象類
class Line{
public:
Line(float len);
virtual float area() = 0;
virtual float volume() = 0;
protected:
float m_len;
};
Line::Line(float len): m_len(len){ }
派生類必須實現抽象類的全部純虛擬函式,也就是實現抽象類的全部功能,才能例項化。
抽象基類除了約束派生類的功能,還能實現多型。
- 一個純虛擬函式就可以使類稱為抽象基類,但抽象基類除了包含純虛擬函式外,還可以包含其他成員函式和成員變數;
- 只有類中的虛擬函式才能被宣告為純虛擬函式,普通成員函式和頂層函式均不能宣告為純虛擬函式。
5. 虛擬函式表
虛擬函式實現多型時,編譯器之所以能通過指標指向的物件找到虛擬函式,是因為在建立物件時額外增加了虛擬函式表。
如果一個類包含虛擬函式,那麼在建立該類的物件時會額外增加一個數組,陣列中的每一個元素都是虛擬函式入口地址。
陣列和物件是分開儲存的,為了將物件和陣列關聯起來,編譯器還要在物件中安插一個指標,指向陣列的起始位置。這裡的陣列就是虛擬函式表(Virtual function table,vtable)。
class People{
public:
People(string name, int age);
virtual void display();
virtual void eating();
protected:
string m_name;
int m_age;
};
// ...
class Student : public People{
public:
Student(string name, int age, float score);
virtual void display();
virtual void examing();
protected:
float m_score;
};
// ...
class Senior : public Student{
public:
Senior(string name, int age, float score, bool hasJob);
virtual void display();
virtual void partying();
private:
bool m_hasJob;
};
// ...
int main(){
People *p = new People("趙紅", 29);
p -> display();
p = new Student("王剛",16,84.5);
p -> display();
p = new Senior("李智",22, 92.0, true);
p-> display();
return 0;
}
執行結果:
Class People:趙紅今年 29 歲了。
Class Student:王剛今年 16 歲了,考了 84.5 分。
Class Senior:李智以 92 的成績從大學畢業了,並且順利找到了工作,Ta 今年 22 歲。
各個類的物件記憶體模型如下所示:
- 在每個物件的開頭有一個指標 vfptr,指向虛擬函式表,並且這個指標始終位於物件的開頭位置。
- 基類的虛擬函式在 vtable 中的索引是固定的,不會隨著繼承層次的增加而改變,派生類新增的虛擬函式會在表後面。
- 如果派生類有同名的虛擬函式遮蔽了基類的虛擬函式,那麼將使用派生類的虛擬函式替換基類的虛擬函式,具有遮蔽關係的虛擬函式在 vtable 中只會出現一次。
當通過指標呼叫虛擬函式時,先根據指標找到 vfptr,再根據 vfptr 找到虛擬函式的入口地址。以虛擬函式 display() 為例,它在 vtable 中的索引為 0,通過 p 呼叫時:
p -> display();
編譯器內部會發生類似轉換:
(((p+0)+0))(p);
- 0時 vfptr 在物件中的偏移, p+0 時 vfptr 地址;
- *(p+0)是 vfptr 的值,而 vfptr 是指向 vtable 的指標,所以*(p+0) 就是 vtable 的地址;
- display() 在 vtable 中的索引(下標)是 0,所以( *(p+0) + 0 )也就是 display() 的地址;
- ( *( *(p+0) + 0 ) )(p)也就是對 display() 的呼叫了,這裡的 p 就是傳遞的實參,它會賦值給 this 指標。
轉換後的表示式其實是固定的,只要呼叫這個函式,不管是哪個類,都會使用這個表示式。轉換後的表示式沒有用到與 p 的型別有關的資訊,只要知道 p 的指向就可以呼叫函式,這跟名字編碼(Name Mangling)演算法有著本質上的區別。
以上是針對單繼承進行的講解,當存在多繼承時,虛擬函式表的結構會變得很複雜,尤其有虛繼承時,還會增加虛基類表。
6. typeid 運算子獲取型別資訊
型別資訊是建立資料的模板,資料佔多大記憶體、能進行什麼樣的操作、該如何操作等,都是由它的型別資訊決定。
typeid 的操作物件既可以是表示式,也可以是資料型別,與 sizeof 運算子使用非常類似,只不過 sizeof 有時可以省略括號,typeid 必須帶上。
typeid(dataType)
typeid(expression)
typeid 會把獲取到的型別資訊儲存到一個 type_info 型別的物件裡面,並返回該物件的常引用;當需要具體的型別資訊時,可以通過成員函式來提取。
int main(){
int n = 100;
const type_info &ninfo = typeid(n);
cout<<nInfo.name()<<" | "<<nInfo.raw_name()<<" | "<<nInfo.hash_code()<<endl;
//獲取一個字面量的型別資訊
const type_info &dInfo = typeid(25.65);
cout << dInfo.name()<<" | "<<dInfo.raw_name()<<" | "<<dInfo.hash_code()<<endl;
//獲取一個物件的型別資訊
Base obj;
const type_info &objInfo = typeid(obj);
cout<<objInfo.name()<<" | "<<objInfo.raw_name()<<" | "<<objInfo.hash_code()<<endl;
//獲取一個表示式的型別資訊
const type_info &expInfo = typeid(20 * 45 / 4.5);
cout<<expInfo.name()<<" | "<<expInfo.raw_name()<<" | "<<expInfo.hash_code()<<endl;
}
- name() 用來返回型別的名稱。
- raw_name() 用來返回名字編碼(Name Mangling)演算法產生的新名稱。
- hash_code() 用來返回當前型別對應的 hash 值。
C++ 標準規定,type_info 類至少要有如下所示的 4 個 public 屬性的成員函式,其他的擴充套件函式編譯器開發 者可以自由發揮,不做限制。
- 原型:const char* name() const;
- 原型:bool before (const type_info& rhs) const;
- 原型:bool operator== (const type_info& rhs) const;
過載運算子“==”,判斷兩個型別是否相同,rhs 引數是一個 type_info 物件的引用。- 原型:bool operator!= (const type_info& rhs) const;
過載運算子“!=”,判斷兩個型別是否不同,rhs 引數是一個 type_info 物件的引用。
大部分情況下只使用過載過的 == 運算子來判斷兩個型別是否相同。
判斷型別是否相等
string str;
int a = 2;
int b = 10;
float f;
判斷結果:
typeid 返回 type_info 物件的引用,而表示式 typeid(a) == typeid(b)的結果為 true,可以說明,一個型別不管使用了多少次,編譯器都只為它建立一個 type_info 物件,所有 typeid 都返回這個物件的引用。
需要提醒的是,為了減小編譯後文件的體積,編譯器不會為所有的型別建立 type_info 物件,只會為使用了 typeid 運算子的型別建立。不過有一種特殊情況,就是帶虛擬函式的類(包括繼承來的),不管有沒有使用 typeid 運算子,編譯器都會為帶虛擬函式的類建立 type_info 物件。
Base obj1;
Base *p1;
Derived obj2;
Derived *p2 = new Derived;
p1 = p2;
判斷型別結果為:
表示式 typeid(p1) == typeid(Base)和 typeid(p1) == typeid(Base)的結果為 true 可以說明:即使將派生 類指標 p2 賦值給基類指標 p1,p1 的型別仍然為 Base*。
type_info 類的宣告
type_info 類位於 typeinfo 標頭檔案,宣告形式類似於:
class type_info
{
public:
virtual ~type_info();
int operator==(const type_info& rhs) const;
int operator!=(const type_info& rhs) const;
int before(const type_info& rhs) const;
const char* name() const;
const char* raw_name() const;
private:
void *_m_data;
char _m_d_name[1];
type_info(const type_info& rhs);
type_info& operator=(const type_info& rhs);
};
它的建構函式是 private 屬性的,所以不能在程式碼中直接例項化,只能由編譯器在內部例項化(藉助友元)。 而且還過載了“=”運算子,也是 private 屬性的,所以也不能賦值。
7. RTTI(執行時型別識別)機制
class Base{
// ...
};
class Derived : public Base{
// ...
};
int main(){
Base *p;
int n;
cin >> n;
if(n<10>)
p = new Base();
else
p = new Derived();
//根據不同的型別進行不同的操作
if(typeid(*p) == typeid(People))
cout<<"I am human."<<endl;
else
cout<<"I am a student."<<endl;
上述程式中編譯器在編譯階段無法確定 p 指向的物件型別,需要在執行後才能確定型別資訊,這種在程式執行後確定物件的型別資訊的機制稱為執行時型別識別(Run-Time Type Identification,RTTI)。
C++ 物件的記憶體模型主要包含以下幾個方面內容:
- 如果沒有虛擬函式也沒有虛繼承,那麼物件記憶體模型中只有成員變數。
- 如果類包含了虛擬函式,那麼會額外新增一個虛擬函式表,並在物件記憶體中插入一個指標,指向這個虛擬函式表。
- 如果類包含了虛繼承,那麼會額外新增一個虛基類表,並在物件記憶體中插入一個指標,指向這個虛基類表。
如果類包含虛擬函式,該物件記憶體中還會存在額外的型別資訊,即 type_info 物件。以上面程式碼為例,Base 和 Derived 物件的記憶體模型如下:
編譯器會在虛擬函式表 vftable 的開頭插入一個指標,指向當前類對應的 type_info 物件。當程式在執行階段獲取型別資訊時,可以通過物件指標 p 找到虛擬函式表指標 vfptr,再通過 vfptr 找到 type_info 物件的指標,進而取得型別資訊。下面的程式碼演示了這種轉換過程:
**(p->vfptr - 1)
編譯器在編譯階段無法確定 p 指向哪個物件,也就無法獲取*p 的型別資訊,但是編譯器可以在編譯階段做好各 種準備,這樣程式在執行後可以藉助這些準備好的資料來獲取型別資訊。這些準備包括:
- 建立 type_info 物件,並在 vftable 的開頭插入一個指標,指向 type_info 物件。
- 將獲取型別資訊的操作轉換成類似**(p->vfptr - 1)這樣的語句。
多型(Polymorphism)是面向物件程式設計的一個重要特徵,它極大地增加了程式的靈活性,C++、C#、Java 等“正統的”面向物件程式語言都支援多型。但是支援多型的代價也是很大的,有些資訊在編譯階段無法確定下來,必須提前做好充足的準備,讓程式執行後再執行一段程式碼獲取,這會消耗更多的記憶體和 CPU 資源。
8. 靜態繫結和動態繫結
CPU 訪問記憶體時是通過地址,而不是變數名和函式名,變數名和函式名只是地址的一種助記符。當原始檔被編譯和連結成可執行程式後,它們都會被替換成地址。編譯和連結過程的一項重要任務就是找到這些名稱所對應的地址。
可以將變數名和函式名統稱為符號(Symbol),找到符號對應的地址的過程叫做符號繫結。
函式呼叫實際上是執行函式體中的程式碼,函式體是記憶體中的一個程式碼段,函式名是該程式碼段的首地址,也就是函式的入口地址。
找到函式名對應的地址,然後將函式呼叫處用該地址替換,這稱為函式繫結。
靜態繫結:在編譯/連結期間找到函式名對應地址完成函式繫結
動態繫結:需要等到程式執行後根據具體的環境或者使用者操作才能決定使用哪個函式
動態繫結的本質:編譯器在編譯期間不能確定指標指向哪個物件,只能等到程式執行後根據具體的情況 再決定。