Java 開發者的函數式程式設計(5)JDK8 預設方法


English

讓我們回歸到 Java 的現實世界吧!Java 的語法是用來定義抽象資料型態,以命令式風格來撰寫程式,它允許可變動的變數與物件,所以之前的文章是純屬娛樂嗎?嗯...如果有些聰明的傢伙已經實作了那些好用的方法,像是 mapfilterreduce 之類的,那我們就不用過問實作細節,只要針對這些公開的介面協定撰寫程式就行了,例如,對清單中每個元素加一的話,就可以撰寫為:
map(list(1, 2, 3, 4, 5), x -> x + 1);
將清單中小於三的元素過濾出來就可以撰寫為:
filter(list(1, 2, 3, 4, 5), x -> x > 3);
要加總清單元素就可以撰寫為:
reduce(list(1, 2, 3, 4, 5), (sum, x) -> sum + x, 0);
沒錯!幾乎每個 Java 開發者都聽過這個原則 - 根據介面撰寫程式,而不是根據實作。也許那些聰明的傢伙是用命令式風格來實作 mapfilterreduce 之類的方法,也許他們為了效率而實作了捷徑(Short-circut)、並行(Concurrent)、惰性(Lazy)等邏輯,無論如何,你只要函數式地思考與使用這些方法就可以了:我現在是打算把清單映射為另一個清單嗎?我現在是打算對原清單進行過濾嗎?我現在是打算逐一取得清單元素以計算出最後的結果嗎?我有沒有把問題分解為子問題?一旦你可以函數式地思考,你就會發現 Joel Spolsky 為什麼會說...

…有第一級函數的編程語言讓你找到更多抽象化的機會…

即使你最後仍是以命令式風格來撰寫程式,你還是可以函數式地思考。在撰寫程式時,函數式地思考總能讓你有新的想法或者是方向,這就是為什麼 Simon Peyton Jones 這麼提到…

…純函數式領域中學到的觀念與想法,可能給主流領域帶來資訊,帶來啟發…

當然,確實有群聰明的傢伙實作了那些函數式程式語言中常用到的方法,然而這邊的問題是,這些方法要放在哪?例如,像 mapfilterreduce 之類的方法要放在哪呢?我們是可以把這些方法定義為 Collections 類別上的 static 方法,這會讓這些方法看來就單純像是函式,就像 Python 中的 mapfilterreduce 函式。不過,Python 本身就是個具多重典範(Multi-paradigm)的語言,在 Python 中將 mapfilterreduce 定義為函式是很自然的一件事,然而,Java 主要典範是物件導向程式設計,把這些方法定義為 Collections 類別的 static 方法,會讓這些方法在 Java 中看來像是二等公民。我們希望這些方法在 Java 中可以有以下的運用風格:
List<String> names = ...;
names.filter(s -> s.length() < 3)
     .forEach(s -> out.println(s));
這樣的風格在 Java 中看起來,會比以下風格更有表達性一些:
forEach(filter(names, s -> s.length() < 3), s -> out.println(s));
只是,我們有辦法在 List 介面中增加像 filter 之類的方法嗎?如果用的是 JDK7 或先前的版本,答案當然是否定的!所有實作 List 介面的客戶端程式碼都會出錯,因為它們本來就沒有實作新增的那些方法。建立一個新的 Collections2 API 是個選項,不過現有的 Collection API 遍佈在全世界許多的程式庫中,要把這些既有的 Collection API 替換為新的 Collections2 會是個龐大任務,在 JDK8 釋出後,開發者應該不會想馬上用新的 Collections2 API 吧!
JDK8 最後採取的策略是,直接演化 interface 的語法,在 JDK8 中,interface 定義時可以加入預設實作,或者稱為預設方法(Default methods)。這策略看來有點像作弊,因為只有語言創建者或相關組織才有辦法這麼做,不過,這確實是在 Java 介面上進行防禦性 API 演化時一種可行的方式。
預設方法的實例之一,就是定義在 Iterable 介面的 forEach 方法:
package java.lang;

import java.util.Iterator;
import java.util.Objects;
import java.util.function.Consumer;

@FunctionalInterface
public interface Iterable<T> {
    Iterator<T> iterator();
    default void forEach(Consumer<? super T> action) {
        Objects.requireNonNull(action);
        for (T t : this) {
            action.accept(t);
        }
    }
}
Iterable 的實作類別,必須實作 iterator 方法,這麼一來,API 客戶端就可以直接使用 forEach 方法。例如,你可以如下撰寫程式碼:
List<String> names = ...;
names.forEach(
    name -> out.println(name.toUpperCase())
);
因為 forEach 方法本身已有實作,所以不會破壞 Iterable 現有的其它實作。預設方法令介面看來像是有抽象方法的抽象類別,不過不同點在於,預設方法中不能使用值域(Field)成員,因為介面本身不能定義值域成員。如上所示,你可以使用預設方法來實作樣版方法(Template Method)模式,例如,你可以如下定義自己的 Comparable 介面:
public interface Comparable<T> {
    int compareTo(T that);

    default boolean lessThan(T that) {
        return compareTo(that) < 0;
    }
    default boolean lessOrEquals(T that) {
        return compareTo(that) <= 0;
    }
    default boolean greaterThan(T that) {
        return compareTo(that) > 0;
    }
    ...
}
如果有個 Ball 類別打算實作 Comparable 介面的話,就只需要實作 compareTo 方法:
public class Ball implements Comparable<Ball> {
    private int radius;
    ...
    public int compareTo(Ball that) {
        return this.radius - that.radius;
    }
}
這麼一來,每個 Ball 實例就可擁有 Comparable 介面定義的那些預設方法。因為類別可以實作多個介面,預設方法的新特性,會讓介面看來就像是 Scala 中的 Trait,或者像是 Ruby 中的 Module。你可以在某些介面中定義可共用的一些操作,如果有個類別需要某些可共用的操作,就只需要實作相關介面,並實作介面中未實作的抽象方法,那麼就可以混入(Mixin)這些共用的操作了。
當然,有關預設方法還有一些細節,你可以看看 State of the Lambda v4 這些文章瞭解更多細節。我們先回到先前看過的例子,我們想要有以下的程式碼撰寫風格:
List<String> names = ...;
names.filter(s -> s.length() < 3)
     .forEach(s -> out.println(s));
不過在 JDK8 中,我們實際上必須撰寫為:
List<String> names = ...;
names.stream()
     .filter(s -> s.length() < 3)
     .forEach(s -> out.println(s));
為什麼要多那個 stream 方法呢?這是下一篇文章中要討論的重點!