C++ 動態聯編和靜態聯編


C++ 動態聯編和靜態聯編

本文較長,非常詳細,主要關於動態聯編、靜態聯編和虛函數。建議前置閱讀如何理解基類和派生類的關系

當你寫了一個函數,程序運行時,編譯器會如何執行你的函數呢?

什么是聯編?

你會認為這個問題很弱智,代碼怎么寫的編譯器就怎么執行唄?這對於C語言來說是成立的,因為每一個函數名都對應一個不同的函數。但是C++由於引入了重載、重寫,一個函數名可能對應多個不同的函數。編譯器必須查看函數參數以及函數名才能確定具體執行哪個函數。

將源代碼中的函數調用解釋為執行特定的函數代碼塊的過程稱為函數名聯編。

意思就是,同一個名稱的函數有多種,聯編就是把調用和具體的實現進行鏈接映射的操作。

而聯編中,C++編譯器在編譯過程中完成的編譯叫做靜態聯編

靜態聯編

靜態聯編工作是在程序編譯連接階段進行的,這種聯編又稱為早期聯編,因為這種聯編實在程序開始運行之前完成的。在程序編譯階段進行的這種聯編在編譯時就解決了程序的操作調用與執行該操作代碼間的關系。

但是重載、重寫、虛函數使得這項工作變得困難。因為編譯器不知道用戶將選擇哪種類型的對象,執行具體哪一塊代碼。所以,編譯器必須生成能夠在程序運行時選擇正確的虛函數的代碼,這個過程被稱為動態聯編,又稱晚期聯編。

動態聯編

編譯程序在編譯階段並不能確切地指導將要調用的函數,只有在程序執行時才能確定將要調用的函數,為此要確切地指導將要調用的函數,要求聯編工作在程序運行時進行,這種在程序運行時進行的聯編工作被稱為動態聯編,或稱動態束定,又叫晚期聯編。

為了深入的探討聯編的內容,我們先從指針、引用和虛函數開始講起。

指針和引用的兼容性

C++中,動態聯編與指針、引用調用方法息息相關。從某個角度而言,動態聯編的產生和繼承如影隨形。繼承是如何處理指向對象的指針、引用我們已經有所討論基類與派生類。通常,C++不允許將一種類型的地址賦給另一種類型的指針。也不允許一種類型的引用指向另一種類型。

例如

double x = 3.14;
int* pi = &x;   // 不可以,int指針不可以指向double
long& ri = x;	// 不可以,long引用不可以引用double

但是,就像我們之前討論的,指向基類的引用或指針可以引用派生類對象,而不需要進行顯式轉換。

例如

Student terry("Terry", 18, male);  // 派生類對象
Person* p = &terry;		// ok
Person& r = terry;		// ok

這種,將派生類引用或指針轉換為基類引用或指針稱為向上強制轉換,這使得公有繼承不需要進行顯式類型轉換就可以通過基類指針或引用來引用派生類對象。這符合is-a規則,所有Student對象都是Person對象,因為Student對象繼承了Person對象所有的數據成員和成員函數。也就是說,可以對Person對象執行的任何操作,也適用於Student對象。因此,為處理Person對象引用而設計的函數可以對Student對象執行同樣的操作,而不必擔心出任何問題,這也是多態中最基礎的應用。將指針向對象的指針作為函數參數時,也是如此。同樣,向上強制轉換是可以傳遞的。Person對象肯定是Mammal對象,那么Student對象肯定也是Mammal對象,因為他們是由is-a關系一路傳遞下來的。所以,對於Mammal對象的操作,也適用於對Student對象的操作。

相反,將基類指針或引用轉換為派生類指針或引用,稱為向下強制轉換。如果不使用顯式類型轉換,則向下強制轉換是不允許的。原因很容易想明白,因為is-a關系是不可逆的。不是所有的Person都是Student,不是所有的Mammal都是Person。同樣,派生類肯定會新增數據成員和成員函數,這些數據成員的類成員函數不能應用於基類。例如,Student對象添加了scores數據表示學生的分數,添加了goSchool()方法表示去上學。但是這兩個成員對於Person來說毫無意義,我一個退休干部,是一個Person,但是退休干部不需要分數,也不需要上學。如果C++允許隱式向下強制轉換,則可能出現一些荒謬的問題。所以,C++只支持顯式向下強制轉換。

例如

class Person {
private:
	string name;
    ...
public:
	void printName();
    ...
};

