about 4 years ago

即使沒有Stream,Java Collection framework的設計仍是相當不錯,只是有時候需要一些簡單的功能,例如:根據某些條件查找容器中的某個物件,總是要寫個for迴圈,程式不難但寫久了也覺得煩。在沒有類似for in的語法糖衣之前,index的管理很惱人。若是巢狀迴圈,index命名不好遇到問題debug起來更是頭痛,用過Apache Commons Collections後,Apache Commons Collections幾乎是專案裡必備的套件。先來個簡單例子,假設想在放Person物件的List中找姓名含某個特定值時,傳統的寫法如Code List 1,會是寫一個for迴圈,然後逐一檢查每個person物件的姓和名。

Code List 1 - Find an object in a list by using for loop
public Person findFirstPersonByForLoop(List<Person> persons, String firstOrLastName) {
    for (Person person : persons) {
        if (person.getFirstName().equalsIgnoreCase(firstOrLastName) ||
            person.getLastName().equalsIgnoreCase(firstOrLastName)) {
            return person;
        }
    }
    return null;
}

那如果用Apache Commons Collections又會如何呢?請看Code List 2.a,基本上不需要寫for迴圈,只要在呼叫find時傳一個實作Predicate介面的物件即可,該物件的實作在Code List 2.b,只需要實作evaluate這個method,判斷是否滿足條件,滿足回傳true,就這樣。什麼!程式碼行數變多,沒錯,確實變多,但PersonNamePredicate這物件是可以重複使用的,若觀察CollectionUtils這個class就會發現有12個methods利用Predicate物件對容器進行過濾、選擇、計數等操作,加上Predicate的實作要測試很容易,所以這樣寫很划算。

Code List 2.a - Find an object in a list by using Apache Commons Collections
public Person findFirstPersonByCommonsCollections(List<Person> persons, String firstOrLastName) {
    return CollectionUtils.find(persons, new PersonNamePredicate(firstOrLastName));
}
Code List 2.b - The implementation of PersonNamePredicate
public class PersonNamePredicate implements Predicate<Person> {

    private String _searchCondition;

    public PersonNamePredicate(String condition) {
        _searchCondition = condition;
    }

    @Override
    public boolean evaluate(Person person) {
        return  person.getFirstName().equalsIgnoreCase(_searchCondition) ||
                person.getLastName().equalsIgnoreCase(_searchCondition);
    }
}

好啦!既然主題是Java 8新的Stream API,那用Stream該怎麼寫?Stream的寫法會像Code List 3,好像沒有比較省行數,但和原始的Code List 1相比,就語意上或是可讀性上,Code List 3可以解讀成『根據一某條件過濾,然後找第一個,如果結果存在就回傳該物件,不存在就回傳null』,迴圈的操過被忽略了,是否有感覺抽象程度被提高了呢?

Code List 3 - Find an object in a list by using Java Stream API
public Person findFirstPersonByStream(List<Person> persons, String firstOrLastName) {
    Optional<Person> result = persons.stream()
            .filter(p -> p.getFirstName().equalsIgnoreCase(firstOrLastName) ||
                    p.getLastName().equalsIgnoreCase(firstOrLastName))
            .findFirst();
    return result.isPresent()? result.get() : null;
}

那如果要像Code List 2.a那樣,可以辦到嗎?可以!首先,像Code List 4.a,寫個StreamUtils輔助類別,提供一個find(Collection, Predicate)函式,然後改寫PersonNamePredicate如Code List 4.b,接著就可以像Code List 4.c那樣只寫一行就搞定。當然,如果不打算讓PersonNamePredicate同時支援Apache Commons Collections及Java Stream,只需實作java.util.Predicate介面的test函式就好。不過繞了一大圈,為的是什麼?除了Code List 4.a使用parallelStream()可能帶來平行處理的好處外,這個版本並沒有帶來太多的好處,主要是應用(find)太簡單了,用Stream有點殺雞用牛刀的感覺。

Code List 4.a - The find method of StreamUtils
public static <T> T find(Collection<T> container, Predicate<T> predicate) {
    Optional<T> result = container.parallelStream().filter(predicate).findFirst();
    return result.isPresent()? result.get() : null;
}
Code List 4.b - The revised PersonNamePredicate
public class PersonNamePredicate implements org.apache.commons.collections4.Predicate<Person>,
    java.util.function.Predicate<Person> {

    private String _searchCondition;

    public PersonNamePredicate(String condition) {
        _searchCondition = condition;
    }

    @Override
    public boolean evaluate(Person person) {
        return  person.getFirstName().equalsIgnoreCase(_searchCondition) ||
                person.getLastName().equalsIgnoreCase(_searchCondition);
    }

    @Override
    public boolean test(Person person) {
        return evaluate(person);
    }
}
Code List 4.c - Find an object in a list by using customized StreamUtils
public Person findFirstPersonByStreamUtils(List<Person> persons, String firstOrLastName) {
    return StreamUtils.find(persons, new PersonNamePredicate(firstOrLastName));
}

