如何不拋出例外的處理錯誤 (1)

我們在 什麼是 Funcational Programming? 有提到拋出 exception 是某種 side effect ,倘若 exception 不能用,那我們該拿什麼來替代?我們該怎麼用 functionally 的方式處理它們呢?

方法就是在 function 的回傳中,把 錯誤正常 一起表示,然後也是用 pattern match 和 high-order function 來應對錯誤情況下的操作,也就是說,錯誤 不是例外,而是回傳值之一,有點像 結構化程式語言 回傳錯誤碼那樣,但多了更多東西,讓我們開始吧。

Exception 的好與壞

首先我們來看一段程式,

def failingFn(i: Int): Int =
  val y: Int = throw new Exception("fail")
  try {
    val x = 42 + 5
    x + y
  }
  catch {
    case e: Exception => 43
  }

若我們在 Scala REPL 中呼叫 failingFn function,會得到下面結果,

scala> failingFn(5)
java.lang.Exception: fail
  at rs$line$1$.failingFn(rs$line$1:2)
  ... 35 elided

如同在 什麼是 Funcational Programming? 提到的,要驗證 y 是否符合 RT 的方法就是使用 Substitution Model 把用到它的地方替換掉,

def failingFn2(i: Int): Int =
  try {
    val x = 42 + 5
    x + ((throw new Exception("fail")): Int)
  }
  catch {
    case e: Exception => 43
  }

相同的調用但卻得到了不同的結果;

scala> failingFn2(5)
val res1: Int = 43

這裡我們可以觀察到 2 個主要問題:

  1. exception 破壞了 RT 且在某些情況下回傳的值會依情境不同而有所差異,當 catch 區塊處理多種 exception 時,可能會得到不同結果,我們不應該使用 try-catch 來控制流程。
  2. exception 不是型別安全 (type-safe) 的,failingFn, Int => Int 是給它 Int 然後回傳 Int,我們並不知道要處理 exception。

若你熟悉 Java,你或許會想 Java 不是有提供一個功能叫 Checked Exception 嗎?這個不是可以解決上面的問題 2 了,這沒錯,但這個不作用在 high-order function 上,

例如 List 的 萬用 map function,我們不可能在這裡檢查所有在 f 中可能會拋出的所有 exception,所以即使在 Java 也是用 RuntimeException 來表達這種錯誤。

def map[A, B](l: List[A], f: A => B): List[B]

難道就沒有一種替代方案能避免上述問題,卻同時不失去 Exception 帶給我們有關整合和中心化錯誤邏輯處理的好處嗎?

一些可能的做法

先來看另一個例子,當輸入是空陣列時拋出 exception,

def mean(xs: Seq[Double]): Double =
  if xs.isEmpty then
    throw new ArithmeticException("mean of empty list")
  else
    xs.sum / xs.length

Seq 是 Scala 中所有有連續性的集合資料結構的抽象類別,包含有索引的 Array 或者線性的 Linked List 的父類別都是 Seq,一些共用的好用工具 function 在 Seq 中都有,所以通常都用 Seq 來當做參數型態,Scala 中相關資料結構可參考此 文件

這個 function 一般被稱做 partial function,因為它對輸入做出了輸入類型未暗示的假設,此處的假設是 Seq 非空,

如果不想無腦拋出 exception,或許我們就直接計算!?

def mean1(xs: Seq[Double]): Double =
  xs.sum / xs.length

因為型態是 Double,當除數為 0.0 時,其回傳值會是 Double.NaN,但這也會有其他的問題,

首先是調用者要自己在腦中記得,我要多加一個 if 判斷 mean 的結果是不是 NaN,其次是這種做法不泛用,無法抽象成萬用的多型方法,像 map 那樣,且若是多型方法你也是不曉得要回傳 Double 型態的 NaN,還是非基本型態的 null。

或許我們有第二種作法,多加一個參數給定若 List 長度為 0 時會回傳的初始值,像這樣,

def mean2(xs: Seq[Double], onEmpty: Double): Double =
  if xs.isEmpty then
    onEmpty
  else
    xs.sum / xs.length

然後就又有人有問題了,如果我想在遇到空 List 直接中斷程式怎麼辦?你這樣改我要調整的地方很多耶!

看起來這種做法還是不夠自由,所以我們需要一個方法去應對以上所有遇到的情況。

讓我們歡迎 Option, Either

enum Option[+A]:
  case Some(get: A)
  case None

enum Either[+E, +A]:
  case Left(get: E)
  case Right(get: A)