class Student :public Person {
private:
	double scores;
    ...
public:
	void goSchool();
    ...
};

	...
	Person aPerson;
	Student aStudent;
	...
	Person* pp = &aStudent;				// 允許向上隱式轉換
	Student* ps = (Student*)&aPerson;   // 必須向下顯式轉換

	pp->printName();					// 向上轉換是安全操作,因為每個人都有名字
	ps->goSchool();						// 向下轉換是風險操作,不是每個人都要去上學

那么我們再看下面這段代碼

class Person {
	...
public:
	virtual void talk();	// 虛函數!!
};

class Student :public Person {
	...
public:
	void talk();
};
	...
void convertV(Person p);	// uses p.talk()
void convertR(Person& p);   // uses p.talk();
void convertP(Person* p);	// uses p->talk();
	...
	Student aStudent;
	...
	convertV(aStudent);		// Person::talk()
	convertR(aStudent);		// Student::talk()
	convertP(&aStudent);	// Student::talk()

我們的convert函數,按照值傳遞,即使我傳入了一個aStudent,一個Student類型的對象,它只會傳入Student對象的Person部分。但由於引用和指針發生的隱式向上轉換,且基類的talk方法是虛函數。導致函數convertR()和convertP()分別為Student對象使用了Student::talk()。

隱式向上強制轉換的存在,使得基類指針和引用可以指向派生類對象,因此需要動態聯編。即程序運行時,我才知道究竟要執行哪一個。C++通過虛函數來滿足這樣的需求。

虛成員函數和動態聯編

如果讀過我以前文章的朋友看到這里可能會有疑義。誒?之前這么調用明明調用的是基類方法啊!

沒錯,我們先回顧一下以前的用基類指針和引用調用派生類的過程。

class Person {
	...
public:
	void talk();			// 非虛函數!!
};

class Student :public Person {
	...
public:
	void talk();
};
	...
void convertV(Person p);	// uses p.talk()
void convertR(Person& p);   // uses p.talk();
void convertP(Person* p);	// uses p->talk();
	...
	Student aStudent;
	Person* pp = &aStudent;
	...
	convertV(aStudent);		// Person::talk()
	convertR(aStudent);		// Person::talk()
	convertP(&aStudent);	// Person::talk()
	pp->talk();				// Person::talk()

這是不是就是以前提到的?如果我們沒有在基類中將talk()定義為虛的,則調用talk()時,將根據指針類型和引用類型來調用Person::talk()。指針類型和引用類型在編譯時是已知的,因此編譯器在編譯時,可以將這里的talk()關聯到Person::talk()。總之,編譯器對非虛方法使用靜態聯編。

然而在基類中將talk()聲明為虛的,則調用talk()時,將根據指針、引用的類型(Student)調用Student::talk()。這個過程通常只有在運行過程中才能確定對象的類型,所以編譯器生成的代碼將在程序運行時,根據對象類型將talk()關聯到 Person::talk() 或 Student::talk()。總之,編譯器對虛方法使用動態聯編。

在絕大多數情況下,動態聯編很好,它很“高級”,能讓程序選擇特定類型設計的方法,那么,你肯定會問了:

  • 為什么有兩種類型的聯編?
  • 既然動態聯編這么好,那還要靜態聯編干嘛?
  • 動態聯編這也太強了,怎么實現的?

為什么有兩種類型的聯編以及為什么默認是靜態聯編

我們已經看到了,動態聯編很靈活,可以重新定義類的方法,可以很好的實現多態。那為什么還要保留靜態聯編呢,原因有二 —— 效率和概念模型。

首先是效率。這很好理解,如果一個程序在運行過程中進行決策,那必然需要一些手段來追蹤基類指針或引用指向的對象。這必然會增大系統的硬件開銷。例如,某些類壓根就不用於繼承,則完全不需要動態聯編。同樣,如果派生類不重寫基類的任何方法,那么完全沒必要使用動態聯編。這些情況下,靜態聯編更合理,效率也更高。C++可是出了名的高性能語言,由於靜態聯編效率更高,因此被設置為C++的默認選擇。畢竟C++的宗旨是,不要為不使用的特性付出代價(內存或時間)。所以動態聯編只有當程序設計確實需要虛函數時,才會使用。

其次是概念模型。在設計類時,可能包含一些不在派生類重新定義的成員函數。例如我們之前看到的Person::printName(),人都有名字,我學生沒必要重寫。所以這個函數就不應該被定義為虛函數,有兩方面的好處:第一是效率更高,第二是指出不需要重新定義的函數意味着應該將需要重新定義的函數聲明為虛的。

總結:如果在派生類中重新定義基類的方法,則將它設為虛方法;否則,設為非虛方法。

虛函數的工作原理

通常,C++編譯器處理虛函數的方法是:給每個對象添加一個隱藏成員。隱藏成員中保存了一個指向存放函數地址的數組的指針。這種數組稱為虛函數表(virtual function table, vtbl)。虛函數表中存儲了為類對象進行聲明的虛函數的地址。

例如:基類對象包含一個指針,該指針指向基類中所有虛函數的地址表。派生類對象將包含一個指向獨立地址表(也就是和基類無關)的指針。如果派生類提供了虛函數的新定義,該虛函數表將保存新函數的地址。如果派生類沒有重新定義虛函數,該vtbl將保存原始版本的地址(理論上和基類的虛函數表相同)。如果派生類定義了新的虛函數(指基類沒有的),則該函數的地址也將被添加到vtbl中。

注意,不論包含的虛函數是1個還是100個,都只需要在對象中添加一個地址成員,只是大小不同而已。

當調用虛函數時,程序將查看存儲在對象中的vtbl地址,然后轉向相應的函數地址表。如果類聲明中定義的第一個虛函數,則程序將使用數組中的第一個函數地址,並執行該地址對應的函數。如果使用類聲明中的第三個函數,程序將使用地址為數組中的第三個元素的函數。

所以可以發現,使用虛函數和動態聯編,無可避免地會增加內存和時間的開銷,

  • 每個對象都將增大,增大量為存儲地址的空間;
  • 對於每個類,編譯器都會創建一個虛函數地址表(本質上就是數組);
  • 對於每個函數調用,都需要執行一項額外的操作,即到表中查找地址。

有關虛函數的注意事項

  • 在基類方法的聲明中使用關鍵詞virtual可使該方法在基類以及所有派生類(包括派生類派生出來的類)中是虛的(也就是說派生類的派生類,也可覆蓋基類的虛函數);
  • 如果使用指向對象的引用或者指針來調用虛方法,程序將使用為對象類型定義的方法,而不是該引用或指針類型定義的方法。這稱為動態聯編(晚期聯編)。這種行為非常重要,使得基類引用或指針可以指向派生類對象;
  • 虛函數是C++中用於實現多態的機制,核心理念就是上條的通過基類訪問派生類定義的函數;
  • 如果定義的類作為基類,必須將那些在派生類中重新定義的類方法聲明為虛的。

以下針對一些特殊的函數做討論:

  1. 構造函數

派生類不會繼承基類的構造函數,自然構造函數不能是虛函數。每個類都會有自己的構造函數,創建派生類對象時先會調用派生類的構造函數,派生類的構造函數又會首先調用基類的構造函數。這個順序之前提到過很多了,因此虛的構造函數毫無意義。

  1. 析構函數

與構造函數相反,析構函數就應該是虛函數,除非這個類不會派生。

我們看如下代碼:

Person * pp = new Student;	// 合法的聲明
...
delete pp;					// ???

如果析構函數不是虛函數的話,那么會采用靜態聯編。delete語句將直接調用 ~Person() 析構函數,這會釋放這個Student對象中Person部分指向的內存,但是不會釋放新的類成員指向的內存。但是如果析構函數是虛的,則上述代碼將調用 ~Student()析構函數釋放由Student組件指向的內存,然后通過派生類的析構函數自動調用基類 ~Person() 析構函數來釋放指向Person組件指向的內存。

所以,通常應該給基類提供一個虛析構函數,即使它並不需要析構函數。

  1. 友元函數

友元函數不是類成員,所以不能是虛函數。

  1. 派生類不重新定義

如果派生類中沒有重新定義函數,將使用該函數的基類版本。如果派生類位於派生鏈中,則將使用最新的虛函數版本,例外的情況是基類的版本是隱藏的。(第五點會提到)

  1. 重新定義將隱藏方法

有以下代碼

class Person{
public:
    virtual void talk(int voice);
...
};

class Student : public Person{
public:
    virtual void talk();
...
};

我們可以看見,派生類新定義了一個不接受任何參數的函數。重新定義並不會生成talk()函數的兩個重載版本,而是隱藏了接受一個int參數的基類版本。也就是說,現在那個能傳參的基類的talk()方法已經沒用了。總之,重新定義繼承的方法並不是重載。如果在派生類中重新定義函數,將不是使用相同的函數特征標覆蓋基類聲明,而是隱藏同名的基類方法,不管參數特征標如何。

