Functional Programming中處理Exception的方法

前言

以前剛從Java轉換到其他語言時有個困擾,就是Checked Exception(受檢例外)不見了。這會導致呼叫function的時候,難以判斷程式到底會不會丟出Exception。

其實關於Checked Exception的討論在網路上已經很多了,我google了兩篇覺得寫得還不錯的,覺得應該存在的正方[1],覺得不應該存在的反方[2],講起來其實都有些道理。

我自己比較偏向Checked Exception必須存在,但更準確來說是「我自己的checked exception應該要存在」。在設計自己的function時,若出現需要raise exception的情況,必定是我覺得這個東西要知會上層中斷流程,並做出一定的處理。而這應該存在於function的interface當中,不該讓同事看了文件或看了原始碼才發現要處理例外。

至於其他library的程式碼,我是覺得隨意。一方面大多數library並不會隨便丟出錯誤。另一方面就算他丟出錯誤,很大部分也是無法處理的錯誤,就讓他直接丟到頂端被top level的try catch給接住也無妨。

嘗試: 回歸錯誤碼檢查

既然想讓exception在function的介面上展現,最簡單的一個方法就是用class給包裝起來。(下面用dart程式碼呈現)

class Result{
  Result(this.result);
  Result.error(this.error);

  int result;
  Exception error;
}

先宣告一個class,裡面有正常的回傳值,也有異常的回傳值。
若程式正常執行則使用預設的constructor,出現錯誤則用error的constructor。如下

Result func(){
  return Result(0);
  // return Result(Exception("Error!!!"));
}

void main(){
  Result v = func();
  if(v.error != null){
    // 處理錯誤
    return;
  }
}

在程式的執行流程中,如果出現error != null的狀況,就進行處理。若有需要也可以再將exception向上傳遞。

這邊為了易懂省掉了generic type的設定,實務上為了更一般化一定要用上generic type來包裝。

缺點

缺點也是顯而易見,exception受歡迎的原因就是因為它可以中斷程式流程,並把錯誤集中處理。若按照這種寫法,很快的程式中就會開始充斥著類似下方的程式碼。

void main(){
  Result a = func1();
  if (a.error != null){
    // 處理
  }
  Result b = func2();
  if (b.error != null){
    // 處理
  }
  Result c = func3();
  if(c.error != null){
    // 處理
  }
}

所有錯誤處理分散在function的各個地方,程式流程也變得非常不線性,看一段錯誤處理又要看一段正常流程,相信許多人會因此感到痛苦。

Functional Programming下的處理方法

型態修改

在正式介紹FP的處理法之前,先把上面的型態宣告做點小修改。

class Result{}

class ResultFailed extends Result {
  ResultFailed(this.e);
  Exception e;
}

class ResultSuccess extends Result { 
  ResultSuccess(this.value);
  int value; 
}

可以想見,一個Exception跟一個正常值,根本不會是同一種type,所以用一個Result來裝兩者,在語意上其實是有點問題的。
此處先將Result作為Base Class,並另外分出兩種class來記錄正確和錯誤。

串接寫法

剛剛程式中最大的問題出在無法將錯誤集中處理,現在試著幫Result加上一個function來解決這個問題。

// Transform是一個parameter為int,return type為int的function。
typedef Transform = int Function(int value);
class Result{
  Result map(Transform fn){
    if (this is ResultException){
      return this;
    }else if (this is ResultSuccess){
      return ResultSuccess(fn(a));
    }
  }
}

Result func(){
  return ResultSuccess(0);
  // return ResultFailed(Exception("NOOO"));
}
void main(){
  Result value = func()
    .map((int v) => v+10)
    .map((int v) => v+10)
    .map((int v) => v+10);

  if (value is ResultFailed){
    // 處理錯誤
  }
}

先集中精力看main function的部分,分析一下map造成了什麼效果?

在執行func獲得一個Result之後,會想拿這個Result去做去執行其他運算,例如說+10。但是在此處我們把「從Result class中提取int值」的動作交給了map,map再去呼叫使用者所傳入的function。

這有什麼好處?

回頭看看map的實作,會發現map除了在程式正確執行的時候,幫助unwrap int值,也能在程式錯誤的時候,選擇性的不執行傳進來的function。因此可以百分之百肯定,當執行function時,參數v鐵定是有值,且沒有exception存在。

要再functional一點的話,應該在Result上再外加一個handleError的function。

因此我們可以這樣描述main的執行

  1. 如果func()執行成功,沒有丟出exception,那麼每一個map都會被執行,最終會得到一個ResultSuccess(30)的結果。
  2. 如果func()執行失敗,丟出了Exception(“NOOO”),那麼接下來的每一步運算都不會被執行。最後的結果還是Exception(“NOOO”)。

而在這樣的寫法中,正確的計算步驟被層層串接,錯誤的處理則被推遲到最後才執行。就跟try catch想達到的效果是一樣的。

另外一方面,如果不想在當前的function處理錯誤,就可以將function的return type換成Result,這樣所有使用此function的人,都會強制注意到此function是有可能回傳Exception的,進而選擇執行錯誤處理,或是將Exception再遞交給更上一層。這跟Java中Checked Exception想達成的效果也是一樣的。

已經存在的輪子

作為一個簡化的例子,這個Result class其實有很多缺陷。不過幸好在FP的世界裡這樣的設計已經有自己的名稱,叫做Either,且很多程式語言都已經有實作,所以實務上並不需要自己重刻一次這樣的工具。

這裡貼幾個Either的實作

Either的繼承樹長得像這樣。

class Either<L,R> {}
class Left<L,R> extends Either<L,R>{}
class Right<L,R> extends Either<L,R>{}

Either的設計其實更為一般化,並沒有規定Left和Right必須要是一個Exception和一個正確值。但在使用慣例上,Left是作為Exception存在,Right作為正確值存在。最好是跟隨這個慣例,並避免將Either用在非Exception處理的地方。

Reference

  1. 王垠: Kotlin 和 Checked Exception
  2. 浅谈Kotlin的Checked Exception机制

留言

這個網誌中的熱門文章

LiteDb簡單key-value pair存法

解決WSL在Windows檔案系統下速度緩慢的問題