許多函數式概念早已隱身或逐漸滲入主流語言。《Coders at Work》中,Simon Peyton Jones就提到:「純函數式領域中學到的觀念與想法,可能給主流領域帶來資訊,帶來啟發」,如果連神秘的Monad都隱身到JDK8,與其只是模仿範例,不如進一步瞭解這類元素背後的典範,從中獲得資訊與啟發。

從JDK8的Optional談起

如果有參數order接受代表訂單的Order物件,可透過getCustomer取得代表客戶的Customer物件,Customer可由getAddress取得客戶的字串位址,為了避免NullPointerException,對order、getCustomer、getAddress都要檢查是否為null,因而很容易形成巢狀或瀑布式的檢查流程,就結構來看每層運算極為類似,只是傳回型態不同,很難抽取流程重用,然而此模式太常見,Groovy為此還直接提出?.運算,用order?.customer?.address來簡短達成任務。

在我先前專欄〈補救null的策略〉談過null的問題,並提到JDK8可使用Optional來建立有無的明確語意,而方才述及之情境,若讓參數order接受Optional<Order>,而getCustomer傳回Optional<Customer>、getAddress傳回Optional<String>會比較好,不過,就算可用order.orElse(EMPTY_ORDER).getCustomer().orElse(EMPTY_CUSTOMER).getAddress().orElse("Unknown")來取代連續的if-else判斷,可讀性似乎也好不到哪去。實際上Optional有個flatMap方法,可如下使用,以增加可讀性:

order.flatMap(Order::getCustomer)
     .flatMap(Customer::getAddress)
     .orElse("Unknown")

Optional<T>的flatMap實作中呼叫了isPresent方法,傳回true的話就取得值,以指定的Lambda對值運算以取得下個Optional<U>,T跟U可以是不同型態,isPresent為false的話傳回Optional.EMPTY。

簡單來說,Optional就像個盒子包裝了值,flatMap則含有判斷值是否存在、取值或建立Optional.EMPTY等運算,當然這些運算情境被隱藏了,Optional的使用者因此可明確指定感興趣的特定運算,從而使程式碼意圖顯露出來,又可接暢地接續運算,以避免巢狀或瀑布式的複雜檢查流程。

flatMap這名稱令人困惑,可從Optional<T>呼叫flatMap後會得到Optional<U>來想像,flatMap如同從盒子取出另一盒子置放一旁(flat就是平坦化的意思),過程中依指定之Lambda將前盒的T映射(map)為U,再放入後盒,為了能達成連續運算步驟,結構上需要有Optional型態、Optional實例建構方法與實作運算情境的flatMap方法,而flatMap接受將T映射為Optional<U>的Lambda運算式。

談到盒子就想到容器,想到容器就會想到List。如果之前的Order有個getLineItems方法,可取得訂單中的產品項目List<LineItem>,想要取得LineItem的名稱,可以透過getName來取得,若你有個List<Order>,想取得所有的產品項目名稱會怎麼寫?這類從List<T>經一連串類似步驟取得List<U>的需求經常發生,程式流程結構也大同小異,然而無論是巢狀或瀑布式的程式碼都不易理解,但又因為型態不同而難以抽取流程重用。若透過JDK8的Stream,你可以寫出以下可讀性較高的程式碼:

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

stream()方法會傳回Stream<Order>,把Stream當成盒子,stream()就是將一群Order物件放入盒中,flatMap指定的Lambda運算是order.getLineItems().stream(),就是從盒中那群Order物件逐一取得List<LineItem>,然後再用一個Stream將所有LineItem裝起來,也就是說,Stream<Order>經由flatMap方法後映射為Stream<LineItem>,這類操作一個個盒子(一個個Stream)接續下去,例如,想進一步取得LineItem的贈品名稱,可以如下:

List<String> itemNames = orders.stream()
          .flatMap(order -> order.getLineItems().stream())
            .flatMap(lineItem -> lineItem.getPremiums().stream())
          .map(LineItem::getName).collect(toList());

來自Monad的概念

無論是Optional或是Stream的例子,都可以發現他們具有相同的結構:一個型態M,一個M<A>實例建構方法(像是Optional或Stream的of方法),一個可將M<A>映射為M<B>的方法(也就是flatMap),而方法接受可將A映射為M<B>的Lambda運算式。

面對API這樣的結構,如果沒有接觸過函數式程式設計,大概不會知道這個結構的概念源由,是來自函數式世界中也算是神秘難解的Monad。

第一個將Monad概念引入語言中的是Haskell,在Haskell官方的〈All About Monads〉談到Monad的實現:Monad型態m、a -> m a的型態建構式,以及m a -> (a -> m b) -> m b的綁定(bind)函式。

在這樣的結構下,m a用來建立了Monad容器以持有型態為a的值,綁定函式是從Monad容器取出值傳給一個函式,該函式會產生新的Monad容器來包括型態為b的新值,而綁定函式名稱的由來,是因為它將Monad容器的值綁定為a -> m b函式的第一個引數,根據a -> m b函式指定的內容,結合綁定函式的運算情境,開發者就可以建立連續的運算步驟,來取代原本複雜不易閱讀,且難以重用的流程結構。

程式碼經常會出現結構類似的連續流程,像是一層層的if-else或for迴圈,如果單看每層if-else、for的區塊,真的就像一層層的盒子包含了類似的運算,每層運算結果值會進入到下一層運算以產生新值,就像是將盒子運算後的結果值取出,送至下個盒子運算,然後再取出結果值,繼續送至下個盒子運算,而Monad結構就是要抽取這類結構中可重用的運算情境,建為Monad型態,將類似的連續流程,轉為一個個互不干擾,但可接續的獨立運算系統。

具體來說,每個Optional與flatMap形成獨立的運算系統,flatMap重用了有無值判斷的邏輯,透過指定的Lambda運算式,產生出下個獨立的Optional運算系統,如此就能突顯Lambda運算式的意圖,隱藏有無值判斷的邏輯;每個Stream與flatMap也是形成獨立的運算系統,flatMap重用了走訪序列、串接序列等邏輯,透過指定的Lambda運算式,產生出下個獨立的Stream運算系統,也突顯Lambda運算式的意圖,隱藏序列處理的複雜邏輯。

當語言或API出現在開發者面前,該學習的不僅是呼叫方式,而是背後的原始情境或典範,如此才能進一步懂得如何善用語言或API,我前篇〈探索技術背後的原始情境〉就是談這概念。

認真去瞭解原始情境或典範下的思考方式,也才能進一步對程式中的元素重新思考,就像我先前專欄〈List處理模式〉最後的結論,認識函數式的List處理模式,可以讓開發者重新思考資料管理問題!那麼Monad呢?為何開發者要瞭解Monad?認識它,可讓開發者在面對結構類似的連續流程時,重新思考能否轉變為接續不斷的獨立運算系統!

JDK8引入了函數式風格,源於Monad概念的API也出現了,繼續用既有方式思考程式會是選擇,只是在合用情境出現時,你就只能繼續土法煉鋼。

有時間的話,不如重新思考這類新工具背後的典範,程式設計的觀點也會不同,在合用情境出現時,也才能有用與不用的選擇,甚至設計更合用的工具。

作者簡介


Advertisement

更多 iThome相關內容