這條規則引出了兩條經驗:第一,如果要重新定義繼承的方法,應確保與原來的原型完全相同。但如果返回類型是基類的引用或指針,則可以修改為指向派生類的引用或指針(這是一種特例)。這種特性被稱為返回類型協變(covariance of return type),因為允許返回類型隨類型的變化而變化。

class Person{
public:
    // base class
    virtual Person* getAdd(int n);
    ...
};

class Student : public Person{
public:
  	// a derived class with a covariance return type
    virtual Student* getAdd(int n);  // same function signature
};

注意:這種返回模式只針對於返回值,而不適用於參數。

第二,如果基類聲明被重載了,則應在派生類中重新定義所有的基類版本。

class Person{
public:
    // three override talk() in base class
    virtual void talk();
    virtual void talk(int a);
    virtual void talk(char n);
    ...
};

class Student : public Person{
public:
  	// three redefined talk() in derived class
    virtual void talk();
    virtual void talk(int a);
    virtual void talk(char n);
};

為什么呢?因為如果在派生類只重定義一個版本,則編譯器會認為你觸發了隱藏機制,另外兩個沒有被重新定義的版本會被隱藏。

可以這么理解隱藏,如果說把基類的定義看作是一種秩序。那么當且僅當所有的重載都在派生類中實現,才不會打破這種秩序。如果秩序被打破了,那么編譯器認為派生類要創造一種新的秩序。所以會隱藏基類以前秩序,則別的沒有被重新定義的版本就會被隱藏。

如果你說你不想重新定義,不需要修改,就要用基類那個。那也必須寫出新定義,可以這么寫。

void Student::talk() {
    Person::talk();		// use function in base class
}

純虛函數

  • 什么是純虛函數?

純虛函數是指在基類中聲明的虛函數,它在基類中沒有定義,但要求任何派生類都要定義自己的實現方法。在基類中實現純虛函數的方法是在函數原型后面加=0

virtual ReturnType FunctionName()= 0;
  • 為什么需要純虛函數?
  1. 為了方便多態的使用,我們常需要在基類中定義純虛函數,讓各個派生類去實現。
  2. 在很多情況下,基類本身生成對象是很不合理的。例如,文具可以作為基類可以派生出鋼筆、鉛筆、橡皮擦等派生類。但是文具本身作為對象很不合理,什么是一個文具?

為了解決上述問題,引入了純虛函數的概念。

編譯器要求在派生類中必須予以重寫以實現多態性。同時含有純虛擬函數的類稱為抽象類,它不能生成對象。這樣就很好地解決了上述兩個問題。也就是,文具就是一個完全高高在上的概念,商店只會賣鋼筆、鉛筆、橡皮擦,但是不可能出售“文具”這個商品。

聲明了純虛函數的類是一個抽象類。所以,用戶不能創建類的實例,只能創建它的派生類的實例。

純虛函數最顯著的特征是:它們必須在繼承類中重新聲明函數(不要后面的=0,否則該派生類也不能實例化),而且它們在抽象類中往往沒有定義。

定義純虛函數的目的在於,使派生類僅僅只是繼承函數的接口。就好比你的電腦的USB接口,他正是因為沒有寫死要插入什么東西,所以又可以插U盤,又可以連接鼠標,又可以連接鍵盤。

純虛函數的意義,讓所有的類對象(主要是派生類對象)都可以執行純虛函數的動作,但類無法為純虛函數提供一個合理的默認實現。

所以類純虛函數的聲明就是在告訴子類的設計者,"你必須提供一個純虛函數的實現,但我不知道你會怎樣實現它"。

抽象類

抽象類是一種特殊的類,它是為了抽象和設計的目的為建立的,它處於繼承層次結構的較上層。

(1)抽象類的定義: 稱帶有純虛函數的類為抽象類。

(2)抽象類的作用: 抽象類的主要作用是將有關的操作作為結果接口組織在一個繼承層次結構中,由它來為派生類提供一個公共的根,派生類將具體實現在其基類中作為接口的操作。所以派生類實際上刻畫了一組子類的操作接口的通用語義,這些語義也傳給子類,子類可以具體實現這些語義,也可以再將這些語義傳給自己的子類。

(3)使用抽象類時注意:

  • 抽象類只能作為基類來使用,其純虛函數的實現由派生類給出。如果派生類中沒有重新定義純虛函數,而只是繼承基類的純虛函數,則這個派生類仍然還是一個抽象類。如果派生類中給出了基類純虛函數的實現,則該派生類就不再是抽象類了,它是一個可以建立對象的具體的類。
  • 抽象類是不能定義對象的。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM