泛型(generics)與函式界面(functional interface)做為強型別語言的一對翅膀

今天想來聊點泛型 (generics) 與 函式界面 (functional interface) 在強型別(strong-typed)語言中所扮演的重要角色。

身為Java愛好者、加上從事的工作和語義息息相關,不諱言地對於「強型別」的語言有一種執著。尤其在我們成長的年代,電腦並沒有像今天畫面包裝得美美的視窗防呆界面,對於機器的基底有多「固執」總有一種深刻的體會。若機器只接受整數123,你一不小心給他字串"123",就有可能當場「死」給你看。

而強型別語言的一板一眼有時確實也令人頭痛,就算可以運用多形 (polymorphism) 來增加語義上的彈性,仍然存在不少限制。例如我們可以用下面兩個同名函式來解決上面的問題,同時接受整數和字串:
int inc(int n) { return ++n; }
int inc(String n) { return Integer.valueOf(n)+1; }

不過其實它們也就是兩個機器不會搞混的函式罷了,語義的一致性是給人類看的,機器認的是方法簽章(method signature),兩個函式實際上並沒有什麼關聯性。

當泛型和函式界面先後導入Java之後,強型別的優勢可說突然如虎添翼。為了不讓講古佔太大的篇幅,以下直接來研究一個最近實際遇到的例子。

最近因為武漢肺炎的關係,時常透過寫程式研究一些COVID-19的數據,茲舉以下這個存放某個國家某日數據的value object為例:
class COVIDValue {
   String country;
   int recovered, cases, critical, active, deaths, todayCases, todayDeaths;
   double casesPerOneMillion, deathsPerOneMillion;
   // constructors, getters...
}

它包含了三種不同型別(Stringintdouble)的資料欄位。假設我們寫了一個函式list()用來將存放在List<COVIDValue> data集合中的所有資料按某種順序列印出來,至於要依什麼順序,則由傳入的參數keyExtractor來決定:
void list(Function<COVIDValue, Integer> keyExtractor) {
   data.stream()
      .sorted(Comparator.comparing(keyExtractor, Comparator.reverseOrder()))
      .forEach(System.out::println);
}

這樣的寫法主要是方便使用像list(COVIDValue::getCases)這樣的方式來呼叫並指定排序的欄位,而這類欄位(cases, todayCases...等等)多為整數,並且我們往往希望先看到數值較高的國家,因此加上了反向排序 reverseOrder() 做為第二參數。

排序是運用Java串流界面(Stream API)中的排序程式sorted,並配合排序器Comparator界面中的方法comparing。這個方法有不同參數的版本,為了加上反向排序,這裡用了兩個參數的版本,其簽章為:
static <T,U> Comparator<T> comparing(Function<? super T,? extends U> keyExtractor,
                                     Comparator<? super U> keyComparator)

然而當casesPerOneMillion這類帶有小數的欄位出現時,這樣的呼叫方式便會出現型別不一致的問題。雖然DoubleInteger有共同的父類別Number,但這個父類別並沒有實作Comparable,也就是無法直接用來做比較,因此沒有辦法將Integer直接改成Number

按前面提到的多形原理,我們可能會想再寫一個接受Double做為參數的版本:
void list(Function<COVIDValue, Double> keyExtractor) { /* ... */ }

以便讓下面兩種呼叫的方式都可以成立:
list(COVIDValue::getCases);
list(COVIDValue::getCasesPerOneMillion);

理論上這樣似乎可行,但在目前的Java語法中,光憑泛型的差異並沒有辦法用來區別方法簽章,也就是說:
void list(Function<COVIDValue, Integer> keyExtractor) { /* ... */ }
void list(Function<COVIDValue, Double> keyExtractor) { /* ... */ }

這兩個方法對編譯器來說是一模一樣的簽章(都是Function)無法並存,因此會產生編譯失敗。可能有人會想到直接將Integer改成Comparable,這樣做並非不行,但經過sorted()之後COVIDValue型別會被抹除,若要在串流的後段針對COVIDValue物件的內容做進一步運算處理,會增加許多麻煩。

要解決這個問題,我們同樣可以利用多形,首先我們把之前的方法改寫成更抽象的形式:
void list(Comparator<COVIDValue> comparator) {
   data.stream().sorted(comparator).forEach(System.out::println);
}

接下來加上一個同名方法:
void list(Function<COVIDValue, Comparable> keyExtractor) {
   list(Comparator.comparing(keyExtractor, Comparator.reverseOrder());
}

如此一來,我們便可以同時使用型別為IntegerDouble的屬性來呼叫,如list(COVIDValue::getCases)list(COVIDValue::getCasesPerOneMillion)等等,甚至使用型別為字串的屬性如list(COVIDValue::getCountry)也可以行得通。

另外我們也可以用自訂的計算方式來排序,例如:
list(value->1.0*value.getDeaths()/value.getCases());

以上善用泛型與函式界面,讓強型別的Java語言一方面擴增其彈性、另一方面又不犧牲掉型別和語義上的嚴謹性,尤其在大型專案中應可發揮不小的作用,減少許多除錯的成本。

留言

這個網誌中的熱門文章

病毒肆虐下、資料集間國名比對的聯想

關於「複合形式」(摘錄自作品首演文件)

以圖形資料建構Star Wars星戰宇宙中的行星們