多型的本質 (二)參數多型用於減輕型態負擔


iThome 網站首載:多型的本質 (二)參數多型用於減輕型態負擔

特定(Ad-hoc)多型允許函式根據不同引數型態,而有不同實作版本或行為,天平的另一端是參數(Parametric)多型,允許函式設計時不理會參數實際型態,函式的實作版本只有一個,呼叫時則可套用不同類型的引數,由於程式語言上的多型,是可使用一致介面來處理不同的資料型態,從這點來看,參數多型才是多型最純綷的型式。

具靜態型別安全檢查功能的泛型函式

有些需求使用函式解決時並不需要在意傳入的資料型態,設計函式時可以空泛地定義參數型態,這類函式稱為泛型(Generic)函式。例如交換陣列兩元素的需求,若使用JDK 1.5以後的版本,可以使用泛型語法設計為:
<T> T[] swap(T[] arrs, int i, int j) {
    T orgi = arrs[i];
    arrs[i] = arrs[j];
    arrs[j] = orgi;
    return arrs;
}

傳入陣列的元素型態都是T型態,但T實際上是型態變數(Type variables),用以取代實際的型態宣告,泛型語法就是Java中實現參數多型的方式:

參數多型讓函式定義時更具涵蓋性,並同時具有靜態型別編譯時期安全檢查功能。例如可以使用String[] result = swap(original, 1, 2)來呼叫swap函式,傳入物件是String[],傳回物件就會是String[],若使用其他型態宣告result,編譯器就會檢查出這個錯誤。

JDK 1.4前沒有泛型語法,相同需求必須使用次型態多型來達成,也就是定義函式為Object[] swap(Object[] arrs, int i, int j),如果實際上傳入物件是String[],呼叫後傳回物件必須進行轉型,例如String[] result = (String[]) swap(original, 1, 2),轉型語法只是要求編譯器停止該語句的型態檢查,真正的轉型是在執行時期進行,既然編譯時期使用轉型語法要求編譯器不要檢查型態,執行時期轉型失敗就得自行負責。

參數多型實際上必須依賴編譯器的類型推斷(Type inference)功能,例如String[] result = swap(original, 1, 2)時,編譯器可由original推斷出T實際上會是String,從而得知傳回物件為String[]。有時為了規範型態變數的範圍,可對型態變數加上約束。例如取陣列中最大值的max函式,可以定義為:
<T extends Comparable> T max(T[] arrs) {
    T max = arrs[0];
    for(T elem : arrs) if(elem.compareTo(max) > 0) {
        max = elem;
    }
    return max;
}

這是由於Java中定義物件可比較性時,必須實現Comparable行為,你可以說<T extends Comparable>是要求編譯器進一步約束傳入物件必須具有Comparable行為,也可以說為了讓編譯器確認每個元素都有compareTo()方法可供操作,讓max函式現階段可以完成編譯。

類別型態也能參數化加深了複雜性

在函數式語言中隨處可見參數多型的應用,因而都直接稱「多型」而不特別稱參數多型。以Haskell為例,配合編譯器強大的類型推斷功能,定義函式時幾乎可忽略型態變數的存在(因為基本上連型態都不用宣告)。例如同樣以取清單中最大值的max'函式為例,Haskell中可以如下定義:
max' [] = error "empty list"
max' [x] = x
max' (x:xs) = max x (max' xs)

在上例中看不到型態變數的存在,也看不到型態類(Typeclasses)約束,但實際上max'函式宣告是(Ord a) => [a] -> aOrd即類似Java中Comparable的角色,然而這個宣告定義可由編譯器自動推斷整個函式定義得知,強大的型態推斷結合參數多型,使得開發者減輕不少型態定義上的負擔。

參數多型的原意,是減輕開發者使用靜態語言時必須時刻在意型態問題的負擔。對於將執行時期發生轉型錯誤的可能性,移轉至編譯時期就檢查出錯誤而言,Java的泛型確實達到這個目的,然而Java泛型語法本身可讀性不佳,單就前幾個Java泛型函式定義範例,就可看出端倪,Java本身又支援物件導向,如果將參數多型從函式擴展到類別,讓類別型態也可以參數化,就會使得情況更加複雜。

