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));
    }
}

因此,可以如下使用 OptionalflatMap 方法:

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

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

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

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

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

若沒辦法修改 findCompanyfindAddressfindCity 等傳回 Optional 型態怎麼辦?Optional 有個 map 方法,例如,若參數 orderOrder 型態,有 null 的可能性,findCompanyfindAddressfindCity 等的傳回型態各是 CompanyAddressCity,也都有可能傳回 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迴圈,若是持續加深這類層次,程式碼就會迅速地失去可讀性。

可以用 Liststream 方法取得 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> 看成盒子,盒中會有一組 OrderflatMap 逐一取得 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());

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

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

CollectorsflatMapping 方法,可指定 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