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

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

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

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

什麼是聯編?數組

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

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

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

而聯編中,C++編譯器在編譯過程當中完成的編譯叫作靜態聯編性能

靜態聯編設計

靜態聯編工做是在程序編譯鏈接階段進行的,這種聯編又稱爲早期聯編,由於這種聯編實在程序開始運行以前完成的。在程序編譯階段進行的這種聯編在編譯時就解決了程序的操做調用與執行該操做代碼間的關係。指針

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

動態聯編

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

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

指針和引用的兼容性

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)使用抽象類時注意:

  • 抽象類只能做爲基類來使用,其純虛函數的實現由派生類給出。若是派生類中沒有從新定義純虛函數,而只是繼承基類的純虛函數,則這個派生類仍然仍是一個抽象類。若是派生類中給出了基類純虛函數的實現,則該派生類就再也不是抽象類了,它是一個能夠創建對象的具體的類。
  • 抽象類是不能定義對象的。
相關文章
相關標籤/搜索