龙空技术网

Scala函数式编程:4 处理错误

启辰8 11

前言:

此时朋友们对“c语言判断成绩等级if语句”可能比较关切,姐妹们都想要了解一些“c语言判断成绩等级if语句”的相关知识。那么小编同时在网络上汇集了一些有关“c语言判断成绩等级if语句””的相关文章,希望兄弟们能喜欢,小伙伴们一起来学习一下吧!

本章涵盖

讨论例外的缺点选项数据类型简介介绍任一数据类型Try 数据类型简介

我们在第 1 章中简要指出,抛出异常是一种副作用。如果在函数式代码中不使用异常,则使用什么?在本章中,我们将学习在功能上引发和处理错误的基本原则。最重要的想法是,我们可以用普通值表示故障和异常,我们可以编写高阶函数来抽象出错误的处理和恢复的常见模式。将错误作为值返回的函数解决方案更安全,并且保留了引用的透明度,并且通过使用高阶函数,我们可以保留异常的主要好处:错误处理逻辑的合并。我们将在本章中仔细研究异常并讨论它们的一些问题后,看到它是如何工作的。

出于与我们在上一章中创建自己的 List 数据类型相同的原因,我们将在本章中重新创建两种 Scala 标准库类型:Option 和 Can。我们的目的是增强您对这些类型如何用于处理错误的理解。完成本章后,您应该随意使用 Scala 标准库版本的 Option 和 Both(尽管您会注意到这两种类型的标准库版本都缺少我们在本章中定义的一些有用函数)。

4.1 异常的好坏方面

为什么例外会破坏参照透明度,为什么这是一个问题?让我们看一个简单的例子;我们将定义一个引发异常并调用它的函数。

示例 4.1 抛出和捕获异常

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

(1) val y: Int = ...将 y 声明为具有 Int 类型,并将其设置为等于 = 的右侧。

(43)捕获块只是我们所看到的模式匹配块。情况 e:异常是匹配任何异常的模式,它将此值绑定到标识符 e。匹配项返回值 <>。

从 REPL 调用 failingFn 会导致引发异常:

scala> failingFn(12)java.lang.Exception: fail!  at failingFn(<console>:8)  ...

我们可以证明y在引用上不透明。回想一下,任何 RT 表达式都可以替换为它所引用的值,并且此替换应保留程序含义。如果我们在 x + y 中用 Throw(“fail!”) 代替 y,它会产生不同的结果,因为异常现在将在 try 块中引发,该块将捕获异常并返回 43 :

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

(1)抛出的异常可以给定任何类型;在这里,我们使用 Int 类型对其进行注释。

我们可以在 REPL 中证明这一点:

scala> failingFn2(12)res1: Int = 43

理解 RT 的另一种方法是知道 RT 表达式的含义不依赖于上下文,并且可以在本地推理,而非 RT 表达式的含义是上下文相关的,需要更多的全局推理。例如,RT 表达式 42 + 5 的含义不依赖于它嵌入的较大表达式——它始终且永远等于 47 。但是表达式 throw 异常(“失败”)的含义非常依赖于上下文;正如我们刚刚演示的,它具有不同的含义,具体取决于它嵌套在哪个 try 块(如果有的话)。

异常有两个主要问题:

异常会中断 RT 并引入上下文依赖性。这使我们远离了替换模型的简单推理,从而可以编写令人困惑的、基于异常的代码。这是民俗建议的来源,即异常应仅用于错误处理,而不应用于控制流。异常不是类型安全的。失败Fn的类型,Int => Int没有告诉我们可能发生异常的事实,编译器肯定不会强迫failingFn的调用者决定如何处理这些异常。如果我们忘记检查 失败Fn 中的异常 ,直到运行时才会检测到。

已检查的异常

虽然 Java 的检查异常会强制决定是否处理或重新引发错误,但它们会为调用方带来重要的样板文件。更重要的是,它们不适用于高阶函数,1 这些函数不可能意识到它们的参数可能引发的特定异常。例如,考虑我们为 List 定义的映射函数:

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

此函数显然很有用,高度通用,并且与使用检查异常不一致;我们不能为F可能抛出的每个检查异常提供一个map版本。即使我们想这样做,地图如何知道哪些例外是可能的?这就是为什么通用代码,即使在Java中,也经常使用RuntimeException或一些常见的检查异常类型。

我们想要一个没有这些缺点的异常替代方案,但我们不想失去异常的主要好处:它们允许我们整合和集中错误处理逻辑,而不是被迫在整个代码库中分发此逻辑。我们使用的技术基于一个旧的想法:我们返回一个值,指示发生了异常情况,而不是抛出异常。任何在 C 中使用返回码来处理异常的人都可能熟悉这个想法,但我们没有使用错误代码,而是为这些可能定义的值引入了一个新的泛型类型,并使用高阶函数来封装处理和传播错误的常见模式。与 C 风格的错误代码不同,我们使用的错误处理策略是完全类型安全的,并且我们从类型检查器那里得到全力帮助,迫使我们以最小的语法噪音处理错误。我们很快就会看到所有这些是如何工作的。

4.2 例外的可能替代办法

现在让我们考虑一个现实情况,在这种情况下,我们可能会使用异常,并查看我们可以改用的不同方法。下面是一个函数的实现,该函数计算列表的平均值,如果列表为空,则未定义该平均值:

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

(4)Seq是各种线性序列类集合的通用界面。有关详细信息,请查看 API 文档 (;>)。

(2) 仅当序列的元素是数字时,sum 才定义为 Seq 上的方法。标准库使用隐式完成了这个技巧,我们不会在这里讨论。

均值函数是所谓的偏函数的一个例子,这意味着它没有为某些输入定义。函数通常是部分函数,因为它对其输入做出一些假设,而这些假设不是由输入类型暗示的。2 在这种情况下,您可能习惯于抛出异常,但我们还有其他一些选择。让我们看看这些作为我们的中庸示例。

第一种可能性是返回某种类型的 Double 类型的虚假值。在所有情况下,我们可以简单地返回 xs.sum / xs.length,当输入为空时,即 Double.NaN,导致 0.0/0.0;,或者我们可以返回一些其他哨兵值。在其他情况下,我们可能会返回 null 而不是所需类型的值。这类通用方法是错误处理通常在语言中完成的,没有例外,我们拒绝此解决方案有几个原因:

