Optional/Stream 的 flatMap

September 29, 2022

程式設計中經常會出現巢狀的流程,就結構來看各層運算往往極為類似,只是運算結果的型態不同,很難抽取流程重用。

Optional 的 flatMap

舉例來說,若方法可能傳回 null,你也許會設計出如下的流程:

var company = order.findCompany();
if(company != null) {
    var address = company.findAddress();
    if(address != null) {
        return address;
    }
}
return "n.a.";

巢狀的層次可能還會更深,像是 …

var company = order.findCompany();
if(company != null) {
    var address = company.findAddress();
    if(address != null) {
        var city = address.findCity();
        if(city != null) {
            ....
        }
    }
}
return "n.a.";

巢狀的層次不深時,也許程式碼看來還算直覺,然後層次加深之後,就容易迷失在層次之中,雖然各層都是判斷值是否為 null,不過因為型態不同,似乎不易於抽取流程重用。

方法可能沒有值時,不建議使用 null,若能修改 findCompany 傳回 Optional<Company>、findAddress 傳回 Optional<String>,那一開始的程式片段可以先改為:

var addr = "n.a.";
var company = order.findCompany();
if(company.isPresent()) {
    var address = company.get().findAddress();
    if(address.isPresent()) {
        addr = address.get();
    }
}
return addr;

看來好像沒有高明到哪去,不過至少每層都是 Optional 型態了,而且都採用了 isPresent 判斷,並將 Optional<T> 轉換為 Optional<U>。

若將 Optional<T> 轉換為 Optional<U> 的方式可由外部指定,就可以重用 isPresent 的判斷了,實際上 Optional 有個 flatMap 方法,已經幫寫好這個邏輯了:

public<U> Optional<U> flatMap(Function<? super T, Optional<U>> mapper) {
    Objects.requireNonNull(mapper);
    if (!isPresent())
        return empty();
    else {
        return Objects.requireNonNull(mapper.apply(value));
    }
}

因此,可以如下使用 Optional 的 flatMap 方法:

return order.findCompany()
            .flatMap(Company::findAddress)
            .orElse("n.a.");

如果層次不深,也許看不出使用 flatMap 的好處,然而層次加深,好處就顯而易見了,例如一開始第二個程式片段,改寫為以下就清楚多了…

return order.findCompany()
            .flatMap(Company::findAddress)
            .flatMap(Address::findCity)
            .orElse("n.a.");

Optional 的 flatMap 名稱令人困惑,可從 Optional<T> 呼叫 flatMap 後會得到 Optional<U> 來想像一下,flatMap 對目前盒子內含值進行運算,結果交給 Lambda 表示式轉換至新盒子,以便進入下個運算情境,flat 是平坦化的意思,就 Optional 而言,flatMap 的意義就是,對 Optional 內含值進行 null 判斷的運算,有值就套用 Lambda 表示式映射,以便進入下個 null 判斷的運算。

因此使用者可以只指定感興趣的運算,從而突顯程式碼的意圖,又可流暢地撰寫程式碼,避免巢狀的運算流程。

若沒辦法修改 findCompany、findAddress、findCity 等傳回 Optional 型態怎麼辦?Optional 有個 map 方法,例如,若參數 order 是 Order 型態,有 null 的可能性,findCompany、findAddress、findCity 等的傳回型態各是 Company、Address、City,也都有可能傳回 null,那麼就可以這麼做:

return Optional.ofNullable(order)
               .map(Order::findCompany)
               .map(Company::findAddress)
               .map(Address::findCity)
               .orElse("n.a.");

與 flatMap 的差別在於,map 方法實作中,對 mapper.apply(value) 的結果使用了 Optional.ofNullable 方法(flatMap 中使用的是 Objects.requireNonNull),因此可持續處理 null 的情況:

public<U> Optional<U> map(Function<? super T, ? extends U> mapper) {
    Objects.requireNonNull(mapper);
    if (!isPresent())
        return empty();
    else {
        return Optional.ofNullable(mapper.apply(value));
    }
}

Stream 的 flatMap

如果之前的 Order 有個 lineItems 方法,可取得訂單中的產品項目 List<LineItem>,且想取得 LineItem 的名稱時,可以透過 name 取得,若有個 List<Order>,想取得全部產品項目的名稱會怎麼寫?你可能會馬上想到使用迴圈…

var itemNames = new ArrayList<String>();
for(var order : orders) {
    for(var lineItem : order.lineItems()) {
        itemNames.add(lineItem.name());
    }
}

層次不深時這樣寫可讀性還可以,不過若層次加深,例如,想進一步取得 LineItem 的贈品名稱,又得多一層for迴圈,若是持續加深這類層次,程式碼就會迅速地失去可讀性。

可以用 List 的 stream 方法取得 Stream,接著運用 flatMap 方法改寫:

List<String> itemNames = orders.stream()
       .flatMap(order -> order.lineItems().stream())
       .map(LineItem::name)
       .collect(toList());

就程式碼閱讀來說,第一個 stream 方法傳回 Stream<Order>,緊接著的 flatMap 傳回 Stream<LineItem>,若將 Stream<Order> 看成盒子,盒中會有一組 Order,flatMap 逐一取得 Order,Lambda 將 Order 轉換為 Stream<LineItem>,flatMap 執行後的傳回值是 Stream<LineItem>,後續可以再逐一取得 LineItem,就上例而言,是再透過 name 取得名稱。

如果想進一步透過 LineItem 取得複數的贈品呢?可以如下:

List<String> itemNames = orders.stream()
       .flatMap(order -> order.lineItems().stream())
       .flatMap(lineItem -> lineItem.premiums().stream())
       .map(Premium::name)
       .collect(toList());

基本上,若能瞭解 Optional、Stream(或其他型態)的 flatMap 方法,就是對目前盒子內含值進行運算,結果交給 Lambda 表示式轉換至新盒子,以便進入下個運算情境,在撰寫與閱讀程式碼時,忽略掉 flatMap 這個名稱,就能比較清楚程式碼的意圖。

flatMap 方法的概念,來自於函數程式設計(Functional Programming)中的單子(Monad)概念。

Collectors 的 flatMapping 方法,可指定 flatMap 操作並傳回 Collector 實例,flatMapping 方法用來減少管線操作層次,或者建立可重用的 Collector,例如原先有個操作:

List<String> addressLt = customers.stream()
      .flatMap(customer -> customer.addressList().stream())
      .collect(toList());

若改用 flatMapping 方法,可以如下:

List<String> addressLt = customers.stream()
      .collect(flatMapping(
          customer -> customer.addressList().stream(), toList())
      );

分享到 LinkedIn 分享到 Facebook 分享到 Twitter