Monad Design Pattern

Monad簡介 : 以Optional, Stream, CompletableFuture 為例

蔡國鍏 Wayne Tsai 2019/10/29 12:35:42
1668

前言

    在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

蔡國鍏 Wayne Tsai