神秘的Monad不神秘


iThome 網站首載:神秘的Monad不神秘

純函數式語言對許多開發者來說太過學術,或許也沒有躋身主流的一天,然而透過適當修正,許多函數式概念早已隱身或逐漸滲入主流語言,《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>TU可以是不同型態,isPresentfalse的話傳回Optional.EMPTY。簡單來說,Optional就像個盒子包裝了值,flatMap則含有判斷值是否存在、取值或建立Optional.EMPTY等運算,當然這些運算情境被隱藏了,Optional的使用者因此可明確指定感興趣的特定運算,從而使程式碼意圖顯露出來,又可接暢地接續運算,以避免巢狀或瀑布式的複雜檢查流程。

從盒中取出盒子的flatMap

OptionalflatMap這個名稱令人困惑,可從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一個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>實例建構方法(像是OptionalStreamof方法),一個可將M<A>映射為M<B>的方法(也就是flatMap),而方法接受可將A映射為M<B>的Lambda運算式。面對API這樣的結構,如果沒有接觸過函數式程式設計,大概不會知道這個結構的概念源由,是來自函數式世界中也算是神秘難解的Monad。

第一個將Monad概念引入語言中的是Haskell,在Haskell官方的〈All About Monads〉談到Monad在Haskell中的實現:一個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-elsefor的區塊,真的就像一層又一層的盒子包含了類似的運算,每一層運算的結果值會進入到下一層運算以產生新的值,就像是將盒子運算後的結果值取出送至下個盒子中做運算,然後再取出結果值繼續送至下個盒子中做運算,Monad結構就是要抽取這類結構中可重用的運算情境,建立為Monad型態,將結構類似的連續流程,轉變為一個又一個互不干擾,但可接續不斷的獨立運算系統。

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

重新思考新工具中滲入的典範

當一個語言或API出現在開發者面前,真正該學習的並不僅是語法或API的呼叫方式,而是背後的原始情境或典範,只有如此,才能進一步懂得如何善用語言或API,我前篇專欄〈探索技術背後的原始情境〉就是談這類概念。認真去瞭解原始情境或典範下的思考方式,也才能進一步對程式中的元素重新思考,就像我先前專欄〈List處理模式〉最後的結論,認識函數式的List處理模式,可以讓開發者重新思考資料管理問題!那麼Monad呢?為何開發者要瞭解Monad?認識Monad,可讓開發者在面對結構類似的連續流程時,重新思考能否轉變為接續不斷的獨立運算系統!

保守的Java在JDK8中都引入了函數式風格,連源於Monad概念的API也出現了,漠視他們繼續用既有方式思考程式會是種選擇,只是在合用情境出現時,你就只能繼續著土法煉鋼的設計方式,有時間的話,不如重新思考這類新工具背後的典範,相信對程式設計的觀點,也會因此有所不同,在合用情境出現時,也才能有用與不用兩種選擇,或甚至設計更合用的工具集合。