IO Monad 設計淺析(一):Monad 和 MonadError
ZIO 是最近 Scala 社群非常熱門且與眾不同的 IO Monad 實現,本專題我們會從各個角度分析 ZIO 和 Cats-Effect 等 IO Monad 的設計。
一個簡單的 IO Monad 方案
對於 Monad
,大家都很熟悉:
trait Monad[F[_]] {
def flatMap[A,B](fa: F[A])(f: A => F[B]): F[B]
def pure[A](x: A): F[A]
}
複製程式碼
是一個定義了函式 flatMap
和 pure
的很常見的 Typeclass(也有其他的形式,就不具體討論了)。
可以看到,Monad
通常來說只有一個型別引數。例如我們最常見的 List
Monad
。
注意,當我們說
List
是一個Monad
的時候,是指我們可以實現一個List
的Monad
例項,並且這個例項滿足Monad
相關的 Laws。
同樣的,IO Monad 也是 Monad,特別的是,它可以隔離副作用。
假如要自己設計一個簡單的 IO Monad,我們腦子裡通常會冒出這樣的方案:
object IO {
private case class Pure[A](value: A) extends IO[A]
private case class FlatMap[A,B](fa: IO[A],f: A => IO[B]) extends IO[B]
private case class Effect[A](run: () => A) extends IO[A]
def effect[A](f: () => A): IO[A] = Effect(f)
implicit val ioMonadInstance: Monad[IO] = new Monad[IO] with StackSafeMonad[IO] {
def pure[A](x: A) = IO.Pure(x)
def flatMap[A,B](fa: IO[A])(f: A => IO[B]): IO[B] = FlatMap (fa,f)
}
}
sealed trait IO[A] {
def unsafeRun(): A = this match {
case IO.Pure(a) => a
case IO.FlatMap(fa,f) => f(fa.unsafeRun).unsafeRun
case IO.Effect(run) => run()
}
}
複製程式碼
這是比較常見的以 ADT 形式描述程式的資料型別。
其中 Effect
子型別提供了非常樸素的副作用隔離功能,它簡單的把執行副作用的程式碼儲存在一個無參函式中,從而達到延遲執行效果。
同時,它還定義了 cats 的 Monad
例項, 從而我們就可以用 cats 提供的 Monad 相關的任何操作。
IO
除了 unsafeRun
以外的所有操作都是純函式,按照 FP 的潛規則,所有的副作用都只在應用邊界觸發,程式的可推理程度會大幅提升。
下面就是一個控制檯程式的例子:
import cats.syntax.all._
object Console {
def getStr(): IO[String] = IO.effect(() => scala.io.StdIn.readLine())
def putStrLn(str: String): IO[Unit] = IO.effect(() => println(str))
}
def sum(i: IO[Int],j: IO[Int]): IO[Int] = {
i.flatMap { ii =>
j.map(jj => ii + jj)
}
}
val readInt = Console.getStr().map(_.toInt)
val app = sum(readInt,readInt).flatMap { r =>
Console.putStrLn(r.toString)
}
app.unsafeRun()
複製程式碼
上述程式在 IO
型別沒有定義 flatMap
、 map
的情況下,我們通過 import cats.syntax.all._
就可以使用 cats 通過 implicit class 定義的擴充套件函式。
這是 cats 約定的套路,擴充套件方法都定義在所謂 syntax 包中,當然我們也可以 for-comprehension 來使程式結構更清晰。
錯誤處理
雖然上述程式隔離了副作用,但它仍舊不是一個純函式,原因在於它數字轉化:
val readInt = Console.getStr().map(_.toInt)
複製程式碼
使用了String.toInt
,這可能會在執行時丟擲異常,所以這不是一個 total function,它對非整形的輸入存在未定義行為(丟擲異常),這種情況一般叫做 partial function。
嚴謹的程式顯然不能接受執行到一半突然終止了,尤其是在一些服務端程式,這些程式通常需要一直執行提供服務,錯誤處理就顯得格外重要。按照 Java 的習慣,我們只要在合適的地方加上 try
catch
列印一行日誌或顯示系統錯誤 “假裝” 已經處理了這些異常。
但這在一些要求知道錯誤資訊的地方,就行不通了,Java 程式設計師可能會通過建立 ChecedException 來攜帶錯誤資訊,實現錯誤處理邏輯。
scala 程式通常會使用 Try
或者 Either
來表達一個結果可能存在失敗,上述程式我們可以用 Either
把 readInt
改成以下形式:
val readInt: IO[Either[String,Int]] = Console.getStr().map { str =>
try{
Right(str.toInt)
} catch {
case e: Exception => Left(s"$str is not a number")
}
}
複製程式碼
這樣,我們就成功地把錯誤資訊保留在了 Either
中。然而,sum
函式的輸入引數也得改成 IO[String,Int]
才行。這會導致 sum
的邏輯非常臃腫,這個函式它不需要關心具體錯誤資訊,Either
的各種判斷,會讓邏輯變得不好理解。
為瞭解決這個問題 cats 還有 MonadError
的 Typeclass 來處理這類問題。我們只需要提供 IO
型別的 MonadError
例項,就可以使用相關操作。為了到達這個目的,我們將在 IO 中增加一種能攜帶錯誤子型別:
case class Failure[A](ex: Throwable) extends IO[A]
...
def unsafeRun(): A = this match {
case IO.Pure(a) => a
case IO.FlatMap(fa,f) => f(fa.unsafeRun).unsafeRun
case IO.Effect(run) => run()
case IO.Failure(ex) => throw ex
}
implicit def ioMonadErrorInstance: MonadError[IO,Throwable] = new MonadError[IO,Throwable] with StackSafeMonad[IO] {
def pure[A](x: A) = IO.Pure(x)
def flatMap[A,B](fa: IO[A])(f: A => IO[B]): IO[B] = IO.FlatMap(fa,f)
def raiseError[A](e: Throwable) = IO.Failure(e)
def handleErrorWith[A](fa: IO[A])(f: Throwable => IO[A]): IO[A] = {
fa match {
case Failure(ex) => f(ex)
case io => io
}
}
}
複製程式碼
注意
unsafeRun
本來就是不安全的,所以我們丟擲異常並沒有超出預期。
這樣一來我們就可以把 readInt
重構成:
case class NotAInt(str: String) extends Throwable
val readInt: IO[Int] = Console.getStr().flatMap { str =>
val r: Either[Throwable,Int] = try {
Right(str.toInt)
} catch {
case e: Throwable => Left(NotAInt(str))
}
MonadError[IO,Throwable].fromEither(r) // 召喚 MonadError 例項
}
複製程式碼
如果我們想處理這個錯誤,只需要在程式邏輯中增加相應邏輯:
val app = sum(readInt,readInt).flatMap { r =>
Console.putStrLn(r.toString)
}.recoverWith {
case NotAInt(str) => Console.putStrLn(s"$str 不是一個字串")
}
複製程式碼
當然,這個方案也不是沒有缺點:
- 首先,所有的錯誤都必須是
Throwable
的子型別。 - 其次,所有的錯誤型別在程式中都泛化成了
Throwable
,無法直觀的從型別中看出錯誤型別,這樣可能會導致呼叫者不知道需要處理什麼錯誤。
所以在實際應用中,我們也可能會使用 IO[Either[E,A]]
以及相應的 transformer EitherT
來處理問題。這裡我們只是演示一下 IO Monad 設計時,如果要攜帶錯誤資訊,通常如何實現。
小結
本文簡單介紹 IO Monad 的一個樸素設計,主要作為後續考察其他 IO Monad 如: Cats-Effect / ZIO 做一個鋪墊。
同時本文也提出了程式設計中錯誤處理的重要性,以及如何用 MonadError 實現一種錯誤處理的手段,後續文章中,我們也會介紹其他錯誤處理的手段。