學習整理——C++ virtual虛擬函式與多型
多型與動態繫結
多型(Polymorphism)按字面的意思就是“多種狀態”。在面嚮物件語言中,介面的多種不同的實現方式即為多型。引用Charlie Calverts對多型的描述——多型性是允許你將父物件設定成為一個或更多的他的子物件相等的技術,賦值之後,父物件就可以根據當前賦值給它的子物件的特性以不同的方式運作。簡單的說,就是一句話:允許將子類型別的指標賦值給父類型別的指標。
另一個與多型相關的一個名詞是動態繫結:動態繫結是指在執行期間(非編譯期)判斷所引用物件的實際型別,根據其實際的型別呼叫其相應的方法。程式執行過程中,把函式(或過程)呼叫與響應呼叫所需要的程式碼相結合的過程稱為動態繫結。Java是一個純面向物件的語言,要理解動態繫結,可以簡單寫一段程式碼。
public class Poly { public static void main(String[] arg){ Animal animal=getAnimal(System.currentTimeMillis()); animal.shout(); } private static Animal getAnimal(long tsp){ if(tsp % 2 == 0){ return new Cat(); }else{ return new Dog(); } } } abstract class Animal{ abstract public void shout(); } class Cat extends Animal{ @Override public void shout(){ System.out.println("Miao"); } } class Dog extends Animal{ @Override public void shout(){ System.out.println("Wang"); } }
上述程式碼中,在編譯期我們無法確定main()函式中的animal.shout()會呼叫的是輸出Miao的還是Wang的函式,只有在執行期通過對當前時間戳的判斷才會唯一確定,這就是動態繫結。按照這種思路,或許我們很容易將這個Java程式碼寫成C++的程式碼。
但是,無論輸入的flag是多少,輸出都會是Not implement。這是因為C++是編譯性語言,在編譯期,由於宣告的animal變數是Animal指標,所以編譯器在編譯animal->shout()時,直接引用了Animal類的shout()函式,執行時就呼叫了該函式,輸出Not implement。由於Cat和Dog都是繼承自Animal,所有兩者都會包含Animal的shout()函式,只不過是被重寫了而在一般情況下不可見。為了解決這種問題,做到上面Java程式碼的類似效果,C++引入了virtual關鍵字,正確的寫法是:#include <iostream> using namespace std; class Animal{ public: void shout(){ cout << "Not implement" << endl; } }; class Cat:public Animal{ public: void shout(){ cout << "Miao" << endl; } }; class Dog:public Animal{ public: void shout(){ cout << "Wang" << endl; } }; Animal* getAnimal(int flag){ if(flag % 2){ return new Cat(); }else{ return new Dog(); } } int main(){ int flag; cin >> flag; Animal* animal = getAnimal(flag); animal->shout(); return 0; }
#include <iostream>
using namespace std;
class Animal{
public:
virtual void shout(){
cout << "Not implement" << endl;
}
virtual ~Animal(){}
};
class Cat:public Animal{
public:
void shout(){
cout << "Miao" << endl;
}
};
class Dog:public Animal{
public:
void shout(){
cout << "Wang" << endl;
}
};
Animal* getAnimal(int flag){
if(flag % 2){
return new Cat();
}else{
return new Dog();
}
}
int main(){
int flag;
cin >> flag;
Animal* animal = getAnimal(flag);
animal->shout();
return 0;
}
實現原理
C++
在C++中通過虛擬函式表的方式實現多型,每個包含虛擬函式的類都具有一個虛擬函式表(virtual table),在這個類物件的地址空間的最靠前的位置存有指向虛擬函式表的指標。在虛擬函式表中,按照宣告順序依次排列所有的虛擬函式。例項化一個物件時呼叫完建構函式後,便會初始化這些指標。由於C++在執行時並不維護型別資訊,所以在編譯時直接在子類的虛擬函式表中將被子類重寫的方法替換掉。當程式中將要呼叫一個虛擬函式時,編譯器會編譯成從指標中找到具體的實現函式。因為虛擬函式指標在物件例項化時就被初始化了,所以即便是該物件被一個父類指標指向也能找到正確的函式。
另外,一日為虛終生為虛。一個函式被宣告為虛時,其衍生類的該函式也將一直是虛擬函式,即使衍生類沒有顯式宣告為virtual。
Java
而Java中,在執行時會維持型別資訊以及類的繼承體系。每一個類會在方法區中對應一個數據結構用於存放類的資訊,可以通過Class物件訪問這個資料結構。其中,型別資訊具有superclass屬性指示了其超類,以及這個類對應的方法表(其中只包含這個類定義的方法,不包括從超類繼承來的)。而每一個在堆上建立的物件,都具有一個指向方法區型別資訊資料結構的指標,通過這個指標可以確定物件的型別。
JVM中用於方法呼叫的指令包括:
invokevirtual:用於呼叫例項方法,會根據物件的實際型別進行呼叫。
invokespecial:需要特殊處理的例項方法,比如:public final方法、私有方法和父類方法等。呼叫的方法取決於引用的型別。
invokeinterface:呼叫介面的方法。
invokestatic:呼叫類方法。
按照上面描述,對於子類覆蓋父類的方法,編譯後,呼叫指令應該是invokevirtual,呼叫的方法取決於物件的型別。invokevirtual方法查詢的實現方式是:
1. 通過物件中類指標找到其類資訊,然後在方法表中根據方法簽名找到該方法。
2. 如果不在當前類,則遞迴查詢其父類的方法表直到Object類。
3. 如果找到Object類,也沒有該方法,會丟擲NoSuchMethodException異常。
與js、lua等動態語言類似,Java多型的實現方式依賴於記憶體中的型別體系資訊,存在一個“原型鏈”,是一個完全動態的查詢過程,相對於C++而言,效率會低一些,因為存在一個連結串列遍歷查詢的過程。之所以,Java中可以這樣實現,本質上是因為它是一門虛擬機器語言,虛擬機器會維持所有的這些型別資訊。
純虛擬函式
虛擬函式為C++提供實現動態繫結的機制,但是擁有虛擬函式的類本身是可以被例項化的。要實現類似Java抽象類的機制,即類本身不能例項化,其子類需要實現其所有抽象方法才能被例項化,C++提供了純虛擬函式。在C++中,擁有純虛擬函式的類不能被例項化,其子類也需要實現其所有純虛擬函式才能被例項化。純虛擬函式的寫法是:
virtual void shout()=0; //純虛擬函式
virtual void toShout(){}; //虛擬函式
只擁有純虛擬函式可以被用作介面來使用,提供給其衍生類才實現,類似Java的interface。
問題
1.建構函式中可以呼叫虛擬函式嗎?解構函式中可以呼叫虛擬函式嗎?
如果在父類建構函式中呼叫虛擬函式,由於子類還沒構造,所以可能會出錯,但由於C++避免這種錯誤,所以會呼叫父類的實現,但這不是多型;在子類中呼叫虛擬函式,由於子類已經構造了,此時呼叫虛擬函式跟普通方法沒有任何區別。
如果在子類解構函式中呼叫虛擬函式,情況如上,和呼叫普通方法沒有任何區別;而在父類解構函式中呼叫虛擬函式,如果只是虛擬函式,情況也如上,C++會呼叫父類的實現,但如果是純虛擬函式,編譯期編譯器就會報錯,出現undefined reference。
所以不要在建構函式和解構函式裡呼叫虛擬函式,即使沒有語法錯誤,執行也不會如你所願,就算有時會成功(不同編譯器),到最後也會讓人困惑不已。這種呼叫根本不是起到虛擬函式的作用,所以為了程式碼規範,可以說成是不可以呼叫。
2.建構函式可以是虛擬函式嗎?
不可以。虛擬函式指標是存放在物件記憶體中,一個物件在例項化(構造)前是沒有記憶體的。如果建構函式都是虛的時候,該虛建構函式指標能夠放在哪裡?該問題編譯器能夠查看出來,是語法錯誤。
3.解構函式可以是虛擬函式嗎?
當一個類擁有虛擬函式的時候,解構函式應當也被宣告為virtual。因為當使用多型時,父類的解構函式沒有宣告為virtual而呼叫delete時,會直接呼叫父類解構函式,子類沒有被呼叫。為了程式的正確執行,防止只析構基類而不析構派生類的狀況發生,應當記住這個規範。