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>
,T
跟U
可以是不同型態,isPresent
為false
的話傳回Optional.EMPTY
。簡單來說,Optional
就像個盒子包裝了值,flatMap
則含有判斷值是否存在、取值或建立Optional.EMPTY
等運算,當然這些運算情境被隱藏了,Optional
的使用者因此可明確指定感興趣的特定運算,從而使程式碼意圖顯露出來,又可接暢地接續運算,以避免巢狀或瀑布式的複雜檢查流程。- 從盒中取出盒子的
flatMap
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
一個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在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-else
、for
的區塊,真的就像一層又一層的盒子包含了類似的運算,每一層運算的結果值會進入到下一層運算以產生新的值,就像是將盒子運算後的結果值取出送至下個盒子中做運算,然後再取出結果值繼續送至下個盒子中做運算,Monad結構就是要抽取這類結構中可重用的運算情境,建立為Monad型態,將結構類似的連續流程,轉變為一個又一個互不干擾,但可接續不斷的獨立運算系統。具體來說,每個
Optional
與flatMap
形成獨立的運算系統,flatMap
重用了有無值判斷的邏輯,透過開發者指定的Lambda運算式,產生出下個獨立的Optional
運算系統,如此就能突顯Lambda運算式的意圖,隱藏有無值判斷的邏輯;每個Stream
與flatMap
也是形成獨立的運算系統,flatMap
重用了走訪序列、串接序列等邏輯,透過開發者指定的Lambda運算式,產生出下個獨立的Stream
運算系統,突顯了Lambda運算式的意圖,隱藏了序列處理的複雜邏輯。- 重新思考新工具中滲入的典範
當一個語言或API出現在開發者面前,真正該學習的並不僅是語法或API的呼叫方式,而是背後的原始情境或典範,只有如此,才能進一步懂得如何善用語言或API,我前篇專欄〈探索技術背後的原始情境〉就是談這類概念。認真去瞭解原始情境或典範下的思考方式,也才能進一步對程式中的元素重新思考,就像我先前專欄〈List處理模式〉最後的結論,認識函數式的List處理模式,可以讓開發者重新思考資料管理問題!那麼Monad呢?為何開發者要瞭解Monad?認識Monad,可讓開發者在面對結構類似的連續流程時,重新思考能否轉變為接續不斷的獨立運算系統!
保守的Java在JDK8中都引入了函數式風格,連源於Monad概念的API也出現了,漠視他們繼續用既有方式思考程式會是種選擇,只是在合用情境出現時,你就只能繼續著土法煉鋼的設計方式,有時間的話,不如重新思考這類新工具背後的典範,相信對程式設計的觀點,也會因此有所不同,在合用情境出現時,也才能有用與不用兩種選擇,或甚至設計更合用的工具集合。