它允许错误以静默方式传播。调用方可能会忘记检查此条件,并且编译器不会发出警报,这可能会导致后续代码无法正常工作。通常,直到代码的后面才会检测到错误。它会导致调用站点出现大量样板代码,并使用显式 if 语句来检查调用方是否已收到实际结果。如果您碰巧调用了多个函数,则此样板文件将被放大,每个函数都使用必须以某种方式检查和聚合的错误代码。它不适用于多态代码。对于某些输出类型,即使我们愿意,我们甚至可能没有该类型的哨兵值!考虑一个像 max 这样的函数,它根据自定义比较函数查找序列中的最大值:def max[A](xs:Seq[A])(更大:(A,A) => 布尔值):A 。如果输入为空,我们就不能发明 A 类型的值,这里也不能使用 null,因为 null 只对非基元类型有效,而 A 实际上可能是像 Double 或 Int 这样的基元。它要求调用方的特殊策略或呼叫约定。正确使用均值函数需要调用者执行调用均值以外的其他操作并利用结果。像这样为函数提供特殊策略使得很难将它们传递给高阶函数,高阶函数必须统一处理所有参数。

第二种可能性是强制调用方提供一个参数,告诉我们在不知道如何处理输入的情况下该怎么做:

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

这使得平均值成为一个整体功能,但它有缺点;它要求直接调用方直接了解如何处理未定义的情况,并限制他们返回 Double .如果平均值被调用为更大计算的一部分,并且如果平均值未定义,我们希望中止该计算,该怎么办?或者,在这种情况下,也许我们想在更大的计算中采用一些完全不同的分支。简单地传递一个onEmpty参数并不能给我们这种自由。我们需要一种方法来推迟决定如何处理未定义的情况,以便能够在最适当的级别处理它们。

4.3 选项数据类型

该解决方案显式表示函数可能并不总是在返回类型中具有答案。我们可以将其视为服从于错误处理策略的调用方。我们引入了一种新类型: 选项 .如前所述,这种类型也存在于 Scala 标准库中,但我们出于教学目的在这里重新创建它:

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

选项有两种情况:可以定义,在这种情况下它将是 一些 ,或者它可以是未定义的,在这种情况下它将是 无 .我们可以像这样使用 Option 来定义平均值:

import Option.{Some, None} def mean(xs: Seq[Double]): Option[Double] =  if xs.isEmpty then None  else Some(xs.sum / xs.length)

返回类型现在反映了结果可能并不总是定义的可能性。我们仍然总是从我们的函数返回声明类型(现在是 Option[Double])的结果,所以 mean 现在是一个总函数,这意味着它将输入类型的每个值恰好带到输出类型的一个值。图 4.1 对比了使用哨兵值与使用 Option 类型。

图 4.1 在响应无效输入时使函数合计的技术

4.3.1 选项的使用模式

部分函数在编程中比比皆是,而 Option(以及我们稍后将讨论的 Both(以及我们稍后将讨论的 Both)数据类型)通常是在 FP 中处理这种部分性的方式。例如,在以下情况下,您将看到在整个 Scala 标准库中使用的 Option:

给定键的映射查找 () 返回选项 。为列表和其他可迭代对象 () 定义的 headOption 和 lastOption 返回一个 Option,其中包含序列的第一个或最后一个元素(如果该元素为非空)。

这些示例并不全面;我们将看到选项出现在许多不同的情况下。Option 之所以方便,是因为我们可以通过高阶函数分解出常见的错误处理模式,从而将我们从编写异常处理代码附带的常用样板中解放出来。在本节中,我们将介绍一些使用 Option 的基本功能。我们在这里的目标不是流利地使用所有这些功能,而是足够熟悉,以便您可以重新访问本章并在必须编写一些功能代码来处理错误时自行取得进展。

选件的基本功能

选项可以被认为是最多可以包含一个元素的列表,我们之前看到的许多List函数在选项上都有类似的功能。让我们看一下其中的一些函数。

就像我们在第 3 章中对 Tree 上的函数所做的那样,我们将函数放在 Option 类型的主体中,以便可以使用 obj.fn(arg) 语法调用它们。这种选择引发了一个关于方差的额外复杂性,我们将在稍后讨论。一起来看看吧。

示例 4.2 选项 数据类型

enum Option[+A]:  case Some(get: A)  case None   def map[B](f: A => B): Option[B]                   ①  def flatMap[B](f: A => Option[B]): Option[B]       ②  def getOrElse[B >: A](default: => B): B            ③  def orElse[B >: A](ob: => Option[B]): Option[B]    ④  def filter(f: A => Boolean): Option[A]             ⑤

(1) 如果选项不是“无”,则应用 f。

(2) 如果不是“无”,则对选项应用 f,这可能会失败。

(3)B>:A表示B类型参数必须是A的超类型。

(4)除非需要,否则不要评估ob。

(5) 如果值不满足 f 将一些转换为无。

这里有一些新的语法。默认值:=> getOrElse 中的 B 类型注释(以及 orElse 中的类似注释)表示参数是 B 类型,但在函数需要它之前不会对其进行计算。暂时不要担心这个;我们将在下一章中详细讨论这种非严格性的概念。此外,getOrElse 和 orElse 函数上的 B >:A 类型参数指示 B 必须等于 A 或 A 的超类型。需要说服 Scala 在 A 中将 Option[+A] 声明为协变仍然是安全的。有关更多详细信息,请参阅章节注释( fpinscala/wiki)。不幸的是,这有点复杂,但在 Scala 中是必要的复杂性;幸运的是,完全理解子类型和方差对于我们的目的来说并不重要。

练习 4.1

——————————————————————————————

在选项 上实现上述所有功能。在实现每个函数时,请尝试考虑它的含义以及在什么情况下使用它。接下来,我们将探讨何时使用这些函数。以下是解决此练习的一些提示:

使用模式匹配是可以的,尽管您应该能够实现除map和getOrElse之外的所有功能,而无需诉诸模式匹配。尝试实现 flatMap , orElse ,并根据 map 和 getOrElse 进行过滤。对于map和flatMap,类型签名应该足以确定实现。getOrElse 返回选项的某些情况内的结果,或者如果选项是 None ,则返回给定的默认值。orElse 返回第一个选项(如果已定义);否则,它将返回第二个选项。

