1. 什么是多態多態是C++中的一個重要的基礎,可以這樣說,不掌握多態就是C++的門個漢。我就給它定一個這樣的名字-- “調用’同名函數’卻會因上下文不同會有不同的實現的一種機制”。這個名字長是長了點兒,可是比“多態”清楚多了。看這個長的定義,我們可以從中找出多態 的三個重要的部分。一是“相同函數名”,二是“依據上下文”,三是“實現卻不同”。我們且把它們叫做多態三要素吧。
2. 多態帶來的好處多態帶來兩個明顯的好處:一是不用記大量的函數名了,二是它會依據調用時的上下文來確定實現。確定實現的過程由C++本身完成,另外還有一個不明顯但卻很重要的好處是:帶來了面向對象的編程。
3. C++中實現多態的方式C++中共有三種實現多態的方式。由“容易說明白”到“不容易說明白”排序分別為: 第一種是函數重載;第二種是模板函數;第三種是虛函數。
4. 細說用函數重載實現的多態函數重載是這樣一種機制:允許有不同參數的函數有相同的名字。具體一點講就是:假如有如下三個函數:void test(int arg){} //函數1void test(char arg){} //函數2void test(int arg1,int arg2){} //函數3如果在C中編譯,將會得到一個名字沖突的錯誤而不能編譯通過。在C++中這樣做是合法的。可是當我們調用test的時候到底是會調用上面三個函數中的哪一個呢?這要依據你在調用時給的出的參數來決定。如下: test(5); //調用函數1 test('c');//調用函數2 test(4,5); //調用函數3
C++是如何做到這一點的呢?原來聰明的C++編譯器在編譯的時候悄悄的在我們的函數名上根據函數的參數的不同做了一些不同的記號。具體說如下:void test(int arg) //被標記為 ‘test有一個int型參數’void test(char arg) //被標記為 ‘test有一個char型的參數’void test(int arg1,int arg2) //被標記為 ‘test第一個參數是int型,第二個參數為int型’這 樣一來當我們進行對test的調用時,C++就可以根據調用時的參數來確定到底該用哪一個test函數了。噢,聰明的C++編譯器。其實C++做標記做的 比我上面所做的更聰明。我上面哪樣的標記太長了。C++編譯器用的標記要比我的短小的多。看看這個真正的C++的對這三個函數的標記:?test@@YAXD@Z?test@@YAXH@Z?test@@YAXHH@Z
是不是短多了。但卻不好看明白了。好在這是給計算機看的,人看不大明白是可以理解的。還記得cout吧。我們用<<可以讓它把任意類型的數據輸出。比如可以象下面那樣: cout << 1; //輸出int型 cout << 8.9; //輸出double型 cout << 'a'; //輸出char型 cout << "abc";//輸出char數組型 cout << endl; //輸出一個函數cout之所以能夠用一個函數名<<(<<是一個函數名)就能做到這些全是函數重載的功能。要是沒有函數重載,我們也許會這樣使用cout,如下: cout int<< 1; //輸出int型 cout double<< 8.9; //輸出double型 cout char<< 'a'; //輸出char型 cout charArray<< "abc"; //輸出char數組型 cout function(…)<< endl; //輸出函數為每一種要輸出的類型起一個函數名,這豈不是很麻煩呀。
不過函數重載有一個美中不足之處就是不能為返回值不同的函數進行重載。那是因為人們常常不為函數調用指出返回值。并不是技術上不能通過返回值來進行重載。
5. 細說用模板函數實現的多態所謂模板函數(也有人叫函數模板)是這樣一個概念:函數的內容有了,但函數的參數類型卻是待定的(注意:參數個數不是待定的)。比如說一個(準確的說是一類或一群)函數帶有兩個參數,它的功能是返回其中的大值。這樣的函數用模板函數來實現是適合不過的了。如下。template < typename T>T getMax(T arg1, T arg2){ return arg1 > arg2 ? arg1:arg2; //代碼段1}這 就是基于模板的多態嗎?不是。因為現在我們不論是調用getMax(1, 2)還是調用getMax(3.0, 5.0)都是走的上面的函數定義。它沒有根據調用時的上下文不同而執行不同的實現。所以這充其量也就是用了一個模板函數,和多態不沾邊。怎樣才能和多態沾 上邊呢?用模板特化呀!象這樣:template<>char* getMax(char* arg1, char* arg2){ return (strcmp(arg1, arg2) > 0)?arg1:arg2;//代碼段2}這樣一來當我們調用getMax(“abc”, “efg”)的時候,就會執行代碼段2,而不是代碼段1。這樣就是多態了。更有意思的是如果我們再寫這樣一個函數:char getMax(char arg1, char arg2){ return arg1>arg2?arg1:arg2; //代碼段3}當我們調用getMax(‘a’, ‘b’)的時候,執行的會是代碼段3,而不是代碼段1或代碼段2。C++允許對模板函數進行函數重載,就象這個模板函數是一個普通的函數一樣。于是我們馬上能想到寫下面這樣一個函數來做三個數中取大值的處理:int getMax( int arg1, int arg2, int arg3){ return getMax(arg1, max(arg2, arg3) ); //代碼段4}同樣我們還可以這樣寫:template <typename T>T getMax(T arg1, T arg2, T arg3){ return getMax(arg1, getMax(arg2, arg3) ); //代碼段5}現在看到結合了模板的多態的威力了吧。比只用函數重載厲害多了。
6. 小結上 面的兩種多態在C++中有一個總稱:靜態多態。之所以叫它們靜態多態是因為它們的多態是在編譯期間就確定了。也就是說前面所說的函數1,2,3代碼段1, 2,3,4,5這些,在編譯完成后,應該在什么樣的上下文的調用中執行哪一些就確定了。比如:如果調用getMax(0.1, 0.2, 0.3)就會執行代碼段5。如果調用test(5)就執行函數1。這些是在編譯期間就能確定下來的。靜態多態還有一個特點,就是:“總和參數較勁兒”。下面所要講的一種多態就是必需是在程序的執行過程中才能確定要真正執行的函數。所以這種多態在C++中也被叫做動態多態。
7. 細說用虛函數實現的多態7.1.虛函數是怎么回事首先來說一說虛函數,所謂虛函數是這樣一個概念:基類中有這么一些函數,這些函數允許在派生類中其實現可以和基類的不一樣。在C++中用關鍵字virtual來表示一個函數是虛函數。C++中還有一個術語 “覆蓋”與虛函數關系密切。所謂覆蓋就是說,派生類中的一個函數的聲明,與基類中某一個函數的聲明一模一樣,包括返回值,函數名,參數個數,參數類型,參數次序都不能有差異。(注1)說覆蓋和虛函數關系密切的原因有兩個:一個原因是,只有覆蓋基類的虛函數才是安全的。第二個原因是,要想實現基于虛函數的多態就必須在派生類中覆蓋基類的虛函數。接下來讓我們說一說為什么要有虛函數,分析一下為什么派生類非要在某些情況下覆蓋基類的虛函數。就以那個非常著名的圖形繪制的例子來說吧。假設我們在為一個圖形系統編程。我們可能有如下的一個類結構。 圖7-1形狀對外公開一個函數來把自己繪制出來。這是合理的,形狀就應該能繪制出來,對吧?由于繼承的原因,多邊形和圓形也有了繪制自己這個函數。現在我們來討論在這三個類中的繪制自己的函數都應該怎么實現。在形狀中嘛,什么也不做就行了。在多邊形中嘛,只要把它所有的頂點首尾相連起來就行了。在圓形中嘛,依據它的圓心和它的半徑畫一個360度的圓弧就行了。可是現在的問題是:多邊形和圓形的繪制自己的函數是從形狀繼承而來的,并不能做連接頂點和畫圓弧的工作。怎 么辦呢?覆蓋它,覆蓋形狀中的繪制自己這個函數。于是我們在多邊形和圓形中各做一個繪制自己的函數,覆蓋形狀中的繪制自己的函數。為了實現覆蓋,我們需要 把形狀中的繪制自己這個函數用virtual修飾。而且形狀中的繪制自己這個函數什么也不干,我們就把它做成一個純虛函數。純虛函數還有一個作用,就是讓 它所在的類成為抽象類。形狀理應是一個抽象類,不是嗎?于是我們很快寫出這三個類的代碼如下:class Shape//形狀{public: virtualvoid DrawSelf()//繪制自己 { cout << "我是一個什么也繪不出的圖形" << endl; }}; class Polygo:public Shape//多邊形{public: void DrawSelf() //繪制自己 { cout << "連接各頂點" << endl; }}; class Circ:public Shape//圓{public: void DrawSelf() //繪制自己 { cout << "以圓心和半徑為依據畫弧" << endl; }};下面,我們將以上面的這三個類為基礎來說明動態多態。在進行更進一步的說明之前,我們先來說一個不得不說的兩個概念:“子類型”和“向上轉型”。
7.2.向上轉型子類型很好理解,比如上面的多邊形和圓形就是形狀的子類型。關于子類型還有一個確切的定義為:如果類型X擴充或實現了類型Y,那么就說X是Y的子類型。向 上轉型的意思是說把一個子類型轉的對象換為父類型的對象。就好比把一個多邊形轉為一個形狀。向上轉型的意思就這么簡單,但它的意義卻很深遠。向上轉型中有 三點需要我們特別注意。第一,向上轉型是安全的。第二,向上轉型可以自動完成。第三,向上轉型的過程中會丟失子類型信息。這三點在整個動態多態中發揮著重 要的作用。假如我們有如下的一個函數:void OutputShape( Shape arg)//專門負責調用形狀的繪制自己的函數{ arg.DrawSelf();}那么現在我們可以這樣使用OutputShape這個函數: Polygon shape1; Circ shape2; OutputShape(shape1); OutputShape(shape2);我們之所以可以這樣使用OutputShape函數,正是由于向上轉型是安全的(不會有任何的編譯警告),是由于向上轉弄是自動的(我們沒有自己把shape1和shape2轉為Shape類型再傳給OutputShape函數)。可是上面這段程序運行后的輸出結果是這樣的:我是一個什么也繪不出的圖形我是一個什么也繪不出的圖形明明是一個多邊形和一個圓呀,應該是輸出這下面這個樣子才合理呀!連接各頂點以圓心和半徑為依據畫弧造成前面的不合理的輸出的罪魁禍首正是‘向上轉型中的子類型信息丟失’。為了得到一個合理的輸出,得想個辦法來找回那些丟失的子類型信息。C++中用一種比較巧妙的辦法來找回那些丟失的子類型信息。這個辦法就是采用指針或引用。
7.3.為什么要用指針或引用來實現動態多態對于一個對象來說無論有多少個指針指向它,這些個指針所指的都是同一個對象。(即使你用一個void的指針指向一個對象也是這樣的,不是嗎?)同理對于引用也一樣。這究竟有多少深層次的意義呢?這里的深層的意義是這樣的:子類型的信息本來就在它本身中存在,所以我們用一個基類的指針來指出它,這個子類型的信息也會被找到,同理引用也是一樣的。C++正是利用了指針的這一特性。來做到動態多態的。注2現在讓我們來改寫OutputShape函數為這樣:void OutputShape( Shape& arg)//專門負責調用形狀的繪制自己的函數{ arg.DrawSelf();}現在我們的程序的輸出為:連接各頂點以圓心和半徑為依據畫弧這樣的輸出才是我們真正的想要的。我們實現的這種真正想要的輸出就是動態多態的實質。
7.4.為什么動態多態要用public繼承在我們上面的代碼中,圓和多邊形都是從形狀公有繼承而來的。要是我們把圓的繼承改為私有或保護會怎么樣呢?我們來試一試。哇,我們得到一個編譯錯誤。這個錯誤的大致意思是說:“請不要用一個私有的方法”。怎么回事呢?是這么回事。它的意思是說下面這樣說不合理。所有的形狀都可以畫出來,圓這種形狀是不能畫出來的。這樣合理嗎?不合理。所以請在多態中使用公有繼承吧。
8. 總結多態的思想其實早在面向對象的編程出現之前就有了。比如C語言中的+運算符。這個運算符可以對兩個int型的變量求和,也可以對兩個char的變量求和,也可以對一個int型一個char型的兩個變量求和。加法運算的這種特性就是典型的多態。所以說多態的本質是同樣的用法在實現上卻是不同的。
9. 附錄:注1:嚴格地講返回值可以不同,但這種不同是有限制的。詳細情況請看有關協變的內容。注2: C++會悄悄地在含有虛函數的類里面加一個指針。用這個指針來指向一個表格。這個表格會包含每一個虛函數的索引。用這個索引來找出相應的虛函數的入口地 址。對于我們所舉的形狀的例子來說,C++會悄悄的做三個表,Shape一個,Polygon一個,Circ一個。它們分別記錄一個DrawSelf函數 的入口地址。在程序運行的過程中,C++會先通過類中的那個指針來找到這個表格。再從這個表格中查出DrawSelf的入口地址。然后現通過這個入口地址 來調用正直的DrawSelf。正是由于這個查找的過程,是在運行時完成的。所以這樣的多態才會被叫做動態多態(運行時多態)
|