Scala:函数式编程下的异常处理

部分代码基于 Scala 3,主要用到了优化后的枚举类声明,见:Scala 3 新特性一览 - 掘金 (juejin.cn)

先谈抛出异常的优点:提供了集中处理代码异常的逻辑,即 catch 语句块。但这种机制本身却破坏了 FP 思想中的引用透明机制。或者说,原本纯函数的计算结果将有可能 依赖上下文

def MayThrowEx(x : Int) : Double =
  try
    val y  = 10
    y / x 
  catch
    case e : Exception => -999.9d
end MayThrowEx

def MustThrowEx(x : Int) : Int =
  try
    // Scala 可以主动引导抛出类型的返回类型,具体在 catch 块中设置。
    val y  = ((throw new Exception("non")) : Int)
    x + y
  catch
    case e : Exception => 12
end MustThrowEx
复制代码

显然,MayThrowEx 的返回值不完全取决于 x + y,实际上还可能取决于 catch 块中的返回值,比如用户调用 MayThrowEx(0)MustThrowEx 是一个更极端的例子,其强制抛出异常的做法导致运算结果实际上完全依赖 catch 块的值。总而言之,包含 try-catch 结构的纯函数随时有变得 "不纯" 的风险。

同时,异常是类型不安全的。Int => Int 的函数本身并没有告诉用户可能抛出何种类型的异常,当然,编译器也不会强制要求用户处理运行时异常。只不过如果用户忘记对潜在的异常进行处理,那么程序只有在运行期才会检测出问题。

Java 强制要求用户对受检异常进行检查,要么就将它抛给上级。然而,这可能会导致函数签名的后缀被迫加上 throws xxx,xxx,xxx ... 。这个机制不适用于高度泛化的高阶函数,因为高阶函数无法感知到它接收的函数可能引发什么异常。

替代异常的方案

同时,我们又不希望丢弃异常处理的好处:整合集中的错误处理,因此要寻找其它表达异常的方式 ( 这不意味着我们要完全放弃抛出异常的做法,只不过它不适合在 FP 中这么做 )。一种是使用 C 语言中以特定错误码的风格来表示异常,比如事先约定 MayThrowEx 返回非负数,这样就可以使用任意一个负数 -1d,或者是 -999.9d 来表示计算错误。但由于以下三点原因,我们拒绝采用这种方式:

  1. 导致错误以一种 "无声" 的方式传播。
  2. 导致后其大量的 if 代码检测模板。
  3. 无法用于泛型代码,因为我们无法预知 T 的异常值定义具体应该是什么样的。

另一方面,为了保证函数正常运行,比如前文提到的 MayThrowEx,用户可能需要额外添加一些约束,比如提前检测 x 的值不可为 0。在 FP 编程中,些规则很难随着 MayThrowEx 传递给其它高阶函数,因为高阶函数自身并不区别对待传入的参数。

第二种方式,将一个函数升级为完全函数 ( total function ),让用户决定当 MayThrowEx 发生错误时应该传回什么值。

def MayThrowEx(x: Int, ifPanic: Double): Double =
  val y = 10
  if x == 0 then ifPanic else y / x
end MayThrowEx
复制代码

而这种做法的缺陷在于:用户需要自行判断异常发生时应当返回什么值。其次,假设程序已经接受了不合法的输入,那或许停止计算,或者选择其它计算分支更明智,而不是总用一个默认值来兜底。因此,最好需要引入一个能够推迟决定当发生意外时如何处理的机制,以便于在合适的时机进行解决。

fpinscala/01.answer.md at second-edition · fpinscala/fpinscala · GitHub

Option

Option 是典型的代数数据类型 Algebraic Data Type,因此这里采用 Scala 3 的枚举类进行定义更加合适。**Scala 库中已经包含了 Option 和 Either **,这里给出自己的实现 Optional:

// 比如 Father >:> Son,
// 那么 Some[Father] >:> Some[Son]
enum Optional[+A] :
  case Some[+A](get : A) extends Optional[A]
  case None extends Optional[Nothing]
复制代码

异常情况可以仅使用一个值来代替:None。Scala 的协变机制保证了 Optional[Nothing] 是任意一个 Optional[X] 的子类型。现在,一个计算的结果无论是否出错了,我们都能将其视作 Optional 进行处理:

def MayCompute(x: Int): Optional[Double] =
  import Optional.*
  val y = 10
  if x == 0 then None else Some(y / x)
end MayCompute
复制代码