Java Stream API的概念類似Unix Pipelinepipes and filters design pattern,透過串接多個簡單的operation完成有意義的工作,由於operation通常都很簡單,所以使用Lambda expression多數時候可以帶來簡潔和提升可讀性的好處。如Figure 1所示,Java Stream能串多個intermediate operations,但最後只能串一個terminal operation來組成pipeline。用intermediate operation轉換stream內容,例如:過濾(filter(Predicate))、替換(map(Function)、排序(sorted(Comparator))等,然後用terminal operation對stream內的資料計算最終結果或產生side effect,例如:收集(collect(Collector))、逐一改變(forEach(Consumer))或歸納(Reduce(BinaryOperator))等。

Figure 1 - Stream Pipeline

例如,可以用Figure 2的pipeline來計算資料中資產超過10億元的富豪,其資產的總合,首先filter(Predicate)過濾出資產超過10億元的資料,接著用map(Function)取出資產的部分,最後用reduce(BinaryOperation)做歸納。事實上,類似的運算實在太常用了,因此Java Stream API中有個Collectors類別提供常用的terminal operation,例如summarizingDouble(ToDoubleFunction)就結合了map(Function)和預設的reduce(BinaryOperation)實作,簡化pipeline的組成。

Figure 2 - Stream Pipeline Example

覺得例子有點抽象?那再來一個更具體的例子吧。假設Exam代表一種測驗,每個人可以參加多次測驗,因此Person有一個List放存受測者參加過的所有測驗。假如想要取得曾經得超過700分的所有受測者排名,為了不寫重複的程式碼,如Code List 5,先將取得受測者曾經參加過的測驗最高分寫成PersongetHighestScore()函式(仍用Stream API)。

Code List 5 - Aggregation with Java Stream API
public Double getHighestScore() {
    Optional<Exam> maxScoreExam = getExams().stream()
                                    .max((e1, e2) -> e1.getScore().compareTo(e2.getScore())); 
    return maxScoreExam.isPresent()? highestScoreExam.get().getScore() : 0;
}

接著就可以寫一個如Code List 6的showRank(List<Person>, double)的函式,第一個參數是所有受測者,第二個參數是排行榜顯示的門檻。程式首先呼叫stream()取得Stream物件,接著對Stream呼叫filter(Predicate)函式,這裡用Lambda Expression就覺得很自然,也提高可讀性,過濾掉低於門檻值的受測者後,呼叫sorted(Comparator)進行排序,然後map(Function)將受測者的全名與分數組成字串(例如:"Spirit Tu: 840.0")當成結果,這邊要小心的是 map(Function)所回傳的Stream物件,裡面裝的已經不是Person物件了,而是字串,所以forEach(Consumer)的Lambda Expression中,e代表的是字串,直接就可以顯示在console上。最後,呼叫showRank(persons, 700)就可以看到曾經得超過700分的受測者排行榜了。Code List 6的整個流程可以畫成如Figure 3的pipeline。

Code List 6 - More interesting example of Stream API
public static void showRank(List<Person> persons, double threshold) {
    persons.stream()
        .filter(p -> p.getHighestScore() > threshold)
        .sorted((p1, p2) -> p1.getHighestScore().compareTo(p2.getHighestScore()))
        .map(p -> String.format("%s: %.1f", p.getFullName(), p.getHighestScore()))
        .forEach(s -> System.out.println(s));
}

Figure 3 - The pipeline of Code List 6

老實說,看到Java Sream API讓我感到相當親切,這應該跟我研究所多年的研究題目是visual dataflow language有關,在VisualTPL中,迴圈的概念被內化了,重點在於做什麼運算(what),而不是如何跑迴圈(how),同樣地,Java Stream API也是把迴圈給內化了,每個operation的重點是要做什麼,大大提高了程式的抽象化程度和可讀性。不過Java Stream API的特色還不只這些,剩下的下一篇再討論。

← Quick Glance of Java 8 - Closure Quick Glance of Java 8 - Stream →