1. 程式人生 > 程式設計 >IO Monad 設計淺析(一):Monad 和 MonadError

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]
}
複製程式碼

是一個定義了函式 flatMappure 的很常見的 Typeclass(也有其他的形式,就不具體討論了)。

可以看到,Monad 通常來說只有一個型別引數。例如我們最常見的 List

,就是一個典型的 Monad

注意,當我們說 List 是一個 Monad 的時候,是指我們可以實現一個 ListMonad 例項,並且這個例項滿足 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 型別沒有定義 flatMapmap 的情況下,我們通過 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 來表達一個結果可能存在失敗,上述程式我們可以用 EitherreadInt 改成以下形式:

 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 實現一種錯誤處理的手段,後續文章中,我們也會介紹其他錯誤處理的手段。