一个返回 Optional ( 即 Option,Either,或类似概念 ) 函数不会对所有的输入都产生一个有意义的输出,称这样的函数为部分函数。

下面给出 Optional 的具体实现,包括在 FP 变成额中基本的几个方法,mapflatMapfilter 等:

// 比如 Father >:> Son,
// 那么 Some[Father] >:> Some[Son]
enum Optional[+A]:
  case Some[+A](get: A) extends Optional[A]
  case None extends Optional[Nothing]

  def map[B](f: A => B): Optional[B] = this match
    case Some(a) => Some(f(a))
    case _ => None

  def flatMap[B](f: A => Optional[B]): Optional[B] = this match
    case None => None
    case Some(a) => f(a)

  def getOrElse[B >: A](`default`: => B): B = this match
    case None => `default`
    case Some(a) => a

  def orElse[B >: A](ob: => Optional[B]): Optional[B] = this match
    case None => ob
    case _ => this

  def filter(f: A => Boolean): Optional[A] = this match
    case Some(a) if f(a) => Some(a)
    case _ => None
复制代码

为了保持方法的拓展性,本文的方法都具备类型参数。比如 getOrElseorElse 的类型参数声明 B >: A,这会允许用户返回一个比自身更抽象的实例。两者的区别是:getOrElse 返回拆箱后的 AB,而 orElse 则返回 Optional[A] ( 即自身 ) 或 Optional[B]

flatMapmap 的语义存在差异。flatMap 接收的函数 f 产出另一个 Optional,因而诞生了如下方法 map2:自身 Optional[A] 接收另一个 Optional[B],同时使用 (A,B) => C 函数返回另一个 Optional[C]

def map2[B, C](b: Optional[B])(f: (A, B) => C): Optional[C] =
  this flatMap (aa => {
    b map {
      bb => f(aa, bb)
    }
  })
复制代码

这种 flatMap + map 的组合逻辑用 For 表达式写出来则更加直观:

val value: Optional[(String,Int)] = for {
    a <- Some("key")
    b <- Some(200)
} yield (a, b)
复制代码

对 Scala For 表达式的深入了解,见:Scala +:类型推断,列表操作与 for loop - 掘金 (juejin.cn)

Sequence 与 Traverse

除了 mapflatMapfilter 等通用方法之外,sequencetraverse 也是在 FP 中常见的方法。以自己实现的 Optional[A] 为例子,两者的功能分别是:

  1. sequence:接收一个 List[Optional[A]] 序列,将它翻转为 Optional[List[A]]
  2. traverse:接受一个 List[A] 序列和 A => Optinal[B] 函数,随后将序列翻转为 Optional[List[B]]

可以将 traverse 看作是一个比 sequence 更泛化的方法,这只需要额外传递一个 Optinonal[A] => Optional[A] 的函数,最简单的形式为 x => x ( 这里 A =:= B ),因此可以先实现 traverse 方法,然后将 sequence 视作是它的一种特殊情况。另一方面,这两个方法均接收外部传入的 Optional[A] 处理并返回,因此将它们声明在 Optional 伴生对象中作为工具函数更为合适。

object Optional:

  def traverse[A,B](os : List[A])(f : A => Optional[B]) : Optional[List[B]] = os match
    case Nil => Some(Nil)
    case h :: tails => f(h).map2(traverse(tails)(f))(_ :: _)

  def sequence[A](as: List[Optional[A]]): Optional[List[A]] = traverse(as)(x => x)
复制代码

提升 Lift

普通的映射函数可以提升 ( lift ) 为对 Optional ( Option,Either ) 映射的函数,这个思路类似装饰器模式,因此不需要对之前的任何函数签名进行更改。

object Optional:
  // - traverse
  // - sequence
  def lift[A, B](f: A => B): Optional[A] => Optional[B] = _ map f // oa : Optional[A] => oa.map(f)
复制代码

Either

本章的核心在于使用普通的值类来统一表达程序运行失败或抛出异常,Optional 是其中一个解决方案,但不是唯一的,也不是最好的。原因是:Optional 不会告诉用户发生错误的具体原因,而仅仅是抛出一个 None

针对这个问题,我们创建出另一个 ADT 类型 Either 来对错误信息进行一个详细提示,下面给出其实现:

enum _Either[+E,+A] :
  case Left[+E](ex : E) extends _Either[E,Nothing]
  case Right[+A](value : A) extends _Either[Nothing,A]
复制代码

