Replace Exception with Functional Monad

Z-xuan Hong
22 min readFeb 21, 2021

--

參考書籍

程式中的基本元件有:迴圈、條件判斷、函數、錯誤處理等等,對於大部分的元件我們都能夠快速上手,但遇到錯誤處理時,時常不知道如何有效處理,今天想要跟大家分享幾個錯誤處理的實務作法,並結合Functional Programming Monad的概念,讓大家達到Clean Error Handling的目標。

此篇文章將會分成以下四個部分:

  • 錯誤處理基本知識
  • 控制流程
  • 函數式導向錯誤處理
  • 實際範例

錯誤處理基本知識

想要掌握錯誤處理的技巧,首先我們要了解以下幾個名詞,分別是:

  • Fault:代表程式中的Bug、硬體上的缺陷,程式中的Bug通常稱之為Design Fault、硬體上的缺陷稱之為Component Fault。
  • Error:代表程式中的狀態已經出現錯誤,通常由Fault引起。
  • Failure:代表元件的外在行為 (Client Observable Behavior)不正確。

以下範例來解釋三者關係

public static class FaultErrorFailure {
private List<String> elements = new ArrayList<>();

public void replace(int index, String element) {
elements.set(index, element); // bug
}

public static void main(String[] args) {
FaultErrorFailure faultErrorFailure = new FaultErrorFailure();
faultErrorFailure.replace(0, "123"); // failure
// 以下程式碼省略
}
}

執行上面程式碼時,系統會丟出java.lang.IndexOutOfBoundsException,從中我們可以得知幾件事:

  • 針對List<String>的邊界沒有妥善處理,因而產生Design Fault (Bug)
  • Fault導致replace函數丟出IndexOutOfBoundsException例外,此時replace函數處於錯誤狀態,稱之為Error
  • 由於IndexOutOfBoundsException例外被丟出,導致replace函數的行為不正確 (replace函數要能替換特定索引的元素),稱之為Failure

如何處理?

上述介紹了Fault、Error、Failure彼此之間的關係,為了解決根本問題 ,我們需要從Fault下手。

觀察以下程式碼幾秒鐘,你覺得這樣處理可以嗎?

public static class FaultErrorFailure {
private List<String> elements = new ArrayList<>();

public void replace(int index, String element) {
try {
elements.set(index, element);
} catch (IndexOutOfBoundsException e) {
e.printStackTrace();
}
}

public static void main(String[] args) {
FaultErrorFailure faultErrorFailure = new FaultErrorFailure();
faultErrorFailure.replace(0, "123");
// 以下程式碼省略
}
}

如果你的回答是Yes,那麼你所開發的系統應該有無數潛在Bug,讓你加班加不完。

原因:

  • 單純把例外蓋掉,根本問題 (Design Fault)沒有被解決
  • IndexOutOfBoundsException例外導致函數狀態錯誤 (Error),但客戶端卻不知道函數已經失效 (Failure)
  • 當程式繼續執行,下面的元件都認為元素已經被替換成功 (實際上沒有),導致問題繼續往外擴張。
  • 最終顯示錯誤的地方 (執行了好幾行之後)實際上發生錯誤的地方 (replace函數)相差太遠,導致難以除錯。

修改後程式碼:

public static class FaultErrorFailure {
private List<String> elements = new ArrayList<>();

public void replace(int index, String element) {
if (!canReplace(index))
throw new RuntimeException("Please check with canReplace before invoke replace");
elements.set(index, element);
}

private boolean canReplace(int index) {
return index < this.elements.size();
}

public static void main(String[] args) {
FaultErrorFailure faultErrorFailure = new FaultErrorFailure();
if (!faultErrorFailure.canReplace(0))
return;

faultErrorFailure.replace(0, "123");
}
}