基本选项功能的使用场景

虽然我们可以在选项上显式模式匹配,但我们几乎总是使用前面的高阶函数。在这里,我们将尝试为何时使用每个提供一些指导。流利地使用这些功能将伴随着练习,但这里的目标是获得一些基本的熟悉。下次尝试编写一些使用 Option 的功能代码时,在诉诸模式匹配之前,看看是否可以识别这些函数封装的模式。

让我们从地图开始。map 函数可用于转换 Option 中的结果(如果存在)。我们可以将其视为在假设错误未发生的情况下进行计算;这也是将错误处理推迟到以后的代码的一种方式:

case class Employee(  name: String,  department: String,  manager: Option[Employee]) def lookupByName(name: String): Option[Employee] = ... val joeDepartment: Option[String] =  lookupByName("Joe").map(_.department)

在这里 lookupByName(“Joe”) 返回一个 选项[员工] ,我们使用映射将其转换为选项[字符串]来拉出部门。请注意,我们不需要显式检查 lookupByName(“Joe”) 的结果;我们只是继续计算,就好像 Map 参数内没有发生错误一样。如果 lookupByName(“Joe”) 返回 None,这将中止其余的计算,并且 map 根本不会调用 _.department 函数。图 4.2 显示了其他链式计算。flatMap 与此类似,只是我们提供的转换结果的函数本身可能会失败。

图 4.2 链接使用 Option 的计算

练习 4.2

——————————————————————————————

根据平面图实现方差函数。如果序列的均值是m,则方差是序列中每个元素x的math.pow(x - m, 2)的均值。参见 Wolfram MathWorld () 上的方差定义:

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

正如方差的实现所表明的那样,使用 flatMap,我们可以构造一个包含多个阶段的计算,其中任何一个阶段都可能失败,并且一旦遇到第一个失败,计算就会中止,因为 None.flatMap(f) 将立即返回 None ,而不运行 f。

如果成功值与给定的谓词不匹配,我们可以使用 filter 将成功转换为失败。一种常见的模式是通过调用映射、平面映射和/或过滤器来转换选项,然后在最后使用 getOrElse 进行错误处理:

val dept: String =  lookupByName("Joe").  map(_.department).  filter(_ != "Accounting").  getOrElse("Default Dept")

getOrElse 在这里用于通过提供默认部门从选项[字符串]转换为字符串,以防地图中不存在键“Joe”或 Joe 的部门是“会计”。orElse 类似于 getOrElse ,除了如果第一个选项未定义,我们返回另一个选项。当我们需要将可能失败的计算链接在一起时,这通常很有用,如果第一个计算没有成功,则尝试第二个计算。

一个常见的习惯用法是使用 o.getOrElse(throw Exception(“FAIL”)) 将 Option 的 None 情况转换回异常。一般规则是,只有在没有合理的程序会捕获异常时才使用异常;如果对于某些调用方来说,异常可能是可恢复的错误,我们使用 Option(或 要么,如下文讨论)来为他们提供灵活性。如有疑问,请避免使用异常,尤其是在入门时 - 由于不熟悉,异常的良好用例通常最终会更好地用值来表达。

如您所见,将错误作为普通值返回可能很方便,并且使用高阶函数可以让我们实现与使用异常相同的错误处理逻辑合并。请注意,我们不必在计算的每个阶段检查 None;我们可以应用多个转换,然后在准备就绪时检查并处理 None。但是我们也获得了额外的安全性,因为 选项[A] 是 与 A 不同的类型 ,编译器不会让我们忘记显式推迟或处理 None 的可能性。

4.3.2 面向异常的选项组合、提升和包装 API

可能很容易得出结论,一旦我们开始使用 选项 ,它会遍布我们的整个代码库。可以想象,任何采用或返回 Option 的方法的调用者都必须修改以处理 一些 或 None ,但这不会发生,因为我们可以将普通函数提升为在 Option 上运行的函数。

例如,map 函数允许我们使用 A => B 类型的函数对 Option[A] 类型的值进行操作,该函数返回 Option[B]。另一种看待这个问题的方法是,映射将类型 A => B 的函数 f 转换为类型为 Option[A] => Option[B] 的函数。让我们明确这一点:

def lift[A, B](f: A => B): Option[A] => Option[B] =  _.map(f)                                          ①

(1) Recall _.map(f) 等价于匿名函数 oa => oa.map(f)。

这告诉我们,我们已经拥有的任何函数都可以转换(通过提升)以在单个选项值的上下文运行。让我们看一个例子:

val absO: Option[Double] => Option[Double] =  lift(math.abs) scala> val ex1 = absO(Some(-1.0))val ex1: Option[Double] = Some(1.0)

图 4.3 进一步分解了此示例。

图 4.3 提升函数以使用选项

数学对象包含各种独立的数学函数,包括 abs、sqrt、exp 等。我们不需要重写 math.abs 函数来处理可选值;我们只是在事后将其提升到选项上下文中。我们可以对任何函数执行此操作。让我们看另一个例子。

假设我们正在为一家汽车保险公司的网站实现逻辑,该网站包含一个页面,用户可以在该页面上提交表单以请求即时在线报价。我们想解析这个表单中的信息,并最终调用我们的 rate 函数:

/** * Top secret formula for computing an annual car * insurance premium from two key factors. */def insuranceRateQuote(age: Int, numberOfSpeedingTickets: Int): Double

我们希望能够调用这个函数,但是如果用户在 Web 表单中提交他们的年龄和超速罚单数量,这些字段将以简单的字符串形式到达,我们必须(尝试)将其解析为整数。此分析可能会失败;给定一个字符串 s ,我们可以尝试使用 s.toInt 将其解析为 Int,如果字符串不是有效的整数,则会抛出 NumberFormatException:

scala> "112".toIntres0: Int = 112 scala> "hello".toIntjava.lang.NumberFormatException: For input string: "hello"  at java.lang.NumberFormatException.forInputString(...)  ...

我们可以编写一个实用程序函数,将该异常转换为 None :

def toIntOption(s: String): Option[Int] =  try Some(s.toInt)  catch case _: NumberFormatException => None    ①

(1) 此语法允许我们捕获任何类型为数字格式异常的异常。由于我们不需要引用捕获的异常,因此我们不给它一个名称,而是使用 _。

