函數式風格錯誤處理


iThome 網站首載:函數式風格錯誤處理

程式人時常要與錯誤搏鬥,然而介紹或探討錯誤處理的文件或書籍,相對來說還是少了許多。由於許多語言都內建了例外(Exception)處理機制,目前大家對以例外機制處理錯誤較為熟悉,然而觀察其它語言典範,從中觀察一些錯誤處理的方法,以不同的習慣來思索,在錯誤處理上可獲得不少的啟發。舉例來說,例外處理多半是命令式風格,想想看函數式的世界中會是怎麼處理例外呢?

解決null問題的Option

Scala中有個Option,guava-libraries中有個Optional,JDK8中也將有個Optional類別,它們的存在目的,基本上是為了避免使用null,以免遭受因為使用了null,而造成的各種問題。我曾經在專欄〈補救null的策略〉中談過,null的問題在於語義含糊不清,可以實現速錯(Fail fast)概念,或者是建立有明確語義的型態來取代null

實際上Option的概念來自於函數式。以Haskell為例,它沒有null的概念,最類似的值是Nothing,它是Maybe型態,Maybe的其它值要由Just構造。在Haskell中如果函式執行後也許會產生某型態結果,或者是沒有結果,那就會傳回Maybe型態的值,呼叫函式的客戶端可使用模式比對(Pattern match)在傳回Just時取得其中的值,在Nothing時作沒有結果時的處理。Scala中的Option有兩個子類別SomeNone,對照來說,Option相當於Haskell的MaybeSome對應於JustNone對應於Nothing,Scala支援函數式設計,SomeNone定義為案例類別(Case class),以支援模式比對。

觀察Haskell的Maybe或是Scala的Option的使用時機,會發現它們多半用在計算可能失敗而沒有結果的場合,也就是說,MaybeOption常是函數式世界中處理計算失敗或錯誤的方式。現在不少命令式語言都逐漸納入函數式風格,除了一級函式是最顯而易見的函數式特性之外,實際上guava-libraies中納入Optional的概念也是,只不過在不支援模式比對的Java語言中,會使用OptionalisPresent方法來確認是否有包裹值,也就說是,Optional除了避免null相關問題外,實際上也給了我們一些以函數式風格處理錯誤的機會。

分別表現對錯資訊的Either

在命令式語言逐步結合函數式概念的過程中,常會出現一些讓熟悉命令式風格的程式人困惑之元素。以嘗試融合物件導向及函數式兩種典範的Scala為例,除了Option之外,還提供了Either型態,表面上看來,在函式可能產生兩種型態之一計算結果時,可以令其傳回Either,實際上Either多半運用於處理錯誤,它有RightLeft兩個子類別,前者是計算成功時用來包裹正確結果(Right的英文暗示著正確),後者是在計算失敗時用來包括為何計算錯誤的有效訊息。Haskell中也有個Either型態,分別可使用RightLeft兩個值建構式(Value constructor)。同樣地,函數式的Haskell或Scala中,Either的值都可以進行模式匹配。

從Java這類命令式語言中看Either,容易有的疑問是「為什麼不直接拋出例外」?拋出例外意謂著客戶端若要處理例外,必須使用try-catch語法,try-catch語法容易寫出更動變數值的程式流程,而拋出例外本身就代表著會中斷程式流程,也就是函數有可能不會有傳回值,這不符合函數式風格。Option的存在解決了部份問題,然而Option在計算失敗時只傳回None,如此一來只會知道計算失敗,但不會知道失敗的原因。

可以使用Either來解決這類問題。以Scala為例,在結合例外型態下,假設計算成功會傳回Int,計算失敗會以Exception包裏錯誤訊息,那麼函式傳回值可以是Either[Exception, Int],假設現在計算成功得到93,則傳回Right(93),否則建立例外物件e並傳回Left(e),如此一來,呼叫函式的客戶端可以模式匹配作相對應的處理,即使客戶端不打算處理例外,也不用撰寫try-catch,程式依然可以進行,也就是可以延遲例外的處理。有了Either這類型態,錯誤處理的方式就可以有函數式的可能性,也因此支援Java進行函數式風格撰寫的Functional Java程式庫,亦定義了Either這個型態。

看看Scala 2.10新增的Try

在Scala中有趣的一點是,try-catch語法是運算式(Expression),也就是try-catch會有傳回值,這使得在運用try-catch語法的場合中,依舊可符合函數式風格,因此若察看Either的API文件時,可以看到以下的範例程式碼:
val result: Either[String,Int] =
    try { Right(in.toInt) }
    catch { case e: Exception => Left(in) }

實際上這是個很常見的模式,因此在Scala 2.10中增加了Try類別,可以直接將上例撰寫為val result: Try[Int] = Try(in.toInt),如果in.toInt執行成功,會傳回Success實例包裏結果,若失敗則以Failure實例包裏例外物件。呼叫函式的客戶端,同樣可進一步地進行模式匹配。

Try型態的主要作用之一,在於提供比Either更明確的語意,Try的兩個子類因而明確地命名為SuccessFailureTry的另一作用是封裝共用的try-catch處理流程。在Java領域談到封裝try-catch共用處理流程的實際例子,是Spring的JdbcTemplate,它封裝了使用JDBC API時繁瑣的try-catch處理流程,這不單是為了重用流程樣版,也是為了可讀性,在《Clean Code》書中有個小節談到了〈提取Try/Catch區塊〉,也是類似的道理。

既然知道了Try的作用在提供更明確語意與重用try-catch流程,要在Java中建立類似的型態基本上就不是問題,雖然try-catch在Java中不是運算式,但可以封裝在函式中,並以return來作到類似功能。現階段的Java版本不支援Lambda語法,寫來會比較囉嗦,如果在JDK8中,則可以較簡潔地寫為tryIt(() -> Integer.parseInt(input)),並令tryIt傳回Try<Integer>實例來達到類似功能。

從多種典範中學習並思考

在過去,程式人習慣使用null,後續因為層出不窮的錯誤,以及《Null References: The Billion Dollar Mistake》之類的呼籲,有人開始思考如何不使用null,因而引入了函數式領域的概念,使用了Option來讓語意明確,現在不少程式人都知道Option的概念與使用的時機。

隨著命令式語言漸次地引入函數式的元素,程式人也得瞭解更多的函數式概念,方能瞭解這些元素實際的使用時機,就像Either,它不僅僅是用來包裹可能的兩種型態傳回值,否則的話基本上Map或其它自訂型態也作得到這點。Either讓編譯器有機會進行型態檢查,更重要的是它常應用在錯誤處理的場合。程式語言融合多種典範,代表著程式人需要更多的思考方向,就如OptionEitherTry等元素若進入到命令式語言之中,程式人在必要的時候就得改變一下處理錯誤的習慣,嘗試從另一角度來解決問題。