什麼是多型?它的實現機制是什麼呢?重載和重寫的區別在那裡?這就是這次我們要回顧的四個十分重要的概念:繼承、多型、重載和重寫。
繼承(inheritance)
簡單的說,繼承就是在一個現有類型的基礎上,透過增加新的方法或重新定義已有方法(下面會講到,這種方式叫重寫)的方式,產生一個新的類型。繼承是物件導向的三個基本特徵--封裝、繼承、多態的其中之一,我們在使用JAVA時所寫的每一個類別都是在繼承,因為在JAVA語言中,java.lang.Object類別是所有類別最根本的基底類別(或稱為父類別、超類別),如果我們新定義的類別沒有明確地指定繼承自哪個基底類,那麼JAVA就會預設為它是繼承自Object類別的。
我們可以把JAVA中的類別分為以下三種:
類別:使用class定義且不含抽象方法的類別。
抽象類別:使用abstract class定義的類別,它可以含有,也可以不含抽象方法。
介面:使用interface定義的類別。
在這三種類型之間存在著下面的繼承規律:
類別可以繼承(extends)類,可以繼承(extends)抽象類,可以繼承(implements)介面。
抽象類別可以繼承(extends)類,可以繼承(extends)抽象類,可以繼承(implements)介面。
介面只能繼承(extends)介面。
請注意上面三條規律中每種繼承情況下使用的不同的關鍵字extends和implements,它們是不可以隨意替換的。大家知道,一個普通類別繼承一個介面後,必須實作這個介面中定義的所有方法,否則就只能被定義為抽象類別。我在這裡之所以沒有對implements關鍵字使用「實作」這種說法是因為從概念上來說它也是表示一種繼承關係,而且對於抽象類別implements介面的情況下,它並不是一定要實作這個介面定義的任何方法,因此使用繼承的說法更為合理一些。
以上三條規律同時遵守以下這些約束:
類別和抽象類別都只能最多繼承一個類,或者最多繼承一個抽象類,並且這兩種情況是互斥的,也就是說它們要么繼承一個類,要么繼承一個抽象類。
類別、抽象類別和介面在繼承介面時,不受數量的約束,理論上可以繼承無限多個介面。當然,對於類別來說,它必須實現它所繼承的所有介面中定義的全部方法。
抽象類別繼承抽象類別,或實作介面時,可以部分、全部或完全不實作父類別抽象類別的抽象(abstract)方法,或是父類別介面中定義的介面。
類別繼承抽象類別,或實作介面時,必須全部實作父類別抽象類別的全部抽象(abstract)方法,或是父類別介面中定義的全部介面。
繼承給我們的程式帶來的好處就是對原有類別的複用(重用)。就像模組的複用一樣,類別的複用可以提高我們的開發效率,實際上,模組的複用是大量類別的複用疊加後的效果。除了繼承之外,我們還可以使用組合的方式來重複使用類別。所謂組合就是把原有類別定義為新類別的屬性,透過在新類別中呼叫原有類別的方法來實現重複使用。如果新定義的類型與原有類型之間不存在被包含的關係,也就是說,從抽象概念上來講,新定義類型所代表的事物並不是原有類型所代表事物的一種,例如黃種人是人類的一種,它們之間存在著包含與被包含的關係,那麼這時組合就是實現復用更好的選擇。下面這個範例就是組合方式的一個簡單範例:
public class Sub { private Parent p = new Parent(); public void doSomething() { // 重複使用Parent類別的方法p.method(); // other code } } class Parent { public void method() { // do something here } }
當然,為了讓程式碼更有效,我們也可以在需要使用到原有型別(例如Parent p)時,才對它進行初始化。
使用繼承和組合復用原有的類,都是一種增量式的開發模式,這種方式帶來的好處是不需要修改原有的程式碼,因此不會為原有程式碼帶來新的BUG ,也不用因為對原有程式碼的修改而重新進行測試,這對我們的開發顯然是有益的。因此,如果我們是在維護或改造一個原有的系統或模組,尤其是對它們的了解不是很透徹的時候,就可以選擇增量開發的模式,這不僅可以大大提高我們的開發效率,也可以規避由於原有程式碼的修改而帶來的風險。
多態(Polymorphism)
多態是另一個重要的基本概念,上面說到了,它是物件導向的三個基本特徵之一。究竟什麼是多態呢?我們先來看看下面的例子,來幫助理解:
//汽車介面interface Car { // 汽車名稱String getName(); // 取得汽車售價int getPrice(); } // 寶馬class BMW implements Car { public String getName() { return "BMW"; } public int getPrice() { return 300000; } } // 奇瑞QQ class CheryQQ implements Car { public String getName() { return "CheryQQ"; } public int getPrice() { return 20000; } } // 汽車出售店public class CarShop { // 售車收入private int money = 0; // 賣出一輛車public void sellCar(Car car) { System.out.println("車型:" + car.getName() + " 單價:" + car.getPrice()); //增加賣出車售價的收入money += car.getPrice(); } // 售車總收入public int getMoney() { return money; } public static void main(String[] args) { CarShop aShop = new CarShop (); // 賣出一輛寶馬aShop.sellCar(new BMW()); // 賣出一輛奇瑞QQ aShop.sellCar(new CheryQQ()); System.out.println("總收入:" + aShop.getMoney()); } }
運行結果:
車型:BMW 單價:300000
車型:CheryQQ 單價:20000
總收入:320000
繼承是多態得以實現的基礎。從字面上理解,多態就是一種類型(都是Car類型)表現出多種狀態(BMW汽車的名稱是BMW,售價是300000;奇瑞汽車的名稱是CheryQQ,售價是2000)。將一個方法呼叫同這個方法所屬的主體(也就是物件或類別)關聯起來叫做綁定,分前期綁定和後期綁定兩種。下面解釋一下它們的定義:
前期綁定:在程式運行之前進行綁定,由編譯器和連接程式實現,又稱為靜態綁定。例如static方法和final方法,注意,這裡也包括private方法,因為它是隱式final的。
後期綁定:在運行時根據物件的類型進行綁定,由方法呼叫機制實現,因此又稱為動態綁定,或運行時綁定。除了前期綁定外的所有方法都屬於後期綁定。
多態就是在後期綁定這種機制上實現的。多態帶給我們的好處是消除了類別之間的耦合關係,使程式更容易擴展。例如在上例中,新增加一種類型汽車的銷售,只需要讓新定義的類別繼承Car類別並實現它的所有方法,而無需對原有程式碼做任何修改,CarShop類別的sellCar(Car car)方法就可以處理新的車型了。新增程式碼如下:
// 桑塔納汽車 class Santana implements Car { public String getName() { return "Santana"; } public int getPrice() { return 80000; } }
重載(overloading)和重寫(overriding)
重載和重寫都是針對方法的概念,在弄清楚這兩個概念之前,我們先來了解一下什麼叫方法的型構(英文名是signature,有的譯作“簽名”,雖然它被使用的較為廣泛,但是這個翻譯不準確的)。型構就是指方法的組成結構,具體包括方法的名稱和參數,涵蓋參數的數量、類型以及出現的順序,但是不包括方法的返回值類型,訪問權限修飾符,以及abstract、static、final等修飾符。例如下面兩個就是具有相同型構的方法:
public void method(int i, String s) { // do something } public String method(int i, String s) { // do something }
而這兩個就是具有不同型構的方法:
public void method(int i, String s) { // do something } public void method(String s, int i) { // do something }
了解完型構的概念後我們再來看看重載和重寫,請看它們的定義:
重寫,英文名是overriding,是指在繼承情況下,子類別中定義了與其基底類別中方法具有相同型構的新方法,就叫做子類別把基底類別的方法重寫了。這是實現多態必須的步驟。
重載,英文名是overloading,是指在同一個類別中定義了一個以上具有相同名稱,但是型構不同的方法。在同一個類別中,是不允許定義多於一個的具有相同型構的方法的。
我們來考慮一個有趣的問題:構造器可以被重載嗎?答案當然是可以的,我們在實際的程式設計上也常常這麼做。實際上建構器也是一個方法,建構器名就是方法名,建構器參數就是方法參數,而它的回傳值就是新建立的類別的實例。但是構造器卻不可以被子類別重寫,因為子類別無法定義與基底類別具有相同型構的構造器。
重載、覆蓋、多態與函數隱藏
經常看到C++的一些初學者對於重載、覆蓋、多態與函數隱藏的模糊理解。在這裡寫一點自己的見解,希望能夠C++初學者解惑。
在弄清楚重載、覆蓋、多態與函數隱藏之間的複雜且微妙關係之前,我們首先要來回顧一下重載覆蓋等基本概念。
首先,我們來看一個非常簡單的例子,理解一下什麼叫函數隱藏hide。
#include <iostream>using namespace std;class Base{public: void fun() { cout << "Base::fun()" << endl; }};class Derive : public Base{public: void fun(int i ) { cout << "Derive::fun()" << endl; }};int main(){ Derive d; //下面一句錯誤,故屏蔽掉//d.fun();error C2660: 'fun' : function does not take 0 parameters d.fun(1); Derive *pd =new Derive(); //下面一句錯誤,故屏蔽掉//pd- >fun();error C2660: 'fun' : function does not take 0 parameters pd->fun(1); delete pd; return 0;}
/*在不同的非命名空間作用域裡的函數不構成重載,子類別和父類別是不同的兩個作用域。
在本例中,兩個函數在不同作用域中,故不夠成重載,除非這個作用域是命名空間作用域。 */
在這個例子中,函數不是重載overload,也不是覆蓋override,而是隱藏hide。
接下來的5個例子具體說明什麼叫做隱藏
例1
#include <iostream>using namespace std;class Basic{public: void fun(){cout << "Base::fun()" << endl;}//overload void fun(int i){cout << "Base ::fun(int i)" << endl;}//overload};class Derive :public Basic{public: void fun2(){cout << "Derive::fun2()" << endl;}};int main(){ Derive d; d.fun();//正確,衍生類別沒有與基底類別同名函數聲明,則基底類別中的所有同名重載函數都會作為候選函數。 d.fun(1);//正確,衍生類別沒有與基底類別同名函數聲明,則基底類別中的所有同名重載函數都會作為候選函數。 return 0;}
例2
#include <iostream>using namespace std;class Basic{public: void fun(){cout << "Base::fun()" << endl;}//overload void fun(int i){cout << "Base ::fun(int i)" << endl;}//overload};class Derive :public Basic{public: //新的函數版本,基底類別所有的重載版本都被屏蔽,在這裡,我們稱之為函數隱藏hide //派生類別中有基底類別的同名函數的聲明,則基底類別中的同名函數不會作為候選函數,即使基底類別有不同的參數表的多個版本的重載函數。 void fun(int i,int j){cout << "Derive::fun(int i,int j)" << endl;} void fun2(){cout << "Derive::fun2()" << endl ;}};int main(){ Derive d; d.fun(1,2); //下面一句錯誤,故屏蔽掉//d.fun();error C2660: 'fun' : function does not take 0 parameters return 0;}
例3
#include <iostream>using namespace std;class Basic{public: void fun(){cout << "Base::fun()" << endl;}//overload void fun(int i){cout << "Base ::fun(int i)" << endl;}//overload};class Derive :public Basic{public: //覆蓋override基底類別的其中一個函數版本,同樣基底類別所有的重載版本都被隱藏hide //派生類別中有基底類別的同名函數的聲明,則基底類別中的同名函數不會作為候選函數,即使基底類別有不同的參數表的多個版本的重載函數。 void fun(){cout << "Derive::fun()" << endl;} void fun2(){cout << "Derive::fun2()" << endl;}};int main(){ Derive d; d.fun(); //下面一句錯誤,故屏蔽掉//d.fun(1);error C2660: 'fun' : function does not take 1 parameters return 0;}
例4
#include <iostream>using namespace std;class Basic{public: void fun(){cout << "Base::fun()" << endl;}//overload void fun(int i){cout << "Base ::fun(int i)" << endl;}//overload};class Derive :public Basic{public: using Basic::fun; void fun(){cout << "Derive::fun()" << endl;} void fun2(){cout << "Derive::fun2()" << endl;}};int main(){ Derive d; d.fun( );//正確d.fun(1);//正確return 0;}/*輸出結果Derive::fun()Base::fun(int i)Press any key to continue*/
例5
#include <iostream>using namespace std;class Basic{public: void fun(){cout << "Base::fun()" << endl;}//overload void fun(int i){cout << "Base ::fun(int i)" << endl;}//overload};class Derive :public Basic{public: using Basic::fun; void fun(int i,int j){cout << "Derive::fun(int i,int j)" << endl;} void fun2(){cout << "Derive::fun2()" << endl;}};int main( ){ Derive d; d.fun();//正確d.fun(1);//正確d.fun(1,2);//正確return 0;}/*輸出結果Base::fun()Base::fun(int i)Derive::fun(int i,int j)Press any key to continue*/
好了,我們先來一個小小的總結重載與覆蓋兩者之間的特徵
重載overload的特性:
n 相同的範圍(在同一個類別);
n 函數名相同參數不同;
n virtual 關鍵字可有可無。
覆蓋override是指派生類別函數覆蓋基底類別函數,覆蓋的特徵是:
n 不同的範圍(分別位於衍生類別與基底類別);
n 函數名和參數都相同;
n 基底類別函數必須有virtual 關鍵字。 (若沒有virtual 關鍵字稱為隱藏hide)
如果基底類別有某個函數的多個重載(overload)版本,而你在衍生類別中重寫(override)了基底類別中的一個或多個函數版本,或是在衍生類別中重新加入了新的函數版本(函數名稱相同,參數不同),則所有基底類別的重載版本都被屏蔽,在這裡我們稱之為隱藏hide。所以,在一般情況下,你想在衍生類別中使用新的函數版本又想使用基底類別的函數版本時,你應該在衍生類別中重寫基底類別中的所有重載版本。你若是不想重寫基底類別的重載的函數版本,則你應該使用例4或例5方式,明確宣告基底類別名字空間作用域。
事實上,C++編譯器認為,相同函式名稱不同參數的函式之間根本沒有關係,它們根本就是兩個毫不相關的函式。只是C++語言為了模擬現實世界,為了讓程式設計師更直覺的思維處理現實世界中的問題,才引入了重載和覆蓋的概念。重載是在相同名字空間作用域下,而覆蓋則是在不同的名字空間作用域下,例如基底類別和衍生類別即為兩個不同的名字空間作用域。在繼承過程中,若發生衍生類別與基底類別函數同名問題時,就會發生基底類別函數的隱藏。當然,這裡討論的情況是基底類別函數前面沒有virtual 關鍵字。在有virtual 關鍵字關鍵字時的情形我們另做討論。
繼承類別重寫了基底類別的某一函數版本,以產生自己功能的介面。此時C++編繹器認為,你現在既然要使用派生類的自己重新改寫的接口,那我基類的接口就不提供給你了(當然你可以用顯式聲明名字空間作用域的方法,見[C++基礎]重載、覆蓋、多型與函式隱藏(1))。而不會理會你基類的介面是有重載特性的。若是你要在衍生類別裡繼續保持重載的特性,那你就自己再給介面重載的特性吧。所以在衍生類別裡,只要函數名稱一樣,基底類別的函數版本就會被無情地屏蔽。在編繹器中,屏蔽是透過名字空間作用域來實現的。
所以,在衍生類別中要保持基底類別的函數重載版本,就應該重寫所有基底類別的重載版本。重載只在目前類別中有效,繼承會失去函數重載的特性。也就是說,要把基底類別的重載函數放在繼承的衍生類別裡,就必須重寫。
這裡「隱藏」是指派生類別的函數屏蔽了與其同名的基底類別函數,具體規則我們也來做一小結:
n 若衍生類別的函數與基底類別的函數同名,但是參數不同。此時,若基底類別無virtual關鍵字,基底類別的函數將被隱藏。 (注意別與重載混淆,雖然函數名稱相同參數不同應稱之為重載,但這裡不能理解為重載,因為派生類別和基底類別不在同一名字空間作用域內。這裡理解為隱藏)
n 若衍生類別的函數與基底類別的函數同名,但是參數不同。此時,若基底類別有virtual關鍵字,基底類別的函數將被隱式繼承到衍生類別的vtable中。此時衍生類別vtable中的函數指向基底類別版本的函數位址。同時這個新的函數版本加入到衍生類別中,作為衍生類別的重載版本。但在基底類別指標實作多態呼叫函數方法時,這個新的衍生類別函數版本將會被隱藏。
n 如果衍生類別的函數與基底類別的函數同名,且參數也相同,但是基底類別函數沒有virtual關鍵字。此時,基底類別的函數被隱藏。 (注意別與覆蓋混淆,這裡理解為隱藏)。
n 如果衍生類別的函數與基底類別的函數同名,且參數也相同,但是基底類別函數有virtual關鍵字。此時,基底類別的函數不會被「隱藏」。 (在這裡,你要理解為覆蓋哦^_^)。
插曲:基類函數前沒有virtual關鍵字時,我們要重寫更為順口些,在有virtual關鍵字時,我們叫覆蓋更為合理些,戒此,我也希望大家能夠更好的理解C++中一些微妙的東西。費話少說,我們舉例說明吧。
例6
#include <iostream>using namespace std; class Base{public: virtual void fun() { cout << "Base::fun()" << endl; }//overload virtual void fun(int i) { cout << "Base::fun(int i)" << endl; }//overload}; class Derive : public Base{public: void fun() { cout << "Derive::fun()" << endl; }//override void fun(int i) { cout << "Derive::fun(int i)" << endl; }//override void fun(int i,int j){ cout<< "Derive::fun(int i,int j)" <<endl;}//overload}; int main(){ Base *pb = new Derive(); pb->fun(); pb->fun(1); //下面一句錯誤,故屏蔽掉//pb->fun(1,2);virtual函數不能進行overload,error C2661: ' fun' : no overloaded function takes 2 parameters cout << endl; Derive *pd = new Derive(); pd->fun(); pd->fun(1); pd->fun(1,2);//overload delete pb; delete pd; return 0;}/*
輸出結果
Derive::fun()
Derive::fun(int i)
Derive::fun()
Derive::fun(int i)
Derive::fun(int i,int j)
Press any key to continue
*/
例7-1
#include <iostream> using namespace std; class Base{public: virtual void fun(int i){ cout <<"Base::fun(int i)"<< endl; }}; class Derive : public Base{}; int main(){ Base *pb = new Derive(); pb->fun(1);//Base::fun(int i) delete pb; return 0;}
例7-2
#include <iostream> using namespace std; class Base{public: virtual void fun(int i){ cout <<"Base::fun(int i)"<< endl; }}; class Derive : public Base{public: void fun(double d){ cout <<"Derive::fun(double d)"<< endl; } }; int main(){ Base *pb = new Derive(); pb->fun(1);//Base::fun(int i) pb->fun((double)0.01);//Base::fun(int i) delete pb ; return 0;}
例8-1
#include <iostream> using namespace std; class Base{public: virtual void fun(int i){ cout <<"Base::fun(int i)"<< endl; }}; class Derive : public Base{public: void fun(int i){ cout <<"Derive::fun(int i)"<< endl; }}; int main(){ Base *pb = new Derive(); pb->fun(1);//Derive::fun(int i) delete pb; return 0;}
例8-2
#include <iostream> using namespace std; class Base{public: virtual void fun(int i){ cout <<"Base::fun(int i)"<< endl; }}; class Derive : public Base{public: void fun(int i){ cout <<"Derive::fun(int i)"<< endl; } void fun(double d){ cout <<"Derive::fun(double d)"<< endl; } }; int main(){ Base *pb = new Derive(); pb->fun(1);//Derive::fun(int i ) pb->fun((double)0.01);//Derive::fun(int i) delete pb; return 0;}
例9
#include <iostream> using namespace std; class Base{public: virtual void fun(int i){ cout <<"Base::fun(int i)"<< endl; }};class Derive : public Base{public: void fun(int i){ cout <<"Derive::fun(int i)"<< endl; } void fun(char c){ cout <<"Derive::fun(char c)"<< endl; } void fun(double d){ cout <<"Derive::fun(double d)"<< endl; } };int main(){ Base *pb = new Derive(); pb->fun(1);//Derive::fun(int i) pb->fun('a');//Derive::fun(int i) pb->fun((double)0.01);//Derive::fun(int i) Derive *pd =new Derive(); pd->fun(1);//Derive::fun(int i) //overload pd->fun('a');//Derive::fun(char c) //overload pd->fun(0.01);//Derive::fun(double d) delete pb; delete pd; return 0;}
例7-1和例8-1很好理解,我把這兩個例子放在這裡,是讓大家作一個比較擺了,也是為了幫助大家更好的理解:
n 例7-1中,衍生類別沒有覆寫基底類別的虛擬函數,此時衍生類別的vtable中的函數指標所指向的位址就是基底類別的虛函數位址。
n 例8-1中,衍生類別覆寫了基底類別的虛擬函數,此時衍生類別的vtable中的函數指標所指向的位址就是衍生類別自己的重寫的虛函數位址。
在例7-2和8-2看起來有點怪怪,其實,你按照上面的原則對比一下,答案也是明朗的:
n 例7-2中,我們為衍生類別重載了一個函數版本:void fun(double d) 其實,這只是一個障眼法。我們具體來分析一下,基底類別共有幾個函數,而衍生類別共有幾個函數:
類型基底類別派生類別
Vtable部分
void fun(int i)
指向基底類別版的虛擬函數void fun(int i)
靜態部分
void fun(double d)
我們再來分析以下三句程式碼
Base *pb = new Derive();
pb->fun(1);//Base::fun(int i)
pb->fun((double)0.01);//Base::fun(int i)
這第一句是關鍵,基類指針指向派生類別的對象,我們知道這是多態呼叫;接下來第二句,運行時基類指針根據運行時對象的類型,發現是派生類對象,所以首先到衍生類別的vtable中去尋找衍生類別的虛函數版本,發現衍生類別沒有覆寫基底類別的虛函數,而衍生類別的vtable只是作了一個指向基底類別虛函數位址的一個指向,所以理所當然地去呼叫基類別版本的虛函數。最後一句,程式運行仍然埋頭去找衍生類別的vtable,發現根本沒有這個版本的虛函數,只好回頭呼叫自己的只有一個虛函數。
這裡也值得一提的是:如果此時基類有多個虛函數,此時程式編繹時會提示」呼叫不明確」。示例如下
#include <iostream> using namespace std; class Base{public: virtual void fun(int i){ cout <<"Base::fun(int i)"<< endl; } virtual void fun(char c){ cout < <"Base::fun(char c)"<< endl; }}; class Derive : public Base{public: void fun(double d){ cout <<"Derive::fun(double d)"<< endl; } }; int main(){ Base *pb = new Derive(); pb->fun(0.01);//error C2668: 'fun' : ambiguous call to overloaded function delete pb; return 0;}
好了,我們再來分析一下例8-2。
n 例8-2中,我們也為衍生類別重載了一個函數版本:void fun(double d) ,同時覆寫了基底類別的虛函數,我們再來具體來分析一下,基底類別共有幾個函數,衍生類別共有幾個函數:
類型基底類別派生類別
Vtable部分
void fun(int i)
void fun(int i)
靜態部分
void fun(double d)
從表中我們可以看到,衍生類別的vtable中函數指標指向的是自己的重寫的虛擬函數位址。
我們再來分析以下三句程式碼
Base *pb = new Derive();
pb->fun(1);//Derive::fun(int i)
pb->fun((double)0.01);//Derive::fun(int i)
第一句不必多說了,第二句,理所當然調用派生類的虛函數版本,第三句,嘿,感覺又怪怪的,其實呀,C++程式很笨的了,在運行時,埋頭闖進衍生類別的vtable表中,隻眼一看,靠,競然沒有想要的版本,真是想不通,基類指針為什麼不四處轉轉再找找呢?呵呵,原來是眼力有限,基類年紀這麼老了,想必肯定是老花了,它那雙眼睛看得到的僅是自己的非Vtable部分(即靜態部分)和自己要管理的Vtable部分,派生類別的void fun(double d)那麼遠,看不到呀!再說了,派生類什麼都要管,難道派生類沒有自己的一點權力嗎?哎,不吵了,各自管自己的吧^_^
唉!你是不是要嘆氣了,基類指針能進行多態調用,但是始終不能進行派生類的重載調用啊(參考例6)~~~
再來看看例9,
本例的效果同例6,異曲同工。想必你理解了上面的這些例子後,這也是小Kiss了。
小結:
重載overload是根據函數的參數清單來選擇要呼叫的函數版本,而多態是根據運行時物件的實際類型來選擇要呼叫的虛virtual函數版本,多態的實作是透過衍生類別對基底類別的虛virtual函數進行覆寫override來實現的,若衍生類別沒有對基底類別的虛virtual函數進行覆寫override的話,則衍生類別會自動繼承基底類別的虛virtual函數版本,此時無論基底類別指標指向的物件是基底類型還是衍生型別,都會呼叫基底類別版本的虛virtual函數;如果衍生類別對基底類別的虛virtual函數進行覆蓋override的話,則會在執行時間根據物件的實際類型來選擇要呼叫的虛virtual函數版本,例如基底類別指標指向的物件類型為衍生類型,則會呼叫衍生類別的虛virtual函數版本,從而實現多態。
使用多態的本意是要我們在基底類別中宣告函數為virtual,並且是要在衍生類別中覆寫override基底類別的虛virtual函數版本,注意,此時的函數原型與基底類別保持一致,即同名同參數類型;如果你在衍生類別中新加入函數版本,你不能透過基底類別指標動態呼叫衍生類別的新的函數版本,這個新的函數版本只會作為衍生類別的一個重載版本。還是同一句話,重載只有在目前類別中有效,不管你是在基底類別重載的,或是在衍生類別中重載的,兩者互不牽連。如果明白這一點的話,在例6、例9中,我們也會對其的輸出結果順利地理解。
重載是靜態聯編的,多型是動態聯編的。進一步解釋,重載與指標實際指向的物件類型無關,多態與指標實際指向的物件類型相關。若基底類別的指標呼叫衍生類別的重載版本,C++編繹認為是非法的,C++編繹器只認為基底類別指標只能呼叫基底類別的重載版本,重載只在目前類別的名字空間作用域內有效,繼承會失去重載的特性,當然,若此時的基類指標呼叫的是一個虛virtual函數,那麼它也會進行動態選擇基底類別的虛virtual函數版本還是衍生類別的虛virtual函數版本來進行具體的操作,這是透過基底類別指標實際指向的物件類型來做決定的,所以說重載與指標實際指向的物件類型無關,多態與指標實際指向的物件類型相關。
最後闡明一點,虛virtual函數同樣可以重載,但是重載只能是在目前自己名字空間作用域內有效到底創造了幾個String物件?
我們先來看一段程式碼:
Java程式碼
String str=new String("abc");
緊接著這段程式碼之後的往往是這個問題,那就是這行程式碼究竟創建了幾個String物件呢?相信大家對這題並不陌生,答案也是眾所皆知的,2個。接下來我們就從這題展開,一起回顧與創作String物件相關的一些JAVA知識。
我們可以把上面這行程式碼分成String str、=、"abc"和new String()四個部分來看。 String str只是定義了一個名為str的String類型的變量,因此它並沒有創建物件;=是對變量str進行初始化,將某個物件的引用(或稱為句柄)賦值給它,顯然也沒有創建對象;現在只剩下new String("abc")了。那麼,new String("abc")為什麼又能被看成"abc"和new String()呢?我們來看看被我們呼叫了的String的建構子:
Java程式碼
public String(String original) {
//other code ...
}
大家都知道,我們常用的創建一個類別的實例(物件)的方法有以下兩種:
使用new建立物件。
呼叫Class類別的newInstance方法,利用反射機制創建物件。
我們正是使用new呼叫了String類別的上面那個建構器方法建立了一個對象,並將它的引用賦值給了str變數。同時我們注意到,被呼叫的建構器方法接受的參數也是一個String對象,這個物件正是"abc"。由此我們又要引入另外一種創建String物件的方式的討論――引號內包含文字。
這種方式是String特有的,它與new的方式有很大差異。
Java程式碼
String str="abc";
毫無疑問,這行程式碼創造了一個String物件。
Java程式碼
String a="abc";
String b="abc";
那這裡呢?答案還是一個。
Java程式碼
String a="ab"+"cd";
再看看這裡呢?答案仍是一個。有點奇怪嗎?說到這裡,我們就需要引入對字串池相關知識的回顧了。
在JAVA虛擬機(JVM)中存在著一個字串池,其中保存著很多String對象,並且可以被共享使用,因此它提高了效率。由於String類別是final的,它的值一建立就不可改變,因此我們不用擔心String物件共用而帶來程式的混亂。字串池由String類別維護,我們可以呼叫intern()方法來存取字串池。
我們再回頭看看String a="abc";,這行程式碼被執行的時候,JAVA虛擬機首先在字串池中查找是否已經存在了值為"abc"的這麼一個對象,它的判斷依據是String類別equals(Object obj)方法的回傳值。如果有,則不再創建新的對象,直接返回已存在對象的引用;如果沒有,則先創建這個對象,然後把它加入到字符串池中,再將它的引用返回。因此,我們不難理解前面三個例子中頭兩個例子為什麼是這個答案了。
對於第三個例子:
Java程式碼
String a="ab"+"cd";
由於常量的值在編譯的時候就被確定了。在這裡,"ab"和"cd"都是常數,因此變數a的值在編譯時就可以確定。這行程式碼編譯後的效果等同於:
Java程式碼
String a="abcd";
因此這裡只創建了一個物件"abcd",並且它被保存在字串池裡了。
現在問題又來了,是不是所有經過「+」連線後得到的字串都會被加入到字串池呢?我們都知道「==」可以用來比較兩個變量,它有以下兩種情況:
如果比較的是兩個基本型別(char,byte,short,int,long,float,double,boolean),則是判斷它們的值是否相等。
如果表較的是兩個物件變量,則是判斷它們的引用是否指向同一個物件。
下面我們就用「==」來做幾個測試。為了方便說明,我們把指向字串池中已經存在的物件也視為該物件被加入了字串池:
Java程式碼
public class StringTest { public static void main(String[] args) { String a = "ab";// 建立了一個對象,並加入字串池中System.out.println("String a = /"ab/" ;"); String b = "cd";// 建立了一個對象,並加入字串池中System.out.println("String b = /"cd/";"); String c = "abcd";// 建立了一個對象,並加入字串池中String d = "ab" + "cd"; // 如果d和c指向了同一個對象,則說明d也被加入了字串池if (d == c) { System.out.println("/"ab/"+/"cd/" 建立的物件/"加入了/" 字串池中"); } // 如果d和c沒有指向了同一個對象,則表示d沒有被加入字串池else { System.out.println("/"ab/"+/"cd/" 建立的物件/"沒加入/" 字串池中"); } String e = a + "cd"; // 如果e和c指向了同一個對象,則說明e也被加入了字符串池if (e == c) { System.out.println(" a +/"cd/" 創建的對象/"加入了/" 字符串池中"); } //如果e和c沒有指向了同一個對象,則說明e沒有被加入字串池else { System.out.println(" a +/"cd/" 建立的物件/"沒加入/" 字串池中" ); } String f = "ab" + b; // 如果f和c指向了同一個對象,則說明f也被加入了字串池if (f == c) { System.out.println("/ "ab/"+ b 所建立的對象/"加入了/" 字串池中"); } // 如果f和c沒有指向了同一個對象,則說明f沒有被加入字串池else { System.out.println("/"ab/" + b 建立的物件/"沒加入/" 字串池中"); } String g = a + b; // 如果g和c指向了同一個對象,則說明g也被加入了字串池if ( g == c) { System.out.println(" a + b 建立的物件/"加入了/" 字串池中"); } // 如果g和c沒有指向了同一個對象,則表示g沒有被加入字串池else { System.out.println( " a + b 建立的物件/"沒加入/" 字串池中"); } } } }
運行結果如下:
String a = "ab";
String b = "cd";
"ab"+"cd" 建立的物件"加入了" 字串池中
a +"cd" 建立的物件"沒加入" 字串池中
"ab"+ b 建立的物件"沒加入" 字串池中
a + b 建立的物件"沒加入" 字串池中從上面的結果中我們不難看出,只有使用引號包含文字的方式建立的String物件之間使用「+」連線產生的新物件才會被加入字串池中。對於所有包含new方式新建物件(包括null)的「+」連線表達式,它所產生的新物件都不會被加入字串池中,對此我們不再贅述。
但是有一種情況需要引起我們的注意。請看下面的程式碼:
Java程式碼
public class StringStaticTest { // 常數A public static final String A = "ab"; // 常數B public static final String B = "cd"; public static void main(String[] args) { // 將兩個常數用+連線對s進行初始化String s = A + B; String t = "abcd"; if (s == t) { System.out.println("s等於t,它們是同一個物件"); } else { System.out.println("s不等於t,它們不是同一個物件"); } } }
這段程式碼的運行結果如下:
s等於t,它們是同一個物件這又是為什麼呢?原因是這樣的,對於常數來講,它的值是固定的,因此在編譯期就能被確定了,而變數的值只有到運行時才能被確定,因為這個變數可以被不同的方法調用,從而可能引起值的改變。在上面的例子中,A和B都是常數,值是固定的,因此s的值也是固定的,它在類別被編譯時就已經確定了。也就是說:
Java程式碼
String s=A+B;
等同於:
Java程式碼
String s="ab"+"cd";
我對上面的例子稍加改變看看會出現什麼情況:
Java程式碼
public class StringStaticTest { // 常數A public static final String A; // 常數B public static final String B; static { A = "ab"; B = "cd"; } public static void main(String[] args) { // 將兩個常數用+連接對s進行初始化String s = A + B; String t = "abcd"; if (s == t) { System.out.println("s等於t,它們是同一個物件"); } else { System.out.println("s不等於t,它們不是同一個物件"); } } }
它的運行結果是這樣:
s不等於t,它們不是同一個物件只是做了一點改動,結果就和剛剛的例子剛好相反。我們再來分析一下。 A和B雖然被定義為常數(只能被賦值一次),但是它們都沒有馬上被賦值。在運算出s的值之前,他們何時被賦值,以及被賦予什麼樣的值,都是個變數。因此A和B在被賦值之前,性質類似一個變數。那麼s就不能在編譯期被決定,而只能在執行時被創建了。
由於字串池中物件的共享能夠帶來效率的提高,因此我們提倡大家用引號包含文字的方式來創建String對象,實際上這也是我們在程式設計中常採用的。
接下來我們再來看看intern()方法,它的定義如下:
Java程式碼
public native String intern();
這是一個本地方法。在呼叫這個方法時,JAVA虛擬機器首先檢查字串池中是否已經存在與該物件值相等物件存在,如果有則傳回字串池中物件的參考;如果沒有,則先在字串池中建立一個相同值的String對象,然後再將它的引用回傳。
我們來看這段程式碼:
Java程式碼
public class StringInternTest { public static void main(String[] args) { // 使用char陣列來初始化a,避免在a被建立之前字串池中已經存在了值為"abcd"的物件String a = new String( new char[] { 'a', 'b', 'c', 'd' }); String b = a.intern(); if (b == a) { System.out.println("b被加入了字串池中,沒有新物件"); } else { System.out.println("b沒被加入字串池中,新建了物件"); } } }
運行結果:
b沒被加入字串池中,新建了物件如果String類別的intern()方法在沒有找到相同值的物件時,是把目前物件加入字串池中,然後傳回它的引用的話,那麼b和a指向的就是同一個物件;否則b指向的物件就是JAVA虛擬機器在字串池中新建的,只是它的值與a相同罷了。上面這段程式碼的運行結果正好印證了這一點。
最後我們再來談談String物件在JAVA虛擬機器(JVM)中的存儲,以及字串池與堆疊(heap)和堆疊(stack)的關係。我們先回顧一下堆和棧的差別:
堆疊(stack):主要保存基本類型(或稱為內建型別)(char、byte、short、int、long、float、double、boolean)和物件的引用,資料可以共享,速度僅次於暫存器(register),快於堆。
堆(heap):用於儲存物件。
我們查看String類別的原始碼就會發現,它有一個value屬性,保存String物件的值,類型是char[],這也正說明了字串就是字元的序列。
當執行String a="abc";時,JAVA虛擬機會在堆疊中建立三個char型的值'a'、'b'和'c',然後在堆中建立一個String對象,它的值(value )是剛才在堆疊中建立的三個char型值組成的陣列{'a','b','c'},最後這個新建立的String物件會被加入到字串池中。如果我們接著執行String b=new String("abc");程式碼,由於"abc"已經被建立並保存於字串池中,因此JAVA虛擬機只會在堆中新建立一個String對象,但是它的值(value)是共用前一行程式碼執行時在堆疊中建立的三個char型值值'a'、'b'和'c'。
說到這裡,我們對於篇首提出的String str=new String("abc")為什麼是建立了兩個物件這個問題就已經相當明了。