让我们使用 toIntOption 实现一个函数 parseInsuranceRateQuote ,它将超速罚单的年龄和数量作为字符串,如果解析两个值成功,则尝试调用 insuranceRateQuote 函数。

清单 4.3 使用选项

def parseInsuranceRateQuote(    age: String,    numberOfSpeedingTickets: String): Option[Double] =  val optAge: Option[Int] = toIntOption(age)                 ①  val optTickets: Option[Int] = toIntOption(numberOfSpeedingTickets)  insuranceRateQuote(optAge, optTickets)                     ②

(1) 将字符串类型的年龄转换为选项[Int]。

(2)不打字检查!

但是有一个问题——在我们解析 optAge 和 optTicket 到 Option[Int] 之后,我们如何调用 insuranceRateQuote,它目前需要两个 Int 值?我们是否必须重写 insuranceRateQuote 来取 Option[Int] 值?不,改变 insuranceRateQuote 会纠缠不清问题,迫使它意识到先前的计算可能已经失败,更不用说我们可能无法修改 insuranceRateQuote ——也许它是在我们无法访问的单独模块中定义的。相反,我们希望将 insuranceRateQuote 提升为在两个可选值的上下文中运行。我们可以通过在 parseInsuranceRateQuote 的主体中使用显式模式匹配来做到这一点,但这将是乏味的。

练习 4.3

——————————————————————————————

编写一个通用函数 map2,该函数使用二进制函数组合两个 Option 值。如果任一选项值为 None ,则返回值也是如此。这是它的签名:

def map2[A, B, C](a: Option[A], b: Option[B])(f: (A, B) => C): Option[C]

请注意,我们这里有两个参数列表;第一个参数列表采用选项[A]和选项[B],第二个参数列表采用函数(A,B)=> C。为了调用这个函数,我们为每个参数列表提供值,例如,map2(oa, ob) (_ + _) 。我们可以改用单个参数列表来定义它,尽管当一个函数接受多个参数并且最后一个参数本身就是一个函数时,使用两个参数列表是常见的风格。3 这样做允许在传递多行匿名函数时进行语法变化,其中最终参数列表替换为缩进块或大括号分隔块:

map2(oa, ob): (a, b) =>     ①  a + b map2(oa, ob) { (a, b) =>    ②  a + b}

(1) 冒号和参数列表后面的缩进块

(2) 大括号分隔块

我们将在本书中使用缩进块,但可以随意尝试这两种样式。

使用 map2,我们现在可以实现 parseInsuranceRateQuote:

def parseInsuranceRateQuote(    age: String,    numberOfSpeedingTickets: String): Option[Double] =  val optAge: Option[Int] = toIntOption(age)  val optTickets: Option[Int] = toIntOption(numberOfSpeedingTickets)  map2(optAge, optTickets)(insuranceRateQuote)                ①

(1) 如果任一解析失败,将立即返回 None。

map2 函数意味着我们永远不需要修改两个参数的任何现有函数来使它们能够识别选项。我们可以在事后将它们提升到在 Option 的上下文中操作。您已经看到如何定义 map3、map4 和 map5 了吗?让我们看看其他几个类似的案例。

练习 4.4

——————————————————————————————

编写一个函数序列,将选项 s 列表合并为一个选项,其中包含原始列表中所有 Some 值的列表。如果原始列表包含 None 甚至一次,则该函数的结果应为 None ;否则,结果应为 一些 ,并列出所有值。这是它的签名:4

def sequence[A](as: List[Option[A]]): Option[List[A]]

有时我们希望使用可能失败的函数映射列表,如果将其应用于列表的任何元素,则返回 None 返回 None 。例如,如果我们有一个完整的字符串值列表,我们希望解析为 Option[Int] 怎么办?在这种情况下,我们可以简单地对映射的结果进行排序:

def parseInts(as: List[String]): Option[List[Int]] =  sequence(as.map(a => toIntOption(s)))

不幸的是,这是低效的,因为它遍历列表两次 - 第一次将这些字符串转换为选项[Int],其次将这些选项[Int]值合并到选项[列表[Int]]中。想要以这种方式对映射的结果进行排序是很常见的,需要一个新的泛型函数 traverse ,具有以下签名:

def traverse[A, B](as: List[A])(f: A => Option[B]): Option[List[B]]

练习 4.5

——————————————————————————————

实现此功能。使用映射和序列很简单,但请尝试仅查看列表一次的更有效的实现。实际上,根据遍历来实现序列。

用于理解

由于提升函数在 Scala 中非常常见,Scala 提供了一种称为理解的语法结构,它会自动扩展到一系列 flatMap 和 map 调用。让我们看看 map2 是如何通过理解来实现的。这是我们的原始版本:

def map2[A, B, C](a: Option[A], b: Option[B])(f: (A, B) => C): Option[C] =  a.flatMap: aa =>    b.map: bb =>      f(aa, bb)

这是为理解而编写的完全相同的代码:

def map2[A, B, C](a: Option[A], b: Option[B])(f: (A, B) => C): Option[C] =  for    aa <- a    bb <- b  yield f(aa, bb)

一个理解由一系列绑定组成,如aa <-a,后跟一个yield,其中yield可以利用任何先前<-绑定左侧的任何值。编译器将绑定脱糖到 flatMap 调用,最终的绑定和 yield 转换为对 map 的调用。5

您应该随意使用理解来代替对flatMap和map的显式调用。同样,如果这样做有助于理解表达式,请随意将理解重写为一系列平面映射调用,然后是最终映射。理解纯粹是一种语法上的便利。

使功能适应选项

在映射、提升、序列、遍历、map2、map3等之间,您永远不必修改任何现有函数来处理可选值。

4.4 任一数据类型

本章的重要思想是,我们可以用普通值表示失败和异常,并编写抽象出常见错误处理和恢复模式的函数。选项并不是我们可用于此目的的唯一数据类型,尽管它经常使用,但它相当简单。您可能已经注意到,Option 不会告诉我们在特殊情况下出了什么问题。它所能做的就是给我们 无 ,表明没有价值可拥有。但有时我们想知道更多。例如,我们可能需要一个提供更多信息的 String,或者如果引发异常,我们可能想知道该错误实际上是什么。