其中,Left 表示发生错误时的结果,而 Right 表示正确计算时的值 ( Right 本身具有双关的含义,它们在 Scala 原生库中也是被这么定义的 )。重新回顾 MayCompute 的例子,如果现在调用 MayCompute(0),那么程序将返回一个 Left(x shouldn't be 0)

def MayCompute(x: Int): _Either[String,Double] =
  import _Either.*
  val y = 10
  if x == 0 then Left("x shouldn't be 0.") else Right(y / x)
end MayCompute
复制代码

当然,可以选择捕获原生的 Exception 异常,因为它还携带堆栈调用信息,这便于用户排查问题。

def MayCompute(x: Int): _Either[Exception,Double] =
  import _Either.*
  val y = 10
  try Right(y / x) catch case e : ArithmeticException => Left(e)
end MayCompute
复制代码

利用传名函数推迟计算

传名调用部分见笔者的早期笔记:Scala 之:函数式编程之始 - 掘金 (juejin.cn)

然而,若传入的是一个表达式而非字面量,这个函数调用会失败:

println(MayCompute(10 / 0))
复制代码

当前的 MayCompute 方法是传值调用,程序在调用该函数之前会首先计算 10 / 0 表达式,但这不在 MayComputetry-catch 块内。解决的方法很简单:将 x 变成一个传名调用,推迟计算。

def MayCompute(x: => Int): _Either[String,Double] = ...
复制代码

这样,程序只有在计算到 Right(y / x) 时才会转而计算 10 / 0 表达式,而此时程序已经进入了 try-catch 的区域之内。

如果将 MayCompute 的整个逻辑进行提纯,我们会得到更加泛化的 Try 函数:

def Try[E <: Exception,V](v : => V) : _Either[E,V] =
  import _Either.*
  try Right(v) catch case e: E => Left(e)
复制代码

型变问题的补充

下面是包含了 Either 版本的 mapflatMap 等方法的声明。

enum _Either[+E, +A]:
  case Left[+E](ex: E) extends _Either[E, Nothing]
  case Right[+A](value: A) extends _Either[Nothing, A]

  import _Either.{Left, Right}

  def map[B](f: A => B): _Either[E, B] = this match
    case Left(ex) => Left(ex)
    case Right(value) => Right(f(value))

  def flatMap[EE >: E,B](f: A => _Either[EE,B]): _Either[EE,B] = this match
    case Left(ex) => Left(ex)
    case Right(value) => f(value)

  def orElse[EE >: E, B >: A](`default`: => _Either[EE, B]): _Either[EE, B] = this match
    case Left(ex) => `default`
    case _ => this

  def map2[EE >: E, B, C](b: _Either[EE, B])(f: (A, B) => C): _Either[EE, C] =
    for aa <- this; bb <- b yield f(aa, bb)


object _Either:
  def traverse[E, A, B](as: List[A])(f: A => _Either[E, B]): _Either[E, List[B]] = as match
    case Nil => Right(Nil)
    case h :: tails => f(h).map2(traverse(tails)(f))(_ :: _)
  def sequence[E, A](es: List[_Either[E, A]]): _Either[E, List[A]] = traverse(es)(x => x)
复制代码

相比 Optional ,Either 的 flatMaporElse 添加了更多的上下界规则,因为在这里要同时考虑到其异常和值都是协变的。下面两个方法均会报错:

@Deprecated
def flatMap00[EE,B](f: A => _Either[EE,B]): _Either[EE,B] = this match
  case Left(ex) => Left(ex) // error
  case Right(value) => f(value)

@deprecated
def orElse00[EE, B](`default`: => _Either[EE, B]): _Either[EE, B] = this match
  case Left(ex) => `default`
  case Right(value) => Right(value) // error
复制代码

先看 flatMap00 方法。Left(ex)_Either(E,Nothing) 类型,但是函数签名要求返回 _Either[EE,B] 类型。因此为了满足 Either 定义的协变关系,所以要声明 EE >: E 来表示自身类型 _Either(EE,Nothing)_Either(E,Nothing) 的父类型。

orElse00 方法同理。Right(value)_Either(Nothing,A) 类型,但函数签名要求返回返回 _Etiher[EE,B] 类型,这里也要声明 B >: A 来满足协变关系。想要详细了解型变部分的内容,见:Scala 泛型中的 Liskov 哲学 - 掘金 (juejin.cn)

参考资料

scala - 使用“Scala中的函数编程”中的eta扩展来“提升”? - Thinbug

function - What is "lifting" in Scala? - Stack Overflow

(4 条消息) 如何评价scalaz这个库? - 知乎 (zhihu.com)

おすすめ

転載: juejin.im/post/7068196620692619277