即使是在函數式語言中,乍看也像是外星人的Monad結構,實際上早已悄悄隱身在各種非函數式語言之中,透過理解這類野生Monad套用前的場合與發掘方式,就能理解各類型Monad容器各自封裝了哪些運算情境,而又該將什麼樣的值抽取出來置入Monad容器中。

這個尋找的訓練過程,不僅有助於Monad結構的理解,更有助於將來相關應用情境的發掘與溝通。

最近熱議的野生Monad

程式碼中出現null的判斷,再常見也不過了,對函數式味道稍有敏感性的開發者,馬上就會聯想到像是JDK8中Optional之類的元素,將一次的if(xxx != null) { doSometing(xxx) },使用ofNullable(xxx).ifPresent(xxx -> doSometing(xxx))之類操作替代,就這類開發者來說,算是很熟悉的一種模式了。

那麼,如果需要連續的null判斷呢?改為ofNullable(xxx).ifPresent(xxx -> ofNullable(xxx.getYyy()).ifPresent(yyy -> ofNullable(yyy.getZzz()).ifPresent(....))),這等難以閱讀的程式碼,恐怕不是個好主意。

無論是巢狀或瀑布式的null判斷,在每次取「值」之前,其實都會呈現出一種「運算情境」:進行null判斷。如果開發者知道可以把「值置入Optional」,那麼每次取「值」之前,會進一步呈現另一種「運算情境」:進行ifPresent判斷,在為true情況下,使用ofNullable傳回Optional實例。

如果把這個「運算情境也放入Optional」,設計為一個map方法,方法內容就會像是if(isPresent()) { return ofNullable(取得下個值); },如果可以在呼叫map方法時指定取值的行為,那麼就會是ofNullable(xxx).map(取值),由於map方法傳回Optional實例,就可以重複使用map中封裝的運算情境,繼續指定取得下個值的行為。

重複是個典型的重構訊號,只不過依重複的樣貌不同,會選擇不同的重構方式以去除重複。在這個場景中,重複的訊號有兩個對象,值與運算情境,並形成巢狀或瀑布式結構。第一次重構時,將「值置入Optional」,第二次重構時的map方法將「運算情境也放入Optional」,這才使得重複性順利去除。

實際上flatMap也是類似地,它將另一個運算情境放入Optional,開發者可以觀看其原始碼來理解這個運算情境,只要理解Optional、被封裝的值與運算情境之關係,就能在重複判斷值存在與否的場合,將值置入Optional,然後連續重用被封裝的運算情境。

從List<Order>清單中每個Order當中取得Customer,收集至List<Customer>清單,這很簡單,用個迴圈就可以搞定,如果要從List<Order>清單中的每個Order取得Customer,再從Customer取得Address,再從Address取得City,最後收集至List<City>清單中呢?嗯!無論採用巢狀或瀑布式流程,三次迴圈應該可以搞定!

實際上,每次迴圈迭代就是一種重複的運算情境,List本身就是「值」的容器,如果可以在List本身設計一個map封裝「迭代並收集為List傳回的運算情境」,然後可以對map指定取得下個值的行為,那就可以使用list.map(取值).map(取值)的方式,來避免重複的迴圈結構。

這種模式在其他語言中很常看到,像是JavaScript的陣列本身就有map方法。在JDK8中,本來的提案也曾經打算這麼設計,不過後來的定案是得明確將List中的值(元素)置入Stream容器中,才能使用Stream的map封裝之運算情境。

類似地,如果List<Order>中每個Order的getItems會傳回List<Item>,透過map將會取回List<List<Item>>,如果想將這個結果展開並重新收集為List<Item>,就會需要再一次迭代,這類運算情境不算罕見,開發者可以將「展開並進行迭代的運算情境」封裝至一個flatMap方法,那麼就可以對flatMap指定取得下個清單的行為,形成list.flatMap(取得清單).flatMap(取得清單)流程,只不過,JDK8也是將這個行為放在Stream上罷了。

在JavaScript的世界中進行非同步處理時,為了避免回呼地獄(Callback hell),採用Promise模式是常見的一個建議,其中一個使用情境是,檢查非同步執行結果成功或失敗,分別再指定下一次回呼,以取得進一步結果。執行結果是一個值,Promise型態是值的容器,檢查成功或失敗是重複的運算情境,這樣就可避免繁複的非同步呼叫邏輯,這在我先前專欄〈非同步操作的多種模式〉中曾經討論過。

JDK8的CompletableFuture也是類似的實現,開發者可以透過supplyAsync指定一個非同步求值運算,這傳回一個CompletableFuture實例,會在非同步任務完成時作為結果值容器,thenApplyAsync方法可以指定下個非同步求值行為,傳回CompletableFuture實例在非同步任務完成時,依舊作為結果值容器,thenApplyAsync的角色就像是先前談過的map方法,而封裝另一個運算情境的thenComposeAsync,角色就相當於先前談過的flatMap方法。

野生Monad的特徵

重複的求值、重複的運算情境,只要設法建立容器,就可以將值置在容器,將重複運算情境封裝在容器中,然後在必要時,將值與運算情境聯結起來。

重複可能以巢狀或瀑布流程呈現,也可能以回呼地獄的方式呈現,通常這會發生在具備一級函式概念的語言中,像是JavaScript,實際上,一開始看到的Optional中使用Lambda的範例,也形成了一種回呼地獄,開發者並不會因為使用了Optional,就自動避免了這種狀況,辨別重複的求值與運算情境,才是最重要的過程。

談到巢狀或瀑布式的重複結構,還有個常見的形式:例外處理。舉例來說,開發者可能進行多次運算,下個運算會以上個運算結果作為輸入,每次運算可能會求得值或發生例外,而開發者得使用try-catch處理例外,這很容易寫出這種重複結構。

例如,Scala中有個Try類別,可以指定求值行為建立Try實例,連續求值行為可以透過map來組合,其中封裝了try-catch的運算情境,巢狀或瀑布式的try-catch結構,就可以轉換為Try(op1()).map(x => op2(x)).map(y => op3(y))的形式,Try上頭也有個flatMap方法,如果op1、op2等操作都是傳回Try實例時可以使用。

顯然地,上述的Optional、Stream、Promise、CompletableFuture、Try等類別,以及map、flatMap、thenApplyAsync、thenComposeAsync等方法,都是觀察與重構而建立的API,基於同樣的過程,開發者也可建立類似結構。例如,對使用者驗證並收集驗證錯誤訊息,容易形成瀑布式流程,〈Monadic Java〉就以此為例,設計了Validation類別,在map與flatMap中封裝了使用者驗證與錯誤訊息收集的情境。

Monad是一種模式

如果有人熟悉Monad,可能會說,上頭談到的map操作,不算是Monad結構中的定義,像是Haskell官網上的〈Monad - Haskell〉中定義就沒有這個操作。當然,現在談的是在非函數式語言中野生的Monad,實際上,map中可以使用flatMap來定義,例如,Optional的map方法,實際上也可以使用flatMap(x -> ofNullable(mapper.apply(x)))來實作。

因此,就如同Gof設計模式,Monad不過也就是一種模式,值、運算情境及容器是其結構,而巢狀、瀑布式或回呼地獄,則是發掘出這類結構的場合。現今開發者大多可以用設計模式名稱,來溝通一些設計上的想法與概念,不過,想想當初我們不也是得從各種情境中,來理解這些設計模式背後代表著哪些問題從而熟悉相關名稱,雖然現在還不習慣Monad作為溝通名詞,不過,多從Optional、Stream、Promise這類既存的API來理解,終有一天會習慣。

作者簡介


Advertisement

更多 iThome相關內容