我们可以制作一种数据类型,对我们想要的有关故障的任何信息进行编码。有时,只需知道是否发生故障就足够了,在这种情况下,我们可以使用 选项 ;其他时候,我们需要更多信息。在本节中,我们将介绍 Option 的简单扩展:要么数据类型,这使我们能够跟踪失败的原因。让我们看看它的定义:

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

两者都只有两种情况,就像选项一样。本质区别在于这两种情况都具有价值。Both数据类型以非常笼统的方式表示可以是以下两种情况之一的值。我们可以说它是两种类型的不相交联合。当我们使用它来指示成功或失败时,按照惯例。右构造函数是为成功案例保留的(边的双关语,意思是正确的),左边用于失败。我们给左类型参数起了建议性名称 E(表示错误)。6

让我们再次看一下均值示例 — 这次在失败时返回一个字符串:

import Either.{Left, Right} def mean(xs: Seq[Double]): Either[String, Double] =  if xs.isEmpty then    Left("mean of empty list!")  else    Right(xs.sum / xs.length)

有时,我们可能希望包含有关错误的更多信息,例如,在源代码中显示错误位置的堆栈跟踪。在这种情况下,我们可以简单地在 要么 的左侧返回异常:

import scala.util.control.NonFatal def safeDiv(x: Int, y: Int): Either[Throwable, Int] =  try Right(x / y)  catch case NonFatal(t) => Left(t)   ①

(1) NonFatal 模式匹配确保我们不会捕获致命错误,例如 OutOfMemoryException。

我们可以提取一个更通用的函数, catchNonFatal ,它排除了将抛出的异常转换为值的常见模式:

def catchNonFatal[A](a: => A): Either[Throwable, A] =  try Right(a)  catch case NonFatal(t) => Left(t)

此函数足够通用,可以在任一配套对象上定义,因为它不绑定到单个用例。

练习 4.6

——————————————————————————————

在任一上实现 map 、flatMap 、orElse 和 map2 的版本,这些版本对 Right 值进行操作:

enum Either[+E, +A]:  case Left(value: E)  case Right(value: A)   def map[B](f: A => B): Either[E, B]  def flatMap[EE >: E, B](f: A => Either[EE, B]): Either[EE, B]    ①  def orElse[EE >: E,B >: A](b: => Either[EE, B]): Either[EE, B]   ②  def map2[EE >: E, B, C](that: Either[EE, B])(f: (A, B) => C): Either[EE, C]

(1)当右侧平面映射时,我们必须将左侧类型参数提升为某个超类型以满足+E方差标注。

(2)与orElse类似。

请注意,有了这些定义,Both现在可以用于理解(回想一下,对于理解是调用flatMap、map等的语法糖)。以以下内容为例:

def parseInsuranceRateQuote(    age: String,    numberOfSpeedingTickets: String): Either[Throwable,Double] =  for    a <- Either.catchNonFatal(age.toInt)    tickets <- Either.catchNonFatal(numberOfSpeedingTickes.toInt)  yield insuranceRateQuote(a, tickets)

现在,我们获取有关发生的实际异常的信息,而不仅仅是在发生故障时返回 None。

练习 4.7

——————————————————————————————

实现 序列 和 遍历 的任一 。这些应返回遇到的第一个错误(如果有):

def sequence[E, A](as: List[Either[E, A]]): Either[E, List[A]] def traverse[E, A, B](as: List[A])(                      f: A => Either[E, B]): Either[E, List[B]]

作为最后一个例子,这里是map2的应用程序,其中函数Person.make在构造一个有效的Person之前验证给定的名字和给定的年龄。

示例 4.4 使用任一来验证数据

case class Name private (value: String)           ①object Name:  def apply(name: String): Either[String, Name] =    if name == "" || name == null then Left("Name is empty.")    else Right(new Name(name)) case class Age private (value: Int)object Age:  def apply(age: Int): Either[String, Age] =    if age < 0 then Left("Age is out of range.")    else Right(new Age(age)) case class Person(name: Name, age: Age)object Person:  def make(name: String, age: Int): Either[String, Person] =    Name(name).map2(Age(age))(Person(_, _))

(1) 名称和年龄是具有私有构造函数的案例类,导致只允许在其配套对象中进行构造。每个配套中的 apply 方法在构造值之前验证输入。

4.4.1 累积误差

map2 的实现只能报告一个错误,即使两个参数都无效(即两个参数都是 Left s)。如果我们可以报告这两个错误,那将更有用。例如,当根据姓名和年龄创建人员时,如果姓名和年龄都未通过验证,我们可能希望向程序的用户显示这两个错误。

让我们创建一个类似于 map2 的函数来报告这两个错误。我们需要调整函数的返回类型以返回 List[E] :

def map2Both[E, A, B, C](  a: Either[E, A],  b: Either[E, B],  f: (A, B) => C): Either[List[E], C] =  (a, b) match    case (Right(aa), Right(bb)) => Right(f(aa, bb))    case (Left(e), Right(_)) => Left(List(e))    case (Right(_), Left(e)) => Left(List(e))    case (Left(e1), Left(e2)) => Left(List(e1, e2))

有了这个新函数,我们可以编写一个返回两个错误的 Person.make 替代方案:

object Person:  def makeBoth(name: String, age: Int): Either[List[String], Person] =    map2Both(Name(name), Age(age), Person(_, _))

当两个输入都无效时,我们会在包装在 Left 的列表中返回两个错误:

scala> val p = Person.makeBoth("", -1)val p: Either[List[String], Person] =  Left(List(Name is empty., Age is out of range.))

不幸的是,map2Both的用处相当有限。考虑一下当我们想要合并对 Person.makeBoth 的两个调用的结果时会发生什么:

scala> val p1 = Person.makeBoth("Curry", 34)val p1: Either[List[String], Person] = Right(Person(Name(Curry),Age(34))) scala> val p2 = Person.makeBoth("Howard", 44)val p2: Either[List[String], Person] = Right(Person(Name(Howard),Age(44))) scala> val pair = map2Both(p1, p2, (_, _))val pair: Either[List[List[String]], (Person, Person)] =  Right((Person(Name(Curry),Age(34)),Person(Name(Howard),Age(44))))

这编译得很好,但请仔细查看推断的对类型 — 要么的左侧现在有嵌套列表!每次连续使用 map2Both都会向错误类型添加另一层列表。我们可以通过稍微更改 map2两者 来解决这个问题。我们要求输入值的左侧已经有一个 List[E]。我们称这个新的变体 map2All :

def map2All[E, A, B, C](  a: Either[List[E], A],  b: Either[List[E], B],  f: (A, B) => C): Either[List[E], C] =  (a, b) match    case (Right(aa), Right(bb)) => Right(f(aa, bb))    case (Left(es), Right(_)) => Left(es)    case (Right(_), Left(es)) => Left(es)    case (Left(es1), Left(es2)) => Left(es1 ++ es2)

除了更改 a 和 b 参数的类型外,唯一的其他区别是出现 Left 值的情况。如果有一个 Left 值,我们可以原封不动地返回它(而不是像 map2Both 那样将其包装在单例列表中)。如果两个输入都是 Left 值,我们返回它们用 Left 包装的串联。使用 map2All,我们可以保留正确的类型,同时组合任意数量的 Either 值:

scala> val pair = map2All(p1, p2, (_, _))val pair: Either[List[String], (Person, Person)] =  Right((Person(Name(Curry),Age(81)),Person(Name(Howard),Age(96))))

现在,让我们尝试实现一个返回所有错误的遍历变体。仅更改返回类型会给我们一个如下所示的签名:

def traverseAll[E, A, B](  as: List[A],  f: A => Either[E, B]): Either[List[E], List[B]]

我们可以像以前实现遍历一样实现这一点——使用一个 foldRight,在每个元素上使用 map2 来构建最终列表,但用 map2All 代替 map2 :

def traverseAll[E, A, B](  as: List[A],  f: A => Either[List[E], B]): Either[List[E], List[B]] =  as.foldRight(Right(Nil): Either[List[E], List[B]])((a, acc) =>    map2All(f(a), acc, _ :: _)  )

请注意,我们还将 f 的类型从 A => Both[E, B] 更改为 A => Both[List[E], B]。我们本可以使用原始类型而不是(A => Both[E, B]),并在调用map2All之前转换调用f的结果,但这样做会降低我们从这个定义中构建新函数的能力。例如,使用新签名,根据遍历所有定义序列都很简单;我们可以传递 f 的恒等函数:

def sequenceAll[E, A](  as: List[Either[List[E], A]]): Either[List[E], List[A]] =  traverseAll(as, identity)
4.4.2 提取已验证的类型

要么[List[E],A],以及像map2All,traverseAll和sequenceAll这样的函数,给了我们累积错误的能力。我们可以命名此累积行为,而不是以这种临时方式定义这些相关函数,也就是说,我们可以为这种行为提供自己的类型:

enum Validated[+E, +A]:  case Valid(get: A)  case Invalid(errors: List[E])

Validated[E, A] 的值可以转换为 Askated[E, A],反之亦然。通过引入一个新类型,我们有一个地方来定义 map2All、traverseAll 和 sequenceAll ——事实上,我们不再需要 All 后缀,因为我们可以定义这个 Validate 类型来固有地支持错误的累积:

enum Validated[+E, +A]:  case Valid(get: A)  case Invalid(errors: List[E])   def toEither: Either[List[E], A] =    this match      case Valid(a) => Either.Right(a)      case Invalid(es) => Either.Left(es)   def map[B](f: A => B): Validated[E, B] =    this match      case Valid(a) => Valid(f(a))      case Invalid(es) => Invalid(es)   def map2[EE >: E, B, C](    b: Validated[EE, B])(    f: (A, B) => C  ): Validated[EE, C] =    (this, b) match      case (Valid(aa), Valid(bb)) => Valid(f(aa, bb))      case (Invalid(es), Valid(_)) => Invalid(es)      case (Valid(_), Invalid(es)) => Invalid(es)      case (Invalid(es1), Invalid(es2)) => Invalid(es1 ++ es2) object Validated:  def fromEither[E, A](e: Either[List[E], A]): Validated[E, A] =    e match      case Either.Right(a) => Valid(a)      case Either.Left(es) => Invalid(es)   def traverse[E, A, B](    as: List[A], f: A => Validated[E, B]  ): Validated[E, List[B]] =    as.foldRight(Valid(Nil): Validated[E, List[B]])((a, acc) =>      f(a).map2(acc)(_ :: _))   def sequence[E, A](vs: List[Validated[E, A]]): Validated[E, List[A]] =    traverse(vs, identity)

引入 Validate 是否矫枉过正,因为它并不比使用 [List[E]、A] 和各种 -All 方法更具表现力?一方面,引入新类型意味着有新的东西需要学习,但另一方面,引入新类型为概念命名,然后可以用来引用、讨论并最终内化概念。当我们大量使用组合而导致函数式编程时,我们经常会遇到这种选择。什么时候将某些类型组合在一起的结果值得一个新的类型?7 平衡新名字的认知负荷和组合类型的认知负荷是一种判断。

让我们进一步概括:我们的验证类型累积了一个错误列表[E]。不过,为什么要列出呢?如果我们想使用其他类型怎么办,比如 向量 ?或者需要至少一个条目的集合类型,因为 Invalid(Nil) 没有多大意义?或者也许是具有一些附加结构的类型,例如某种形式的树?事实上,如果我们仔细查看 已验证 的定义 ,只有一个地方依赖于将错误建模为列表:在 map2 的定义中,当我们连接来自两个无效值的错误时。让我们重新定义 Validing,以避免直接依赖 List 进行错误累积:

enum Validated[+E, +A]:  case Valid(get: A)  case Invalid(error: E)    ①

(1) 错误情况现在是单个 E 而不是列表 [E]。

乍一看,我们似乎倒退了一步,将“已验证”定义为“已验证”,就像定义“任一”一样。此版本的“已验证”和“任一”之间的主要区别在于 map2 的签名。特别是,我们需要一种方法将两个无效值组合成一个无效值。在前面的 Validified定义中,Invalid 包装了一个 List[E],我们的组合操作是列表连接。但是有了这个新定义,我们需要一种方法将两个 E 值组合成一个 E 值,而我们对 E 一无所知。看起来我们被卡住了,但我们可以修改 map2 的签名并简单地要求这样的组合操作:

enum Validated[+E, +A]:  case Valid(get: A)  case Invalid(error: E)   def map2[EE >: E, B, C](    b: Validated[EE, B])(    f: (A, B) => C)(    combineErrors: (EE, EE) => EE         ①  ): Validated[EE, C] =    (this, b) match      case (Valid(aa), Valid(bb)) => Valid(f(aa, bb))      case (Invalid(e), Valid(_)) => Invalid(e)      case (Valid(_), Invalid(e)) => Invalid(e)      case (Invalid(e1), Invalid(e2)) =>        Invalid(combineErrors(e1, e2))    ②

(1) 我们要求调用方提供一个将两个错误合并为一个错误的函数。

(2) 在两个输入都无效的情况下,我们使用调用方提供的合并操作来合并错误。

使用此版本的 已验证 ,创建一个 Person 将返回 Validated[List [String], Person] ,并且由于我们使用 List[String] 作为错误类型,因此在调用 map2 时,我们必须将列表连接作为 combineErrors 的值传递。例如,假设 Name 和 Age 也被修改为返回 List[String] 作为错误类型的 Valid,我们可以通过 Name(name).map2 (Age(age))(Person(_, _))(_ ++ _) 创建一个 Person。

因为遍历调用map2,它必须为组合错误传递一个值,但遍历不知道错误类型。因此,我们需要更改遍历的签名,以将 combineErrors 作为参数。通过类似的参数,序列需要一个 combineErrors 参数:

def traverse[E, A, B](  as: List[A], f: A => Validated[E, B],  combineErrors: (E, E) => E): Validated[E, List[B]] =  as.foldRight(Valid(Nil): Validated[E, List[B]])((a, acc) =>    f(a).map2(acc)(_ :: _)(combineErrors)) def sequence[E, A](  vs: List[Validated[E, A]],  combineErrors: (E, E) => E): Validated[E, List[A]] =  traverse(vs, identity, combineErrors)

传递 combineErrors 函数非常不方便,我们将在本书的第 3 部分中看到如何处理此类样板。8 目前,关键思想是 Validated[E, A] 可以与 Any[E, A] 相互转换,唯一的区别是 map2 的行为。9

选项和标准库中的任一

如本章前面所述,Option 和 Both(Option API 位于 ;任 API 位于 ),我们在本章中定义的大多数函数都存在于标准库版本中。建议通读选项和任一 API 以了解差异。不过也有一些缺失的函数——特别是序列、遍历和map2。Typelevel Cats 函数编程库 () 定义了这些缺失的函数。

我们编写的 toIntOption 函数在 String 上有一个等价物,它返回一个标准库 Option[Int]。例如,“asdf”.toIntOption 返回一个 None ,而 “42”.toIntOption 返回一个 Some(42) 。

标准库还定义了数据类型 Try[A](API 位于 ),它基本上等同于 Both[Throwable, A]。试用 API 具有一些专门用于处理异常的操作。例如,Try 同伴对象上的 apply 方法等效于我们之前定义的 catchNonFatal 方法。两者都是尝试,因为 Java 的检查异常是未检查的异常。也就是说,Both让我们跟踪精确的错误类型(例如,Both[NumberFormatException,Int]),而Try跟踪Throwable。

最后,已验证数据类型的常规版本由类型级别 Cats 库提供(API 位于 )。Validation in Cats 的版本使用了一个我们将在第 10 章中介绍的概念,以避免手动将 combineErrors 传递给各种函数的需要。

4.5 结论

在本章中,我们指出了使用异常的一些问题,并介绍了纯函数式错误处理的基本原则。虽然我们专注于代数数据类型 Option 和 Both,但更大的想法是我们可以将异常表示为普通值,并使用高阶函数来封装处理和传播错误的常见模式。这种将效果表示为值的一般想法,我们将在本书中以各种形式反复看到。

我们并不期望你能熟练地使用我们在本章中编写的所有高阶函数,但你现在应该有足够的熟悉程度,可以开始编写自己的函数代码,并完成错误处理。有了这些新工具,应仅保留真正不可恢复的情况的例外情况。

最后,在本章中,我们简要地谈到了非严格函数的概念(回想一下函数 orElse、getOrElse 和 catchNonFatal)。在下一章中,我们将更仔细地研究为什么非严格性很重要,以及它如何在我们的功能程序中为我们带来更大的模块化和效率。

总结引发异常是一种副作用,因为这样做会破坏引用透明度。抛出异常会抑制局部推理,因为程序含义会根据抛出嵌套在哪个 try 块中而变化。异常不是类型安全的;发生错误的可能性不会在函数类型中传达,从而导致未经处理的异常成为运行时错误。我们可以将错误建模为值,而不是异常。我们不将错误值建模为返回代码,而是使用各种 ADT 来描述成功和失败。Option 类型有两个数据构造函数,Some(a) 和 None,用于对成功结果和错误进行建模。未提供有关该错误的详细信息。Both类型有两个数据构造函数,Left(e)和Right(a),用于对错误和成功结果进行建模。“任一”类型类似于 选项,不同之处在于它提供了有关错误的详细信息。Try 类型类似于 要么 ,不同之处在于错误表示为可抛出值而不是任意类型。通过将错误约束为可抛出的子类型,Try 类型能够为抛出异常的代码提供各种方便的操作。验证类型与 要么 类似,只是在组合多个失败的计算时会累积错误。高阶函数,如map和flatMap,让我们处理可能失败的计算,而无需显式处理每个函数调用的错误。这些高阶函数是为各种错误处理数据类型中的每一种定义的。4.6 练习答案

答案 4.1

——————————————————————————————

enum Option[+A]:  case Some(get: A)  case None   def map[B](f: A => B): Option[B] = this match    case None => None    case Some(a) => Some(f(a))   def getOrElse[B>:A](default: => B): B = this match    case None => default    case Some(a) => a   def flatMap[B](f: A => Option[B]): Option[B] =    map(f).getOrElse(None)   def orElse[B>:A](ob: => Option[B]): Option[B] =    map(Some(_)).getOrElse(ob)   def filter(f: A => Boolean): Option[A] =    flatMap(a => if f(a) then Some(a) else None)

map 和 getOrElse 方法都是通过对此进行模式匹配并为每个数据构造函数定义行为来实现的。flatMap 和 orElse 方法都是通过创建嵌套选项,然后使用 getOrElse 删除外层来实现的。最后,过滤器通过flatMap实现,就像我们为List实现它一样。