思考過程如下:Design Fault的發生,有可能因為幾種原因導致:1. 元件內部實作錯誤產生Bug、2. 元件沒有針對輸入資料進行驗證、3. 客戶端使用方式不正確。

  • 元件內部實作錯誤產生Bugreplace函數的實作沒有邏輯上的錯誤。
  • 元件沒有針對輸入資料進行驗證replace函數確實沒有針對輸入資料進行驗證,因此我們加入了canReplace函數來驗證輸入資料
  • 客戶端使用方式不正確先前客戶端在呼叫replace函數時,沒有確認replace的元素是否存在,因此客戶端需要呼叫canReplace函數來確認替換元素有無存在後,才可以進行替換。

上述的作法:

  • 幫我們解決發生Fault的根本問題
  • 把錯誤處理與正常流程切開,讓程式碼表達完整的意圖、降低維護的成本。
  • 當客戶端沒有適當的使用API時,canReplace函數會立刻拋出例外,提醒客戶端修正API的使用方式,達到Fail Fast Principle (遇到錯誤馬上顯示,程式不會繼續執行)。

控制流程

常見的控制流程有以下幾種:

  • Normal Control Flow:程式中一行一行指令被依序執行,途中也會根據條件判斷執行不同的區塊。
  • Exception Control Flow:跟Normal Control Flow相似,只是Normal Control Flow通常處理Happy Path,而Exception Control Flow處理的是Exception發生時的情況。
  • Mixed Control Flow (Normal & Exception):上述兩者混雜在一起。

想要寫出可維護的程式,需要把Normal Control Flow與Exception Control Flow分開,反之,將會導致程式碼難以維護。

以下為Mixed Control Flow的範例:

public boolean replace(int index, String element) {
try {
elements.set(index, element);
return true;
} catch (Exception e) {
return false;
}
}

上述程式碼缺點有:

  • 忽略Design Fault,直接catch IndexOutOfBoundsException,沒有解決根本問題。
  • 將Normal Control Flow與Exception Control Flow混合在一起,可讀性不佳,沒辦法知道程式碼處理什麼問題。
  • 直接catch Exception,當其他例外發生時,也會被當成正常流程,導致遇到其他問題時,沒辦法快速找出錯誤並且終止,違反Fail Fast Principle。

應該要使用上述介紹過的處理方式,將例外處理與控制流程拆開,接著在元件驗證資料,提供客戶端驗證資料的API

函數式導向錯誤處理

Functional Programming當中,Monad為用來簡化錯誤處理邏輯的Design Pattern。

什麼是Monad:Monad的概念很簡單,只需要滿足以下條件,就稱之為Monad

  • lift function: T -> M<T>
  • bind function: M<A> -> (A -> M<B>) -> M<B>
  • monad Type: M<T>

相信看完上面的定義你還是不了解Monad,我們舉Java8中Optional,各位應該會比較熟悉:

public static class OptionalApp {
public static void main(String[] args) {
Optional<Integer> optionalOne = Optional.of(1);
// Optional.of is a lift function: given input T, create output Optional<T>
assertEquals(Optional.of(1), optionalOne);
Function<Integer, Optional<Integer>> addOne = i -> Optional.of(i + 1);
// function: given input A, create output Optional<B>
Optional<Integer> optionalTwo = optionalOne.flatMap(addOne);
// flatMap: given function (A -> Optional<B>) with Optional<A>, create output Optional<B>
assertEquals(Optional.of(2), optionalTwo);
}
}

Monad的好處:

  • 將錯誤處理抽象化並封裝。
  • 透過bind function,能把多個錯誤處理函數組合在一起,達到Fail Fast的效果 (下面範例章節中說明)。
  • 增加可讀性,強調Domain Logic而不是錯誤處理的boilerplate code。

實際範例

我們舉出兩個範例,分別為:

  • 如何有效處理查詢不到資料的情況
  • 如何有效驗證輸入資料

範例1:使用者呼叫Repository查詢資料,但資料不存在 (以下不探討實作,只探討API設計)。

回傳null:

private static void main(String[] args) {
StudentRepository studentRepository = createStudentRepository();
OrderRepository orderRepository = createOrderRepository();
Student student = studentRepository.findBy(0L);
if (student != null) {
List<Order> orders = orderRepository.findOrdersBy(student.getId());
orders.forEach(order -> order.completeOrder());
}
}

public class StudentRepository {
Student findBy(Long id) {
Student student = db.findStudent(id);
if (student != null)
return student;
return null;
}
}

public class OrderRepository {
List<Order> findOrdersBy(Long studentId) {
return db.findOrdersBy(studentId);
}
}

上述程式碼有以下缺點:

  • 回傳null導致客戶端需要null checking,而null checking 也會重複散落在多個客戶端,降低維護性。
  • 回傳null沒有compile time checking,很容易因為沒有判端而造成NullPointerException
  • Function signature dishonest:從客戶端角度來看,輸入任何id都會成功回傳Student物件,但實際上並不是,開發者需要了解函數的實作細節才知道如何處理例外情況。

Replace return null with Monad:

private static void main(String[] args) {
StudentRepository studentRepository = createStudentRepository();
OrderRepository orderRepository = createOrderRepository();

studentRepository.findBy(0L)
.flatMap(student -> orderRepository.findOrdersBy(student.getId()))
.ifPresent(
orders -> orders.forEach(order -> order.completeOrder())
);
}

public class StudentRepository {
Optional<Student> findBy(Long id) {
Student student = db.findStudent(id);
if (student != null)
return Optional.of(student);
return Optional.empty();
}
}

public class OrderRepository {
List<Order> findOrdersBy(Long studentId) {
return db.findOrdersBy(studentId);
}
}

上述程式碼有以下優點:

  • 回傳Optional,客戶端能夠使用bind function來組合其他函數,範例中使用回傳Optional<Student>的flatMap來組合orderRepository的findOrdersBy函數。
  • 回傳Optional擁有compile time checking,完全移除NullPointerException。
  • Function signature dishonest:從客戶端角度來看,輸入id會回傳Optional<Student>,代表Student有可能查詢不到,讓使用者不需看實作細節,便能理解函數提供的功能。
  • 程式碼可讀性高,沒有null checking,能快速理解程式碼要解決的問題。

範例2:針對輸入資料進行驗證,如果輸入資料有誤,應馬上終止,反之使用驗證後的資料產生特定物件。

class CustomerService {
public Customer createCustomer(CustomerDto customerDto) {
if (!validateAge(customerDto))
throw new IllegalArgumentException("Age is invalid, the range of age should between 20~100");
if (!validateFirstName(customerDto))
throw new IllegalArgumentException("First name is invalid, the first name can't not be null or empty");
if (!validateSecondName(customerDto))
throw new IllegalArgumentException("Second name is invalid, the second name can't not be null or empty");

return new Customer(
customerDto.age,
customerDto.firstName,
customerDto.secondName
);
}

private boolean validateAge(CustomerDto customerDto) {
return customerDto.age >= 20 && customerDto.age <= 100;
}

private boolean validateFirstName(CustomerDto customerDto) {
return customerDto.firstName != null && !customerDto.firstName.isEmpty();
}

private boolean validateSecondName(CustomerDto customerDto) {
return customerDto.secondName != null && !customerDto.secondName.isEmpty();
}
}

class CustomerDto {
final int age;
final String firstName;
final String secondName;

CustomerDto(int age, String firstName, String secondName) {
this.age = age;
this.firstName = firstName;
this.secondName = secondName;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
CustomerDto that = (CustomerDto) o;
return age == that.age &&
Objects.equals(firstName, that.firstName) &&
Objects.equals(secondName, that.secondName);
}

@Override
public int hashCode() {
return Objects.hash(age, firstName, secondName);
}
}

上述程式碼有以下缺點:

  • 在非例外的情況使用例外處理 (使用者輸入錯誤資料很常見,不是例外情況)
  • Function signature dishonest:客戶端沒辦法一眼看出createCustomer 可能會失敗,API跟使用者說customer物件一定能創建成功,但實際上不是。
  • 大量的exception handling,導致程式碼可讀性降低,沒辦法表明真正意圖。