我們一樣會透過實作 Scala 基本庫已有的類別 EitherOption 來當做練習,了解 functional programming 中如何處理錯誤,也能符合 Referential Transparency。

Option 資料型態

enum Option[+A]:
  case Some(get: A)
  case None

Option 有 2 種表示,一種代表定義存在的 Some,另一種是未定義的 None,我們可以用 Option 來幫前一天的 mean function 加工,讓 mean 的回傳能包含所有造成它無法定義的狀況。

def mean(l: List[Double]): Option[Double] =
  if l.isEmpty then 
    None
  else
    Some(l.sum / l.length)

雖然 Scala 原生庫中已經有 Option 了,但我們還是可以試著自行實現看看,了解如何使用 pattern match 和 high-order function 去實作那些符合 functional programming 風格的好用功能;

但今天的實作會跟 Day 3 ~ Day 5 有些不太一樣,我們是把所有 function 定義在 List 的 companion object 裡,而今天我會在 Option 這個 enum 底下定義 function,如此能直接以 Option 物件做 function 呼叫,使用上更直覺。


Exercise D7-1

嘗試使用 pattern match 實作以下 function 吧!

  • getOrElse:從 Option 取得值,或回傳 default 值。
  • flatMap:能把 2 個 Option 弄成一個 Option 後回傳。
  • orElse:若 Option 有被定義,則回傳自己,否則回傳入參 Option。
  • filter:只保留符合 function f 條件 的 Option。
enum Option[+A]:
  case Some(get: A)
  case None


  def map[B](f: A => B): Option[B] = this match
    case None => None
    case Some(x) => Some(f(x))

    def getOrElse[B >: A](default: => B): B
  def flatMap[B](f: A => Option[B]): Option[B]
  def orElse[B >: A](ob: => Option[B]): Option[B]
  def filter(f: A => Boolean): Option[A]

default: => B 表示了 default 是 call-by-name,然後回傳 B;deafult 如果用不到的話,它就不會被 evaluate,我們在 Day 9 的 Laziness 時會介紹更多。


用 Option 包裹的好處就是我們能用 high-order function 去做各種轉換操作,而不用在其中處理錯誤,我們來看個例子,

scala> case class Employee(name: String, department: String)

scala> val employees = List(Employee("tshine73", "RD"), Employee("Bob", "Support"))

scala> employees.find(_.name == "Bob")
     |     .map(_.department)
     |     .filter(_ == "Support")
     |     .getOrElse("default department")
val res1: String = Support

我定義了一個 Employee 類別,然後使用 List.find 嘗試找到名為 Bob 的員工,find 會回傳 Option 物件,

def find(p: A => Boolean): Option[A]

然後透過 map 將 Employee Option 轉變為部門名稱,然後在使用 filter 過濾部門是否為 Support,否則就回傳預設部門名稱;

當其中某些操作失敗時,後續的所有操作都不會執行,例如 None.map(f) 就會立即回傳 None,直到你使用 getOrElse 之類的 function 去取值;

scala> employees.find(_.name == "tshine73")
     |     .map(_.department)
     |     .filter(_ == "Support")
     |     .getOrElse("default department")
val res2: String = default department

當你還是需要將 None 轉為 Exception 時,一個常用的方式是 getOrElse(throw new Exception("Fail"))

Either 資料型態

或許你有注意到,Option 無法讓調用者知道究竟是出了哪些錯,我們只看到 None 被傳遞回來,但有些時候我們想知道到底發生什麼錯誤,此時我們有另一個選擇 Either,

enum Either[+E, +A]:
  case Left(get: E)
  case Right(get: A)

跟 Option 的最大的不同是,2 種表示都有攜帶值,Left 通常表達失敗或發生錯誤,而 Right 通常表達成功,

讓我們來看如何用 Either 改寫這幾天用到的 mean function,現在我們可以透過 Left 來取得錯誤資訊了。

def mean(xs: Seq[Double]): Either[Exception, Double] =
  if xs.isEmpty then
    Left(new ArithmeticException("mean of empty list"))
  else
    Right(xs.sum / xs.length)

Exercise D7-2

跟 Option 類似,我們一樣可以用 pattern match 在 Either 中實作 flatMap, orElse 的 function,來試看看吧!

enum Either[+E, +A]:
  case Left(get: E)
  case Right(get: A)

  def map[B](f: A => B): Either[E, B] = this match
    case Left(e) => Left(e)
    case Right(a) => Right(f(a))

    def flatMap[EE >: E, B](f: A => Either[EE, B])
  def orElse[EE >: E, B >: A](b: => Either[EE, B])

Exercise answer

tshine73
tshine73
文章: 50

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *