部分代码基于 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 来表示计算错误。但由于以下三点原因,我们拒绝采用这种方式:
- 导致错误以一种 "无声" 的方式传播。
- 导致后期存在大量的
if
代码检测模板。 - 无法用于泛型代码,因为无法定义
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]
Scala 2 可以使用这样的方式来表达代数数据类型:
trait Optional[+A]
case object None extends Optional[Nothing]
case class Some[+A](v : A) extends Optional[A]
异常情况可以仅使用一个值来代替: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 范式编程中基本的几个方法,map
,flatMap
,filter
等:
// 比如 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
为了保持方法的拓展性,本文的方法都具备类型参数。比如 getOrElse
和 orElse
的类型参数声明 B >: A
,这会允许用户返回一个比自身更抽象的实例。两者的区别是:getOrElse
返回拆箱后的 A
或 B
,而 orElse
则返回 Optional[A]
( 即自身 ) 或 Optional[B]
。
flatMap
和 map
的语义存在差异。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
除了 map
,flatMap
,filter
等通用方法之外,sequence
和 traverse
也是在 FP 中常见的方法。以自己实现的 Optional[A]
为例子,两者的功能分别是:
sequence
:接收一个List[Optional[A]]
序列,将它翻转为Optional[List[A]]
。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]
/*
define in scala 2:
trait _Either[+E,+A]
case class Left[+E](ex : E) extends _Either[E,Nothing]
case class Right[+A](v : 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
表达式,但这不在 MayCompute
的 try-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
版本的 map
,flatMap
等方法的声明。
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 的 flatMap
,orElse
添加了更多的上下界规则,因为在这里要同时考虑到其异常和值都是协变的。下面两个方法均会报错:
@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