Preface
今天跟大家介紹 Monad,這個令人生畏的單字。不過不用怕,我跟大家一樣,都是不懂它後面那複雜的數理,那 Lambda Calculus。儘管如此,我還是能夠在日常的開發裏使用它,從它得到不少好處。本文將透過實作來闡釋 Monad,也會解釋,對於 Java 開發者來說,它到底有什麼幫助。
Example 1: Optional
- // 從 Account 裏取出居住的城市名稱
- String getCityName(Account account) {
- if (account != null) {
- if (account.getAddress() != null) {
- if (account.getAddress().getCity() != null) {
- return account.getAddress().getCity().getName();
- }
- }
- }
- return "Unknown";
- }
有沒有辦法可以避免這些問題呢?我們先來試試 extract method:
- // 將檢查 null 的邏輯獨立抽出
- // map() 呼叫一個外部的轉換 function, 如果 value 不是 null
- // 的話,它會將 value 轉成 R。
R map(T value, Function transform) { - if (value != null) {
- return transform.apply(value);
- }
- return null;
- }
- //用 map() 改寫後
- String getCityName(Account input) {
- Address address = map(input, account -> account.getAddress());
- City city = map(address, a -> a.getCity());
- String name = map(city, c -> c.getName());
- if (name != null) {
- return name;
- }
- return "Unknown";
- }
hmmm.... 這改寫是有去除掉一些重複的程式,但沒有好很多,因為它還是沒有解決 null 容易遺漏的問題。如果真要保證不會遺漏,最好是 compile 時期就能發現。我們不能變更 Java 語言,不過物件導向給了我們自訂型別的能力 -- 型別不對 compile 就不會過!
我們繼續重構,這一次抽象化一個特殊的容器 Optional,包裝這個反復出現 null 檢查:
- class Optional {
- //容器內存著一個值,有時是 null
- private final Object value;
- Optional(Object value) {
- this.value = value;
- }
- // map() 呼叫一個外部的轉換 function, 如果 value 不是 null
- // 的話,它會將 value 轉成 R,再用新的容器包一次傳出去。
- Optional map(transfer) {
- if (value != null) {
- return new Optional(transfer(value));
- }
- return new Optional(null);
- }
- //方便的 method
- Object orElse(defaultValue) {
- return value != null ? value : defaultValue;
- }
- }
- String getCityName2(Account inputAccount) {
- Optional optAccount = new Optional(inputAccount);
- Optional optAddress = optAccount.map({account -> account.address});
- Optional optCity = optAddress.map({address -> address.city});
- Optional optName = optCity.map({city -> city.getName()});
- return optName.orElse("Unknown");
- }
- account = new Account(address=new Address(city=new City(name="Taipei")))
- account2 = new Account(address=new Address(city=null))
- printf("City name=%s\n", getCityName2(account))
- printf("City name=%s\n", getCityName2(account2))
- String getCityName3(Account inputAccount) {
- return new Optional(inputAccount)
- .map({account -> account.address})
- .map({address -> address.city})
- .map({city -> city.name})
- .orElse("Unknown");
- }
目前為止,Optional 這樣的容器,很像我們今天要討論的 Monad 了,讓我們看更多的例子來進一步了解。
Example 2: Transactional
- def transfer(Account account1, Account account2, int m) {
- database.beginTransaction(); //開啟資料庫的交易
- try {
- account1.withdraw(m); //提錢
- try {
- account2.deposit(m); //存錢
- } catch (Exception e) {
- database.rollback(); //放棄,恢復資料庫
- return;
- }
- } catch (InsufficientBalanceException e) {
- System.err.printf("\t[Error] %s\n", e)
- database.rollback(); //放棄,恢復資料庫
- return;
- }
- if (!database.isRollback()) {
- database.commit(); //最後都沒異常才會進資料庫
- }
- }
我們來套套看剛才 Optional 範例中學到的解法:
- class Transactional {
- // 資料庫交易開始
- static Transactional begin() {
- database.beginTransaction();
- return new Transactional(TxState.BEGIN);
- }
- private final TxState txState;
- static Database database=new Database()
- Transactional(TxState txState) {
- this.txState = txState;
- }
- // 這裏會根據傳入的 transform Function 的行為,
- // 對資料庫做不同的操作
- Transactional map(transform) {
- // 如果當前的交易狀態不是已開始,直接跳過
- if (txState != TxState.BEGIN)
- {
- return this;
- }
- try {
- //執行外部的邏輯
- TxState result = transform(txState);
- return new Transactional(result);
- } catch (TransactionException e) {
- System.err.printf("\t[Error] %s\n", e)
- database.rollback(); //transform 如果出錯,放棄交易
- return new Transactional(TxState.ROLLBACK);
- }
- }
- // 如果交易的狀態是已經開始,就對資料庫下 commit。
- // 反之則跳過不做事。
- Transactional commit() {
- return map({state ->
- database.commit();
- return TxState.COMMIT;
- });
- }
- }
- void transfer2(Account account1, Account account2, int m) {
- Transactional.begin()
- .map({txState ->
- account1.withdraw(m);
- return txState;
- })
- .map({txState ->
- account2.deposit(m);
- return txState;
- })
- .commit();
- }
Transactional 這個新容器解決了:
Transactional 這個例子比較複雜,但相對的,套入 容器 的概念後,我們獲得的好處更多,有一種遇強則強的感覺,這種好事在程式中是很少見的。再繼續深入探討之前,我們來整理一下兩個例子裡,它們容器的共同點:
可以想見 map() 是個關鍵的設計。現在我們知道它可以去掉 if、try catch 這樣的重複結構,不過如果要去除更複雜的結構,我們需要更強大的 flatMap。
flatMap -展開轉換
讓我們回到 Optional 的例子,我們剛才有看到取出城市名稱可以連鎖 map() 呼叫,不過隨著程式越寫越多,難免會出現包了兩層 Optional 的情況:
- class Account
- {
- // get city 太常用所以寫了個可重用的 method
- public Optional city() {
- return new Optional(address).map({o->o.city});
- }
- }
- String getCityName4(Account inputAccount) {
- Optional optAccount = new Optional(inputAccount);
- //想重用 account.city() 結果出現雙層 Optional
- Optional optOptCity = optAccount.map({account -> account.city()});
- //只好連續 map 兩次硬生生展開 Optional,好噁...
- Optional optName = optOptCity.map({optCity->optCity.map({o->o.name})})
- return optName.orElse("Unknown");
- }
- class Optional {
- ...
- Optional flatMap(transform) {
- if (value == null) return new Optional(null);
- return transform(value);
- }
- ...
- }
- String getCityName5(Account inputAccount) {
- // flatMap 那行的 Function generic 是:
- // Function
> - return new Optional(inputAccount)
- .flatMap({account -> account.city()})
- .map({city -> city.getName()})
- .orElse("Unknown");
- }
Example 3: Stream
第三個例子,是收集一群帳號裡的所有台灣電話。因為每個 Account 都有多個電話,所以用巢狀的 for loop 收集
- List
taiwanPhoneNumbers(List accounts) { - List
numbers = new ArrayList<>(); - for (Account account : accounts) {
- for (Phone phone : account.getPhones()) {
- if (phone.getNumber().startsWith("+886")) {
- numbers.add(phone.getNumber());
- }
- }
- }
- return numbers;
- }
- class Stream
- {
- private List values
- public Stream(List vals){values = vals}
- Stream flatMap(transform)
- {
- def results = []
- for(def value:values)
- {
- Stream transformed = transform(value)
- for(def result:transformed.values)
- {
- results.add(result)
- }
- }
- return new Stream(results)
- }
- Stream map(transform) {
- // 注意:這裏只是 flatMap 和建構子的組合
- return flatMap({value ->
- new Stream(asList((transform(value))))});
- }
- // filter 對每個 T 值做判斷,Stream 中只留下判斷為 true 的值
- Stream filter(predicate) {
- // // 一樣只是 flatMap 和建構子的組合
- return flatMap({value ->
- if (predicate(value)) {
- return new Stream(asList(value));
- } else {
- return new Stream(Collections.emptyList());
- }
- });
- }
- List toList(){return new ArrayList(values)}
- }
- List
taiwanPhoneNumbers2(List accounts) { - return new Stream(accounts)
- .flatMap({account -> new Stream(account.getPhones())})
- .map({phone -> phone.getNumber()})
- .filter({number -> number.startsWith("+886")})
- .toList();
- }
宣告式不僅程式易讀,也增加了最佳化的可能性 (Java 8 裏真正的 Stream 效能和 for loop 一樣,可以參考 paper) 。
Monad Design Pattern
一共舉了三個範例,分別解決不同的運算問題,但是解法都是設計一個狀態的容器,加上 flatMap() method 來接受轉換的函式。具備這樣特徵的容器我們稱之為 Monad:
- class Monad
{ - // 建構子提供狀態的起始值,當然也可以寫成
- // factory method,意思一樣就行
- Monad(T state) {...}
- // flatMap() 提供改變狀態,以及 Monad
- // 的可組合性 (Composibility)
-
Monad flatMap(Function > transform) { - // 封裝反覆出現的運算
- // ...
- }
- //map() 只是建構子和 flatMap() 的組合,算是 flatMap 的捷徑
-
Monad map(Function transform) { - return flatMap(state ->
- new Monad(transform.apply(state));
- }
- }
Monad 的適用範圍
什麼時候適用 Monad 來解決問題呢?從上面範例的推導裏,相信大家已經有點概念了,我們重新整理成比較通用的規則:
如果有幾項符合,那麼就可以試試 Monad 來解決。
Monad 的優點
同樣,範例中已經展現了 Monad 帶給我們的好處,我們總結一下
優點實在很多啊!重新整理一下範例,看得更清楚些:
Monad 的缺點
Monad 帶來的好處很多,但有光就有影,它最大的缺點是它是侵入式的,一旦開始採用後,你的 API 就會被迫改變:
- //原本 API 很乾淨的:
- City getCity() {...}
- //加上 Monad 的保護,API 非改不可,有時候這不是你想要的
- Optional
city() {...} - //如果出現了需要混用不同 Monad 的情境,就完了
- //試想,這 API 能看嗎?
- Transactional
> tryCreateCity(String cityName) {...}
Example 4: Promise
希望現在你對 Monad 已經很有感覺了,也不會再遇到別人提到 Monad,而有聽沒有懂。這篇文的最後,我們來挑戰最後一個例子,它的運算結構很複雜 -- 就是惡名昭彰的 callback hell. 下面 crawlPage() 是個抓網頁的程式 -- 它抓個 html 網頁後就存檔,存檔完再寄信通知:
- //抓網頁存檔再寄信通知,連續 callback 的風格
- void crawlPage(String url) {
- httpClient.getHtml(url, (String html) -> {
- String fileName = "Page1.html";
- fileUtil.writeFile(fileName, html, (Boolean success) -> {
- String email = "my@gmail.com";
- nailClient.sendEmail(email, (Boolean sent) -> {
- System.out.println("result: " + sent);
- });
- });
- });
- }
- //三個非同步的工具,它們的 method 都是接 callback:
- class HttpClient {
- void getHtml(String url, Consumer
callback) { - //download html text... then invoke callback
- }
- }
- class FileUtil {
- void writeFile(String fileName,
- String data,
- Consumer
callback) { - //writing data to file... then invoke callback
- }
- }
- class MailClient {
- void sendEmail(String email, Consumer
callback) { - //sending... then invoke callback
- }
- }
Promise Monad
- class Promise
{ - private T value;
- private Function pendingTransform;
- private Promise chainPromise;
- Promise(T value) {
- this.value = value;
- }
- public
Promise map(Function transform) { - return flatMap(value -> new Promise<>(transform.apply(value)));
- }
- public
Promise flatMap(Function > transform) { - if (value != null) {
- return transform.apply(value);
- }
- pendingTransform = transform;
- Promise
chainPromiseR = new Promise<>(null); - this.chainPromise = chainPromiseR;
- return chainPromiseR;
- }
- public void complete(T value) {
- if (pendingTransform == null) {
- this.value = value;
- return;
- }
- Promise
- (Promise
- promiseR.flatMap(nextValue -> {
- chainPromise.complete(nextValue);
- return null; //end of promise chain
- });
- }
- }
為了達到上述的需求,Promise 的相較於前面的 Monad,實作上複雜了點。在 flatMap 這個 method 裏,如果值還不存在的話,我們將 transform Function 先保留在pendingTransform 這個欄位,然後做一份居中的 chainPromise 先回傳。之後等值有了之後,complete() 這個 method 會被呼叫,這時才真的呼叫 transform Function,而它的結果再轉交回給 chainPromise。
雖然我做了簡單的解釋,不過中間的 chainPromise 繞來繞去的,不是很好懂,建議在腦中仔細 trace 一下整個流程。我們來看看利用 Promise 這個 Monad 重構後會是如何:
- class HttpClient {
- void getHtml(String url, Consumer
callback) { - //download html text... then invoke callback
- }
- //同樣功能的 method,但改成回傳 Promise 的版本
- Promise
getHtml(String url) { - Promise
promise = new Promise<>(null); - //callback 回傳的結果轉交給 promise.complete
- getHtml(url, html -> promise.complete(html));
- return promise;
- }
- }
- class FileUtil {
- void writeFile(String name,
- String data,
- Consumer
callback) { - //writing data to file... then invoke callback
- }
- Promise
writeFile(String name, String data) { - Promise
promise = new Promise<>(null); - //同上,用 method reference 更簡潔
- writeFile(name, data, promise::complete);
- return promise;
- }
- }
- class MailClient {
- void sendEmail(String email, Consumer
callback) { - //sending... then invoke callback
- }
- Promise
sendEmail(String email) { - Promise
promise = new Promise<>(null); - sendEmail(email, promise::complete);
- return promise;
- }
- }
當工具都 Promise 化之後,爬網頁的程式就可以直接串接每個回傳的 Monad,整個平坦化:
- void crawlPage(String url) {
- httpClient.getHtml(url)
- .flatMap(html -> fileUtil.writeFile("Page1.html", html))
- .flatMap(success -> mailClient.sendEmail("my@gmail.com"))
- .map(emailSent -> {
- System.out.println("result: " + emailSent);
- return null;
- });
- }
- class Promise
- {
- def value = null
- def pendingCallback = null
- Promise chainPromise = null
- Promise(v){value=v}
- Promise map(callback)
- {
- return flatMap({value -> new Promise(callback(value))})
- }
- /**
- * Register callback and return next Promise whose complete will be called after callback.
- * @param callback
- * @return
- */
- Promise flatMap(callback)
- {
- if (value != null)
- {
- return callback(value)
- }
- pendingCallback = callback
- Promise chainPromiseR = new Promise(null)
- this.chainPromise = chainPromiseR
- return chainPromiseR
- }
- public void complete(value)
- {
- Promise
- if(promiseR!=null)
- {
- promiseR.flatMap({nextValue ->
- chainPromise.complete(nextValue);
- return null; //end of promise chain
- })
- }
- }
- }
- class HttpClient {
- void getHtml(String url, callback) {
- //download html text... then invoke callback
- Thread.start {
- printf("Download html text... then invoke callback\n")
- sleep(2000)
- callback("html body")
- }
- }
- //同樣功能的 method,但改成回傳 Promise 的版本
- Promise getHtml(String url) {
- Promise promise = new Promise(null);
- //callback 回傳的結果轉交給 promise.complete
- getHtml(url, {html -> promise.complete(html)});
- return promise
- }
- }
- class FileUtil {
- void writeFile(String name,
- String data,
- callback) {
- Thread.start{
- printf("Write file=${name} with data=${data}...\n")
- sleep(1000)
- callback("write file done!")
- }
- }
- Promise writeFile(String name, String data) {
- Promise promise = new Promise(null);
- //同上,用 method reference 更簡潔
- writeFile(name, data, {result->promise.complete(result)});
- return promise;
- }
- }
- class MailClient {
- void sendEmail(String email, callback) {
- //sending... then invoke callback
- Thread.start{
- printf("Sending to ${email}... then invoke callback\n")
- sleep(1000)
- callback("Done!")
- }
- }
- Promise sendEmail(String email) {
- Promise promise = new Promise(null);
- sendEmail(email, {result->promise.complete(result)});
- return promise;
- }
- }
- httpClient = new HttpClient()
- fileUtil = new FileUtil()
- mailClient = new MailClient()
- void crawlPage(String url) {
- httpClient.getHtml(url) // Will return promise object
- .flatMap({html -> fileUtil.writeFile("Page1.html", html)})
- .flatMap({success -> mailClient.sendEmail("my@gmail.com")})
- .map({emailSent ->
- System.out.println("Result: " + emailSent);
- return null;
- });
- }
- crawlPage("www.google.com.tw")
Conclusion
Promise 這最後一個範例很複雜,但透過實際的實作,可以幫助各位更加了解 Monad 的能力。Promise 連 callback hell 都能征服了,我想幾乎沒有什麼結構難得倒 Monad 的。我希望各位讀完後,未來可以開始用 Monad 這個詞與其他開發者溝通,可以開始識別出某段程式套用了 Monad 來解決。最後更進一步,能夠自己用 Monad 解決問題。Monad 不是什麼很玄的東西,它只是: 將重複的運算結構隱藏,透過凸顯的型別來強化程式 。
本文的實作範例雖然可以動,但都未達上線使用的標準,只能拿來作教學用途,不要傻傻的直接 copy 來用。JDK8 裏已經有現成的 Optional, Stream, CompletableFuture 可用,不必再發明輪子。參考資料:Mario Fusco 著的 Monadic Java ,本文的所有概念都是從這學來的。
沒有留言:
張貼留言