答案 4.2

——————————————————————————————

def variance(xs: Seq[Double]): Option[Double] =  mean(xs).flatMap(m => mean(xs.map(x => math.pow(x - m, 2))))

我们首先取原始样本的平均值,结果为选项[双倍]。我们在此调用 flatMap,传递一个类型为 Double => Option[Double] 的匿名函数。在这个匿名函数的定义中,我们映射原始样本,将每个元素 x 转换为 math.pow(x - m, 2) ,然后我们取该转换序列的平均值。请注意,内部对均值的调用返回一个 Option[Double],这就是为什么我们需要对外部均值的结果进行平面映射。如果我们改用地图,我们最终会得到一个选项[选项[双倍]]。

答案 4.3

——————————————————————————————

def map2[A, B, C](a: Option[A], b: Option[B])(f: (A, B) => C): Option[C] =  a.flatMap(aa => b.map(bb => f(aa, bb)))

此实现在第一个选项上使用 flatMap,在第二个选项上使用 map。如果我们在这两个选项上使用map,我们最终会得到一个Option[Option[C]],然后我们需要通过getOrElse(None)来减少它,但是map(g).getOrElse(None)是flatMap的定义。或者,我们可以使用模式匹配:

def map2[A, B, C](a: Option[A], b: Option[B])(f: (A, B) => C): Option[C] =  (a, b) match    case (Some(aa), Some(bb)) => Some(f(aa, bb))    case _ => None

答案 4.4

——————————————————————————————

def sequence[A](as: List[Option[A]]): Option[List[A]] =  as.foldRight[Option[List[A]]](Some(Nil))((a, acc) => map2(a, acc)(_ :: _))

我们使用 map2 右折叠选项列表,列表缺点作为我们的组合函数。请注意,我们在此处使用的是 List 的标准库版本,它使用的名称与我们之前创建的数据类型不同。特别是, :: 取代了缺点;h :: t 是第 3 章中 Cons(h, t) 的标准库等价物。

答案 4.5

——————————————————————————————

def traverse[A, B](as: List[A])(f: A => Option[B]): Option[List[B]] =  as.foldRight(Some(Nil): Option[List[B]])((a, acc) =>    map2(f(a), acc)(_ :: _))

我们使用与最初实现序列相同的遍历策略:在列表元素上右折,将 map2 和 :: 作为组合函数。唯一的区别是我们在调用 map2 之前将每个元素应用于 f。为了实现序列,我们将恒等函数传递给遍历,因为我们输入列表中的每个元素都已经是一个选项:

def sequence[A](as: List[Option[A]]): Option[List[A]] =  traverse(as)(a => a)

答案 4.6

——————————————————————————————

enum Either[+E, +A]:  case Left(value: E)  case Right(value: A)   def map[B](f: A => B): Either[E, B] = this match    case Right(value) => Right(f(value))    case Left(value) => Left(value)   def flatMap[EE >: E, B](f: A => Either[EE, B]): Either[EE, B] =    this match      case Right(value) => f(value)      case Left(value) => Left(value)   def orElse[EE >: E,B >: A](b: => Either[EE, B]): Either[EE, B] =    this match      case Right(value) => Right(value)      case Left(_) => b   def map2[EE >: E, B, C](that: Either[EE, B])(    f: (A, B) => C  ): Either[EE, C] =    for      a <- this      b <- that    yield f(a, b)

映射、平面映射和 orElse 操作是使用模式匹配实现的。map2 操作是使用理解实现的,它扩展到 flatMap(a => that.map(b => f(a, b)))。

答案 4.7

——————————————————————————————

我们可以应用我们在排序和遍历选项列表方面的经验,稍微改变定义:

def sequence[E, A](as: List[Either[E, A]]): Either[E, List[A]] =  traverse(as)(x => x) def traverse[E, A, B](as: List[A])(f: A => Either[E, B]): Either[E, List[B]] =  as.foldRight[Either[E, List[B]]](Right(Nil))((a, b) => f(a).map2(b)(_ :: _))

除了类型签名,唯一的变化是我们选择了一个初始累加器:Right(Nil)而不是Some(Nil)。

1 这是一个活跃的研究领域,Scala 3 有一些实验性功能试图解决这个问题。有关详细信息,请参阅 。

2 如果函数不终止某些输入,它也可能是部分函数。我们不会在这里讨论这种形式的偏袒,因为它不是一个可恢复的错误,所以没有如何最好地处理它的问题。有关偏袒的更多信息,请参阅章节注释 ()。

3 在 Scala 2 中,多个参数列表还有另一个好处:更好的类型推断。Scala 2 连续推断每个参数列表上的类型参数。如果 Scala 2 能够在第一个参数列表中推断出具体类型,那么该类型在后续参数列表中的任何出现都将是固定的(即,不会进一步推断或推广)。例如,第 1 章中的 map(List(2, 3, 1), _ + 3) 将无法编译并出现类型推断错误,但是如果我们使用两个参数列表定义 map,导致像 map(List(1, 2, 3))(_ + 1) 这样的用法,编译就会成功。Scala 3 可以同时从所有参数列表中推断类型参数,因此使用多个参数列表不再具有类型推断优势。

4 这是一个明显的实例,其中不适合以 OO 样式定义函数。这不应该是 List 上的方法(它不需要知道任何关于 Option 的信息),它也不能是 Option 上的方法,所以它进入 Option 伴侣对象。

5 For-comprehens支持与我们的目的无关的其他功能,例如通过防护装置过滤和执行副作用。有关更多详细信息,请参阅 。

6 在不值得定义新数据类型的情况下,两者都经常更普遍地用于编码两种可能性之一。我们将在整本书中看到一些这样的例子。

7 甚至只是一个类型名称?在后面的章节中,我们将遇到为组合类型分配名称的不同方法。

8 熟悉类型类的读者可能会认识到,我们可以通过使用第10章中介绍的Monoid[E]或较弱的Semigroup[E](本书未涉及后者)来避免显式传递combineErrors。

9 熟悉一元函数式编程的读者可能会认识到,Validified提供了替代应用函子,以替代任一monad所暗示的函子。

标签: #c语言判断成绩等级if语句 #异常类别有哪三类