Monad簡介 : 以Optional, Stream, CompletableFuture 為例
前言
在Java 1.8 版本出現了三個重要的API:Optional、Stream、CompletableFuture,解決了不斷出現的空值判斷、多重迴圈以及回呼地獄等問題,這些API背後實作了Monad設計模式,本文將透過簡單的實作,初步了解Monad設計模式的原理以及優缺點。
一、Optional
String getInsuranceName(Person person) {
//每一層都做空值檢查
if(person.getCar() != null) {
Car car = person.getCar();
if(car.getInsurance() != null) {
Insurance insurance = car.getInsurance();
if(insurance.getName() != null) {
return insurance.getName();
}
}
}
return "Unknown";
}
以上程式從Person類別中逐步操作,最後取出Insurance的屬性Name,為了避免 NullPointerException,每個取出的步驟都必須使用 if 判斷式,不僅容易遺漏,也會降低程式碼的可讀性,模糊了程式真正的意圖。
以下我們實作簡易的Optional類別來避免這種情況:
public class CustomOptional<T> {
static final CustomOptional<?> EMPTY = new CustomOptional<>(null);
private T value;
private CustomOptional(T value) {
this.value = value;
}
public static<T> CustomOptional<T> of(T value) {
return new CustomOptional<>(value);
}
//若為空值則回傳EMPTY
public<U> CustomOptional<U> flatMap(Function<? super T, CustomOptional<U>> f) {
return value == null? (CustomOptional<U>) EMPTY:f.apply(value);
}
//只是flapMap的組合
public<U> CustomOptional<U> map(Function<? super T, ? extends U> f) {
return flatMap(t -> of(f.apply(t)));
}
public T orElse(T other) {
return value == null? other: value;
}
並且改寫Person、Car類別,使getter方法可以回傳 CustomOptional 容器:
public class Person {
private CustomOptional<Car> car;
public CustomOptional<Car> getCar() {
return car;
}
// 其他程式碼
public class Car {
private CustomOptional<Insurance> insurance;
public CustomOptional<Insurance> getInsurance() {
return insurance;
}
// 其他程式碼
接著使用新的 CustomOptional 來改寫 getInsuranceName 方法:
String getInsuranceName(CustomOptional<Person> person) {
return person.flatMap(Person::getCar)
.flatMap(Car::getInsurance)
.map(Insurance::getName)
.orElse("Unknown");
}
改寫後的 getInsuranceName 方法將重複的空值檢查隱藏在 CustomOptional 中,允許開發者使用類似管線化的風格來撰寫程式。
CustomOptional 的重點在於 map 及 flatMap 兩個方法,透過組合這兩種方法,我們可以任意改變容器內的包裝的值,它們都接受 Function 類別的參數,分別回傳一般類別以及包裹於 CustomOptional 容器中的類別。
使用 CustomOptional 帶來以下優點:
1. 除去了重複的空值檢查,使程式的意圖凸顯出來。
2. 可以加上一些便利的方法,例如 orElse ,在容器內的值為 null 時提供預設值。
同時也有缺點,必須改寫原來的類別,使其支援 CustomOptional。
二、Stream
接著我們來試著處理重複的 for loop,假設我們想收集所有商品的贈品中,價值大於某個數字的列表:
List<String> getAllPresentsGreaterThan(List<Product> products, int price) {
List<String> presentsName = new ArrayList<>();
for(Product product: products) {
for(Product present: product.getPresents()) {
if(present.getPrice() > price) {
presentsName.add(present.getName());
}
}
}
return presentsName;
}
我們設計一個新的容器 Stream 來處理這些重複的 for loop 結構:
public class CustomStream<T> {
//內部用List來儲存資料
private final List<T> values;
//用空陣列代表沒有資料
static final CustomStream<?> EMPTY = new CustomStream<>(new ArrayList<>());
public CustomStream(List<T> values) {
this.values = values;
}
//將迴圈結構隱藏在flapMap方法中
public <R> CustomStream<R> flatMap(Function<T, CustomStream<R>> transform) {
ArrayList<R> results = new ArrayList<>();
for(T value: values) {
CustomStream<R> transformed = transform.apply(value);
for(R result: transformed.values) {
results.add(result);
}
}
return new CustomStream<>(results);
}
//將函數轉換的結果用CustomStream包裝,並將整個函數當成參數傳入flapMap
public <R> CustomStream<R> map(Function<T, R> transform) {
return flatMap(value -> new CustomStream<>(asList(transform.apply(value)))
);
}
//增加過濾用的方法,符合條件就回傳原值
public CustomStream<T> filter(Predicate<T> predicator) {
return flatMap(value -> {
if(predicator.test(value)) {
return new CustomStream<T>(asList(value));
}
return (CustomStream<T>) EMPTY;
});
}
public List<T> getValues() {
return values;
}
}
接著改寫 getAllPresentsGreaterThan 方法:
List<String> getAllPresentsGreaterThanAfter(List<Product> products, int price) {
return new CustomStream<Product>(products)
.flatMap(Product::getPresents)
.filter(product -> product.getPrice() > price)
.map(Product::getName)
.getValues();
}
同樣有以下優點:
1. 用 flapMap 及 map 將迴圈結構隱藏起來,凸顯程式的意圖。
2. 加上一些便利的方法,在此例中為 filter 。
缺點也一樣,必須改寫原來的 Product 類別,使其支援 CustomStream。
三、CompletableFuture
最後來處理回呼地獄,也就是 Callback Hell。主要方法 crawlPage 由 getHtml、writeFile、sendEmail 三個方法組成:
//抓取網頁、存檔、寄信
crawlPageOld(String url, String fileName, String email) {
getHtml(url, html -> {
writeFile(fileName, html, success -> {
sendEmail(email, emailSent -> {
System.out.println("result: " + emailSent);
});
});
});
}
void getHtml(String url, Consumer<String> callback) {
//下載 html,並呼叫 callback
callback.accept("This is a html.");
}
void writeFile(String fileName, String data, Consumer<Boolean> callback) {
//寫入檔案,並呼叫 callback
callback.accept(true);
}
void sendEmail(String email, Consumer<Boolean> callback) {
//寄信,並呼叫 callback
callback.accept(true);
}
設計一個新容器 Promise 來解決這個問題:
public class Promise<T> {
//儲存將來運算結束後會得到的值,目前可能還不存在(null)
private T value;
//取得值後,執行這個運算
private Function pendingTransform;
//代表下一個運算的 Promise
private Promise chainPromise;
Promise(T value) {
this.value = value;
}
public <R> Promise<R> flatMap(Function<T, Promise<R>> transform) {
//如果已經取得結果,就直接執行函數
if(value != null) {
return transform.apply(value);
}
//否則,先將函數儲存起來,並回傳一個空的 Promise,代表接下來的運算結果
pendingTransform = transform;
Promise <R> chainPromiseR = new Promise<>(null);
this.chainPromise = chainPromiseR;
return chainPromiseR;
}
//如果已經取得值,呼叫 pendingTransform,並遞迴呼叫 chainPromise 的 complete 方法
public void complete(T value) {
if(pendingTransform == null) {
this.value = value;
return;
}
Promise<Object> promiseR =
(Promise<Object>) pendingTransform.apply(value);
//傳入的函數回傳值不重要,重要的是執行了 complete 方法取得值
promiseR.flatMap(nextValue -> {
chainPromise.complete(nextValue);
return null;
});
}
public <R> Promise<R> map(Function<T,R> transform) {
return flatMap(value -> new Promise<>(transform.apply(value)));
}
public T get() {
return this.value;
}
}
接著使用新容器來改寫原本的方法:
Promise<String> getHtml(String url) {
Promise<String> promise = new Promise<>(null);
getHtml(url, promise::complete);
return promise;
}
Promise<Boolean> writeFile(String fileName, String data) {
Promise<Boolean> promise = new Promise<>(null);
writeFile(fileName, data, promise::complete);
return promise;
}
Promise<Boolean> sendEmail(String email) {
Promise<Boolean> promise = new Promise<>(null);
sendEmail(email, promise::complete);
return promise;
}
void crawlPage(String url, String fileName, String email) {
//為了模擬還未取得值的情境,所以先傳入 null
Promise<String> initPromise = getHtml(null);
initPromise
.flatMap(html -> writeFile(fileName, html))
.flatMap(success -> sendEmail(email))
.map(emailSent -> {
System.out.println("result: " + emailSent);
return null;
});
//最後將值傳入,遞迴執行所有 Promise 的 complete 方法
initPromise.complete(url);
}
同樣的,改寫後的 crawlPage 方法已經看不到多層的 callback,程式的意圖很明顯,同時也可自行加入一些常用的方法,缺點也很明顯,必須改寫原本的類別及方法,使其支援新容器。
四、結論
先來說說 Monad 的優點:
1. 將重複的運算結構隱藏起來,凸顯程式的意圖。
2. 重複的運算集中在 Monad 容器中,容易維護。
3. 可使用 flapMap 以及 map 方法任意組合函數,增加可讀性。
4. 可以自行加上一些常用的高階方法。
而 Monad 最大的缺點,在於必須改變既有的類別,才能享受到它所帶來的優點。
以上範例的自訂 Monad 容器,在 Java 8 中都有對應的類別: Optional、Stream、CompetableFuture,不需自行實作。
五、參考資料與延伸閱讀
https://ingramchen.io/blog/2014/11/monad-design-pattern-in-java.html
https://www.slideshare.net/mariofusco/monadic-java
https://www.ibm.com/developerworks/cn/java/j-understanding-functional-programming-5/index.html
https://www.itread01.com/articles/1476416712.html
https://medium.com/swlh/write-a-monad-in-java-seriously-50a9047c9839