Replace Validate Logic with Result Monad

class CustomerService {
public Result<Customer> createCustomer(CustomerDto customerDto) {
return validateAge(customerDto)
.flatMap(age -> validateFirstName(customerDto)
.flatMap(firstName -> validateSecondName(customerDto)
.flatMap(secondName -> new Customer(age, firstName, secondName))));
}

private Result<Integer> validateAge(CustomerDto customerDto) {
if (customerDto.age >= 20 && customerDto.age <= 100)
return Result.success(customerDto.age);
return Result.failure("Age is invalid, the range of age should between 20~100");
}

private Result<String> validateFirstName(CustomerDto customerDto) {
if (customerDto.firstName != null && !customerDto.firstName.isEmpty())
return Result.success(customerDto.firstName);
return Result.failure("First name is invalid, the first name can't not be null or empty");
}

private Result<String> validateSecondName(CustomerDto customerDto) {
if (customerDto.secondName != null && !customerDto.secondName.isEmpty())
return Result.success(customerDto.secondName);
return Result.failure("Second name is invalid, the second name can't not be null or empty");
}
}

class CustomerDto {
final int age;
final String firstName;
final String secondName;

CustomerDto(int age, String firstName, String secondName) {
this.age = age;
this.firstName = firstName;
this.secondName = secondName;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
CustomerDto that = (CustomerDto) o;
return age == that.age &&
Objects.equals(firstName, that.firstName) &&
Objects.equals(secondName, that.secondName);
}

@Override
public int hashCode() {
return Objects.hash(age, firstName, secondName);
}
}

Result Monad結構如下:

  • Success代表函數處理成功。
  • Failure代表函數處理失敗。
  • 透過bind function將驗證函數組合在一起 (validateAge、validateFirstName、validateSecondName),能夠簡化程式碼。
  • 如果驗證全部成功會創建出customer物件,反之,只要其中一個驗證函數失敗,則會回傳Failure並且包含錯誤訊息,能夠defer錯誤處理的邏輯到最後一刻,達到defer decision的效果。

上述程式碼有以下優點:

  • 程式只有單純的控制流程,沒有exception handling code。
  • Function signature dishonest:客戶端一眼就能看出createCustomer 可能會失敗,並且有compile time checking強制客戶端做出處理。
  • 程式碼可讀性提高,能夠表明真正意圖 (驗證資料、創建customer物件)。

以下為客戶端使用API的情況:

static class Client {
public static void main(String[] args) {
CustomerService customerService = new CustomerService();
// valid customer input
CustomerDto customerDto = new CustomerDto(25, "Z-Xuan", "Hong");
Result<Customer> customerResult = customerService.createCustomer(customerDto);
// invalid customer input
CustomerDto customerDto = new CustomerDto(-1, "Z-Xuan", "Hong");
Result<Customer> customerResult = customerService.createCustomer(customerDto);
// error message: Age is invalid, the range of age should between 20~100
}
}

Result Monad與Optional Monad非常相似,差別在於:

  • Optional Monad用來Model absence effect。
  • Result Monad用來Model exceptional effect。

結論:

  • 遇到錯誤處理時先思考Fault, Error, Failure
  • Exception只能在Exception的情況使用
  • 當Exception被使用在Non Exception的情況時,使用Replace Exception with Functional Monad
  • Optional Monad用來Model absence effect
  • Result Monad用來Model exception effect

參考:

https://fsharpforfunandprofit.com/posts/elevated-world-3/#validation

--

--

Z-xuan Hong
Z-xuan Hong

Written by Z-xuan Hong

熱愛軟體開發、物件導向、自動化測試、框架設計,擅長透過軟工幫助企業達到成本最小化、價值最大化。單元測試企業內訓、導入請私訊信箱:t107598042@ntut.org.tw。

No responses yet