以Java的群集(Collection)為例,由於收集的物件多半是同質的(Homogeneous),為了讓編譯器協助開發者檢查出型態錯誤,而引進了Colllection<E>的泛型語法,E是型態變數,如果開發者宣告變數時使用Collection<String>,那麼編譯器就不允許收集String以外的物件,從而避免了執行時期ClassCastException的問題。

然而實際上,類別型態也可以參數化的結果,等於是對原有型態系統進了極大擴充。Collection<String>可看作一種型態、Collection<Customer>也是一種型態,既然將型態變數實例化後的結果都可視作一種型態,那麼就會衍生出Collection<Collection<Customer>>這類複雜的語法,即使Java群集框架的實現領導者Joshua Bloch喜歡泛型,認為在某些方面還是能實現簡潔度,但看到Enum<E extends Enum<E>>之類的語法,就覺得泛型的設計還不夠成熟到能放入Java中。

繼承與參數多型又衍生出變異性的問題

類別型態也能參數化,因而加深了原有型態系統的複雜度,若再加上繼承的問題,得再考慮更複雜的變異性(Variance)問題。在型態系統中,如果型態階層與取代階層的方向一致,稱之為正變性(Covariance),反正則稱為逆變性(Contravariance),兩者皆非則為不變的(Invariant)。以泛型來說,如果Banana繼承Fruit,而List<Banana>視為一種List<Fruit>,則稱List<E>具有共變性,如果List<Fruit>視為一種List<Banana>,則稱List<E>具有逆變性。

Java的泛型不具共變性,所以List<Banana>不是一種List<Fruit>,因而不能設計show(List<Fruit> fs)來顯示List<Banana>與List<Apple>(如果Banana與Apple都繼承自Fruit),但這個需求確實存在,Java中可使用?型態通配字元(Wild card)與extends來宣告變數,使其達到類似正變性,也就是設計show(List<? extends Fruit> fs)來接受List<Banana>與List<Apple>。

Java的泛型不具逆變性,所以List<Fruit>不是一種List<Apple>,這就有個困擾,如果要設計sort函式,希望可以傳入一個Comparator<Fruit>對List<Apple>或List<Banana>排序,顯然不能設計為sort(List<T> list, Comparator<T> c),以傳入List<Apple>為例,這時相當於sort(List<Apple> list, Comparator<Apple> c),這樣的話就不能接受Comparator<Fruit>作為第二個參數了。Java可以使用?型態通配字元與super來宣告變數,使其達到類似逆變性,也就是設計sort(List<T> list, Comparator<? super T> c),以傳入List<Apple>為例,這時相當於sort(List<Apple> list, Comparator<? super Apple> c),原先設計的Comparator<Fruit>也就可以傳入...然而...這一切實在是太複雜了...

參數多型是為了簡化而不是複雜化

參數多型出發點是為了減輕開發者處理型態的負擔,允許函式設計時不理會參數實際型態,從而設計出介面更一致的函式,並進一步得到編譯時期的型態安全檢查,這份苦差事顯然落到了編譯器頭上,具有強大類型推斷功能的Haskell從參數多型得到了益處,函式設計時彷若動態語言,不用在乎參數型態,但又得到實質的編譯時期型態安全檢查。

Java本身類型推斷功能有限,泛型語法本身亦不簡潔,考慮類別參數化會使得型態系統變得複雜,繼承而衍生出的變異性問題又使得觀念及語法更加魔幻,然而Java泛型不是沒有優點,一開始在Java中加入泛型也是為了減輕開發者一些手工任務,像是基本的泛型函式與收集同質物件的需求,使用泛型確實可以簡化程式,然而使用泛型是為了簡化而不是使程式複雜,若開始涉及Enum<E extends Enum<E>>之類的語法,或是面對變異性問題時,就應捨棄泛型,因為語法複雜度帶來的缺點已經超越編譯器給的優點,此時回歸特定多型或次型態多型也許才是正確的處理方式,例如先前排序需求可宣告為sort(List list, Comparator c),在必要時明確進行轉型,反倒是簡潔明確的作法。