iThome 網站首載:多型的本質 (一)介面一致、實作各異的特定多型
多型(Polymorphism)經常出現在物件導向的討論中,實際上並非物件導向才有多型觀念,多型與物件導向的誕生時間差不多都在60年代,兩者各有自己的發展體系,並在歷史的演進中相互融合。程式語言上的多型,是可使用一致介面來處理不同的資料型態,根據此定義而衍生出各種實作方式。歷史上曾對多型作過幾次正式或非正式的分類,就現在而言,主要將多型分為三類:特定(Ad-hoc)多型、參數(Parametric)多型與次型態(Subtype)多型。
- 特定多型優點是從使用者的角度來看
如果函式可以套用不同型態的引數,稱為多型函式。如果函式的參數型態彼此間沒有關聯,且函式根據不同引數型態而有不同實作版本或行為,這樣的函式稱為特定多型。函式重載(Overload)是特定多型常見的形式,可以使用相同方法名稱,但根據引數型態決定實際呼叫的函式版本。
舉例來說,在沒有函式重載功能的語言中,如果要顯示指定的引數,必須有根據不同型態的printInt(int data)、printIntArray(int[] array)、printString(String str)等實作,就使用者觀點來說,如此名稱雖具有清楚描述的優點,但就撰寫來說比較麻煩。若語言具有函式重載功能,則可定義print(int data)、print(int[] array)、print(String str)等,就使用者觀點來說,需要顯示功能時一律呼叫print,就操作介面來說具有一致性。
特定(Ad-hoc)這個字眼表示,開發者必須針對引數型態提供特定的函式實作。例如運用重載時,實際上必須為print(int data)、print(int[] array)、print(String str)撰寫三個函式定義,多型的外觀實際上是從使用者呼叫函式時的角度來看,從編譯器或直譯器角度的話,不同引數型態仍要有不同版本的函式實作。由於函式針對特定引數型態進行處理,程式容易受到型態增減需求而進行修改,要處理的型態增加,就要增加新的函式定義,型態一旦決定廢棄,對應的函式也得一併刪除。
- 特定多型就是針對型態不同而有特定實作
如果對於特定引數型態,函式實作時會有實質差異,特定多型對開發者才會有益,除了不用為命名函式而苦惱,不同型態實作不同函式版本,程式邏輯也會比較清楚。例如,print(int data)單純將數值輸出,然而print(int[] array)也許必須用迴圈走訪陣列,將元素值組合為"{1, 2, 3, 4, 5}"字串輸出。不過像以下print(SwordsMan s)與print(Magician m)的實作就不適當,因為實作上並沒有差異性:
void show(SwordsMan s) {
out.print("(%s, %d)", s.getName(), s.getBlood());
}
void show(Magician m) {
out.print("(%s, %d)", m.getName(), m.getBlood());
}
out.print("(%s, %d)", s.getName(), s.getBlood());
}
void show(Magician m) {
out.print("(%s, %d)", m.getName(), m.getBlood());
}
函式重載可以根據不同型態,也可以根據引數個數。例如程式中會有串接陣列需求,也會有串接List需求,因而有了append(int[] array1, int[] array2)與append(List list1, List list2)兩個重載函式,由於資料結構不同,這兩個函式也就必須針對陣列與List而有特定實作。
在不支援預設引數的語言中,例如Java標準API中可看到如indexOf(String str)與indexOf(String str, int fromIndex)的重載函式,這是根據參數個數而有不同重載,但並不是指有兩個特定實作,實際上真正的特定實作只有indexOf(String str, int fromIndex),indexOf(String str)只是在函式內部呼叫indexOf(str, 0)。如果Java有預設引數功能,就只需定義一個indexOf,例如indexOf(String str, int fromIndex = 0)。有些建構式(Constructor)會重載多個版本,內部使用this()指定預設引數呼叫真正的特定實作,也是同樣道理。
特定多型的概念,就是相同函式介面針對不同型態可以呈現截然不同的行為。就實作而言,就是單一函式名稱可以關聯不同實作。從這個角度來思考,就可以明瞭重載的使用時機,查看Java標準API文件,也就可以清楚看到同一函式名稱的重載版本,都具有實作上的差異性。
- 動態語言的特定多型實現
特定多型的概念,對於變數帶有型態資訊的靜態語言來說較易實現,對於不要求變數型態的動態語言來說就複雜多了,通常得在執行時期針對引數型態決定呼叫哪個函式。
舉例來說,有些動態語言可以定義特定函式名稱來定義運算子行為,這是動態語言中較常見的特定多型。例如+運算子可用來展現1 + 1、[1, 2] + [3, 4]、"abc" + "xyz"等行為,如果運算元型態都相同則實作上較為單純,但不同的話就麻煩的多。以Ruby為例,要定義+的行為,函式必須是def +(that),that實際型態未知,如果真要左運算元為A型態,右運算元可以是B、C型態,就得在+函式內部進行型態或行為的判斷,例如使用that.is_a?(B)或that.is_a?(C)判斷該進行哪個操作。
動態語言無法根據型態進行函式重載,通常也不提供根據參數個數的函式重載。如果語言提供預設引數或不定長度引數的功能(例如Ruby、Python),倒可以讓函式呼叫時看來像是有重載,從而實現特定多型的概念。JavaScript雖然沒有提供預設引數功能,但是JavaScript的函式不管實際定義的參數有幾個,呼叫時可傳入任意數量引數的特性,可讓呼叫外觀看來也像是有重載。無論是哪個作法,為了實現特定多型,都必須在函式內部根據實際傳入的引數型態、順序或個數,判斷應當進行哪些對應動作,讓程式邏輯變得複雜。
如果動態語言支援的字典(Dictionary)物件或關聯陣列(Associative array)具有簡潔且適當的語法,也常會用來作為選項物件(Option object)以實現特定多型,在不支援預設選項的語言中,也可在函式內部設定選項物件中未設定選項的預設值,好處是使用者呼叫時不用受到引數順序的限制,然而壞處是又增加了函式內部實作的複雜。
動態語言實現特定多型時往往綜合了以上提到的幾種方式。例如JavaScript程式庫jQuery中的\$函式就是個最佳實例,它是個具有多重角色的函式,jQuery程式庫經常使用執行時期判斷引數型態、個數、選項物件等方式來實現特定多型。對使用者而言,確實可以得到特定多型使用固定介面的好處,但缺點就是開發者仍得確定引數的型態或行為、順序、選項物件的鍵名稱等,此時說明清楚的API文件就非常重要。
- 特定多型用於實作具差異性場合
特定多型因為必須針對特定型態而有特定實作,有人認為價值不大,事實上這個被認定價值不大的理由,反而就是特定多型存在的價值。就像Java中PrintStream的print函式,雖然要顯示整數與顯示陣列必須分別實作print(int data)與print(int[] array),但就使用者來說,目的都是「把傳入的引數顯示出來」,如果有特定多型,確實可省去記憶函式名稱的細節,尤其有整合開發工具輔助時更為省事。如果特定多型的價值不大,動態語言就不用大費周章地要在API中封裝複雜判斷,以求在外觀上實現特定多型的概念。
真正讓一些人認為特定多型價值不大的原因,其實是將特定多型運用於實作無差異性的場合。實例之一是像先前的show函式,其實應當使用Role作為SwordsMan與Magician父類別,定義兩者的共同行為,並以次型態多型來解決需求:
void show(Role r) {
out.print("(%s, %d)", r.getName(), r.getBlood());
}
out.print("(%s, %d)", r.getName(), r.getBlood());
}
另一不適當的實作也許像是:
String first(String[] strs) {
return strs[0];
}
Integer first(Integer[] integers) {
return integers[0];
}
return strs[0];
}
Integer first(Integer[] integers) {
return integers[0];
}
實際上first函式的實作與型態無關,像這個例子也許用參數多型會比較適合:
<T> T first(T[] ts) {
return ts[0];
}
return ts[0];
}