系列文章目錄
文章目錄
- 系列文章目錄
- 摘要
- 一、基本概念
- 二、多態的分類
- 三、多態的實現
- 3.1 類型兼容與函數重寫
- 3.2 動態聯編與靜態聯編
- 3.3 虛函數
- 3.4 動態多態的實現過程
- 總結
- 參考文獻
摘要
多態性特征是 C++中最為重要的一個特征,熟練使用多態是學好 C++的關鍵,而理解多態的實現機制及實現過程則是熟練使用多態的關鍵。文章在分析多態性基本屬性的基礎上,結合具體程序實例重點分析了動態多態的實現機制,并結合虛函數
和編原理
分析了動態多態的實現過程。
關鍵詞: C++; 多態性; 虛函數
一、基本概念
封裝、繼承和多態是面向對象設計的 3 大特點。
- 封裝就是把客觀事物抽象得到的數據和行為封裝成一個整體,在
C++中,實現數據和行為封裝的程序單元就叫類。封裝就是將代碼模塊化,實現了類內部對象的隱蔽。 - 繼承是由已經存在的類創建新類的機制,體現在類的層次關系中,子類擁有父類中的數據和方法,子類繼承父類的同時可以修改和擴充自己的功能。
- 多態是指父類的方法被子類重寫、可以各自產生自己的功能行為。封裝和繼承的目的是代碼的重用,多態就是實現接口重用,即“一個接口,多種方法”。
相比封裝和繼承,多態因其復雜性、靈活性更難以掌握和理解。
二、多態的分類
C++中利用類繼承的層次關系來實現多態,通常是把具有通用功能的聲明存放在類層次高的地方,而把實現這一個功能的不同方法放在層次較低的類中,C++語言通過子類重定義父類函數來實現多態。
多態通常分為兩種: 通用多態
和特定多態
。
三、多態的實現
在 C++中利用類繼承的層次關系來實現多態,通常是把具有通用功能的聲明存放在類層次高的地方,而把實現這一個功能的不同方法放在層次較低的類中,C++語言通過子類重定義父類函數來實現多態
3.1 類型兼容與函數重寫
- C++中的繼承遵循了類型兼容性原則
即當子類以 Public方式繼承父類時,將繼承父類的所有屬性和方法,因此,可以變相的理解成子類是一種特殊的父類,可以使用子類對象初始化父類,也可以使用父類的指針或引用來調用子類的對象。 - C++中的函數重寫
在程序設計過程中,很多時候會出現這樣一種情況,子類繼承父類的 A 函數,但父類的 A 函數不能滿足子類的需求,此時需要在子類中對 A 函數進行重寫。C++中的函數重寫是指: 函數名、參數、返回類型均相同。
如果程序中類型兼容性原則遇到了函數重寫會怎么樣,調用父類的 A 函數還是子類中重寫的 A函數,類型兼容與函數重寫之間的關系可以用以下程序代碼闡釋
代碼示例:
#include<iostream>
using namespace std;class Animal // 父類
{
public:void Speak(){cout << "動物在說話" << endl;}
};
class Dog : public Animal // 子類
{
public:void Speak(){cout << "小狗在汪汪叫" << endl;}
};
int main()
{// 第一種定義Dog dog;dog.Speak();dog.Animal::Speak();// 第二種定義Animal animal1 = dog;animal1.Speak();// 第三種定義Animal* animal2 = &dog;animal2->Speak();return 0;
}
運行截圖:
上述程序中定義了 Animal 和 Dog 兩個類,其中,Dog 類以 Public 方式繼承了 Animal 類,并且重寫了
Speak( ) 方法。
- 根據程序運行結果不難看出: main( )函數中定義的 Dog 類對象 dog 的調用方法
dog.Speak( )
是通過子類對象的 Speak( ) 函數來實現小狗在汪汪叫功能。 - dog.Animal: : Speak( ) 是子類對象通過使用操作符作用域調用父類的 Speak( ) 函數來實現:
動物在說話。定義的 Animal 的對象 animal1 通過調用拷貝構造函數
,把 dog 的 數 據 拷 貝 到 animal1
中,animal1 仍為父類對象,所以animal1.Speak( )
執行的結果是動物在說話。 - 最終定義了一個指向 Animal 類的指針 animal2,將派生類對象 dog 的地址賦給父類指針 animal2,利用該變量調用
animal2 –>speak ( )
方法。得到的結果是: 動物在說話。
原因
a) C++編譯器進行了類型轉換,允許父類和子類之間進行類型轉換,即父類指針可以直接指向子類對象。根據賦值兼容,編譯器認為父類指針指向的是父類對象,因此,編譯結果只可能是調用父類中定義的同名函數。
b) 在此時,C++認為變量animal2中保存的就是 Animal 對象的地址,即編譯器不知道指針 animal2指向的是一個什么對象,編譯器認為最安全的方法就是調用父類對象的函數,因為父類和子類肯定都有相同的 Speak( )函數。因此,在 main() 函數中執行 animal2 –>Speak( ) 時,調用的是 Animal 對象的 Speak( ) 函數。
3.2 動態聯編與靜態聯編
- 以上程序出現這種情況的原因涉及 C++在具體編譯過程中函數調用的問題,這種確定調用同名函數的哪個函數的過程就叫做聯編( 又稱綁定) 。在C++中聯編就是指函數調用與執行代碼之間關聯的過程,即確定某個標識符對應的存儲地址的過程,在C++程序中,程序的每一個函數在內存中會被分配一段存儲空間,而被分配的存儲空間的起始地址則為函數的入口地址。
- 按照程序聯編所進行的階段,聯編可分為兩種:靜態聯編和動態聯編。
靜態聯編
就是在程序的編譯與連接階段就已經確定函數調用和執行該調用的函數之間的關聯。在生成可執行文件中,函數的調用所關聯執行的代碼是確定好的,因此,靜態聯編也稱為早綁定
。動態聯編
是在程序的運行時根據具體情況才能確定函數調用所關聯的執行代碼,因此,動態聯編也稱為晚綁定
。 - 當類型兼容原則與函數重寫發生沖突時,程序員希望根據程序設計的子類對象類型來調用子類對象的函數,而不是編譯器認為的調用父類的對象函數。也就是說,如果父類指針(引用) 指向( 引用) 父類的對象時,程序就應該調用父類的函數,如果父類指針( 引用) 指向( 引用)子類的對象時,程序就應該調用子類的函數。這一功能可以通過動態聯編實現。與靜態聯編相比,動態聯編是在程序運行階段,根據成員函數基于對象的類型不同,編譯的結果就不同,這就是動態多態。動態多態的基礎是虛函數。虛函數是用來表現父類和子類成員函數的一種關系。
3.3 虛函數
虛函數的定義方法是用關鍵字 virtual 修飾類的成員函數,虛函數的定義格式:
virtual〈返回值類型〉〈函數名〉( 〈形式參數表〉) { <函數體>}
在類的層次結構中,成員函數一旦被聲明為虛函數,那么,該類之后所有派生出來的新類中其都是虛函數。父類的虛函數在派生類中可以不重新定義,若在子類中沒有重新改寫父類的虛函數,則調用父類的虛函數。對兼容性與函數重寫程序,進行適當的修改,將父 類 Animal 中 的 Speak ( ) 函數使用關鍵子Virtual 將其定義為虛函數,代碼如下所示。
#include<iostream>
using namespace std;
class Animal // 父類
{
public:virtual void Speak() //用virtual 關鍵子定義 Speak() 為虛函數{cout << "動物在說話" << endl;}
};
class Dog : public Animal // 子類 Dog以public 方式繼承了 Animal
{
public:void Speak() //重寫了 Speak() 函數{cout << "小狗在汪汪叫" << endl;}
};
int main()
{Dog dog;dog.Speak();dog.Animal::Speak();Animal animal1 = dog;animal1.Speak();Animal* animal2 = &dog;animal2->Speak();return 0;
}
運行截圖:
Animal * animal2 = &dog,animal2.Speak( ) 時,由于在父類 Animal 的 Speak( ) 函數前加關鍵字 Virtual,
使得 Speak( ) 函數變成虛函數,編譯器在編譯的時候,發現 animal 類中有虛函數,此時,編譯器會為每個包含虛函數的類創建一個虛函數表,該表是一個一維數組,在這個數組中存放每個虛函數的地址,這樣就實現了動態聯編,也就是晚綁定。也就實現了前面說的當調用父類指針( 引用) 指向( 引用) 子類對象函數時,調用的是子類對象的函數,實現了動態多態。通過分析發現,要想實現動態多態
要滿足以下 3個條件:
- 必須存在繼承關系,程序中的 Dog 類以public 的方式繼承了 Animal 類。
- 繼承關系中必須要有同名的虛函數。在兩個類中 Speak( ) 函數為同名虛函數,子類重寫父類的虛函數。
- 存在父類的指針或引用調用子類該虛函數。
了解多態是如何實現的之前,先要了解虛函數的調用原理,虛函數的調用原理和普通函數不一樣,編譯器在程序編譯的時候,發現類中有關鍵字 virtual 的虛函數時,編譯器會自動為每個包含虛函數的類創建一個虛函數表用來存放類對象中虛函數的地址,并同時創建一個虛函數表指針指向該虛函數表[6]。每個類使用一個虛函數表,每個類對象用一個指向虛表地址的虛表指針。父類對象包含一個指針指向父類所有虛函數的地址,子類對象也包含一個指向獨立地址的指針。
如果子類沒有重新定義虛函數,該虛函數表將保存函數原始版本的地址,如果子類提供了虛函數的新定義,該虛函數表將保存新函數的地址。示例程序中定義了兩個類 A 和 B,類 B 繼承自類 A,父類 A
中定義了兩個虛函數,子類 B 中重寫了其中一個虛函數,代碼如下所示:
class A
{
public:virtual void fun1(){cout << " fun1 是類 A 虛函數";}virtual void fun2(){cout << " fun2 是虛類 A 函數";}
};
class B : public A
{
public:virtual void fun1(){cout << " fun1 是類 B 的虛函數";}
};
分析上述程序,對于父類 A 中的兩個虛函數 fun1( ) 和 fun2( ) ,由于子類 B 重寫了類 A 中的 fun1( ) 函
數,就導致子類 B 的虛函數表的第一個指針指向的是類 B 的 fun1( ) 的函數而不是父類 A 的 fun1( ) 函數,
具體如下表所示:
類 A 的虛函數表 | 類 B 的虛函數表 |
---|---|
0: 指向類 A 的 fun1 的指針 | 0: 指向類 B 的 fun1 的指針 |
1: 指向類 A 的 fun2 的指針 | 1: 指向類 A 的 fun2 的指針 |
3.4 動態多態的實現過程
編譯器進行編譯程序時發現有 virtual 聲明的函數,就會在這個類中產生一個虛函數表。即使子類中沒有用 virtual 定義虛函數,由于父類中的定義,子類通過繼承后仍為虛函數
。程序中 Animal 類和 Dog 類都包含一個虛函數 Speak( ) ,因此,編譯器會為這兩個類都建立一個虛函數表,將虛函數地址存放到該表中。
編譯器在為每個類創建虛函數表的同時,還為每個類的對象提供了一個虛函數表指針( vfptr) ,虛函數表指針指向了對象所屬類的虛表。根據程序運行的對象類型去初始化虛函數表指針。虛函數表指針在沒有初始化的情況下,程序是無法調用虛函數的。虛函數表的創建和虛函數表指針的始化是在構造函數中實現的
,在構造子類對象時,先調用父類的構造函數,并初始化父類的虛函數指針,指向父類的虛函數表,當子類對象執行構造函數時,子類對象的虛函數表指針也被初始化,指向子類的虛函數表。實現了在調用虛函數時,就能夠找到正確的函數,如下圖所示。
總結
多態性作為面向對象程序設計語言的 3 大要素之一,因其靈活性、伸縮性和復雜性而難以掌握。本文著重分析多態的分類、特征及動態多態的實現機制和原理,但本文對于動態多態的分析僅僅局限于單繼承的情況,對于多繼承的情況原理基本相同,本文未作過多說明。
參考文獻
[1]李家宏,孫慶英.C++多態性的實現過程[J].無線互聯科技,2023,19(02):131-134.
網址鏈接