第8章 异常与 Option

Scala 用 Option / Either / Try 把“可能失败”写进类型里,异常只留给真正的意外。

健壮性第12-13周3 讲6 段示例代码

本章学习目标

  • 会写 try / catch / finally,并自定义异常
  • 能用 Option 替代 null,用 getOrElse / map / flatMap 安全取值
  • 能说清 Option、Either、Try 各自的适用场景
  • 能改写一段“层层判空”的代码

本章导览

核心内容

8.1

try / catch / finally 与自定义异常

写出可靠的 try/catch/finally,并用自定义异常表达业务错误。

能用 try/catch 捕获可恢复错误,用 finally 做资源清理能按“子类在前、父类在后”的顺序写 catch 分支能定义带业务字段的自定义异常并精确捕获

知识点

try 是表达式,catch 是偏函数

  • try 的返回值就是最后一个表达式的值,可以直接赋给 val。
  • catch 接收的是 PartialFunction[Throwable, T],所以分支一律写成 case e: 类型 =>。
  • 没有被捕获的异常会继续向调用栈上方抛,最后终止程序。

finally 只做清理,不要写 return

  • 不论是否异常、是否 return,finally 都会执行,适合关闭文件、释放连接。
  • 在 finally 里写 return 会覆盖 try 的结果,这是很难排查的坑。
  • Scala 2.13 管理可关闭资源更推荐 Using,第 10 章会正式使用。

自定义异常要携带上下文

  • 继承 Exception,把出错的数据放进构造参数,例如差多少钱、哪个订单号。
  • 调用方用模式匹配捕获具体子类,再决定提示、重试还是上报。
  • 自定义异常让“错在哪一步”从消息文本变成类型信息。

代码示例

▶ 自定义异常加分类捕获
SCALA
class BalanceException(val need: Double)
  extends Exception(s"余额不足,还差 $need 元")

object BankDemo {
  private var balance = 100.0

  def withdraw(amount: Double): Unit = {
    if (amount <= 0) throw new IllegalArgumentException("取款金额必须大于 0")
    if (amount > balance) throw new BalanceException(amount - balance)
    balance -= amount
    println(f"取款 $amount%.1f 元,余额 $balance%.1f 元")
  }

  def main(args: Array[String]): Unit = {
    try withdraw(30)
    catch {
      case e: BalanceException => println(s"业务失败:${e.getMessage}")
      case e: IllegalArgumentException => println(s"参数错误:${e.getMessage}")
    }

    try withdraw(80)
    catch {
      case e: BalanceException => println(s"业务失败:${e.getMessage}")
    }

    try withdraw(-5)
    catch {
      case e: IllegalArgumentException => println(s"参数错误:${e.getMessage}")
    }

    println(f"最终余额:$balance%.1f")
  }
}
👀 运行结果
取款 30.0 元,余额 70.0 元
业务失败:余额不足,还差 10.0 元
参数错误:取款金额必须大于 0
最终余额:70.0
▶ finally 的执行时机与 catch 顺序
SCALA
object FinallyDemo {
  def parse(s: String): Option[Int] =
    try Some(s.toInt)
    catch {
      case _: NumberFormatException => None
    } finally println(s"清理:解析 $s 结束")

  def classify(): String =
    try {
      throw new IllegalArgumentException("坏参数")
    } catch {
      case _: IllegalArgumentException => "捕获 IllegalArgumentException"
      case _: RuntimeException => "捕获 RuntimeException"
    } finally println("finally 执行")

  def main(args: Array[String]): Unit = {
    println(parse("42"))
    println(parse("abc"))
    println(classify())
  }
}
👀 运行结果
清理:解析 42 结束
Some(42)
清理:解析 abc 结束
None
finally 执行
捕获 IllegalArgumentException

常见错误

⚠️ 踩坑与解决
  • catch 把父类写在子类前面
    → 前面的父类会吃掉子类,后面的 case 永远轮不到,编译器还会提示 unreachable。;更具体的子类 case 放前面,父类或 Throwable 只做最后兜底。
  • 在 finally 里写 return 改了返回值
    → finally 在正常和异常路径都会执行,其中的 return 会覆盖 try 的结果。;finally 只做关闭、清理;要返回就让 try 产生结果,再用 val 接住。

💡 先问“调用方能不能处理这个错误”:能处理就用返回值表达,真正的意外才抛异常。

8.2

Option / Some / None:告别 null

用 Option 把“可能没有值”写进类型,替代 null 和层层判空。

能创建 Some、None,并用 getOrElse / map / flatMap 安全取值能解释为什么 Option.get 在 None 上仍然会抛异常能把嵌套判空改写成 flatMap 或 for 推导式

知识点

Option 只有两种取值

  • Option[A] 要么是 Some[A],要么是 None,调用方从类型上就知道值可能缺失。
  • Some(null) 仍然算“有值”,想过滤 null 要用 Option(x),它会把 null 变成 None。
  • null 是 Java 留下的洞,Scala 业务代码尽量让 Option 守住边界。

map 处理有值,getOrElse 提供兜底

  • map 只在 Some 上执行函数,遇到 None 直接短路,不会抛空指针。
  • getOrElse、orElse、fold 都是不抛异常的取值方式。
  • Option.get 只适合“我已经百分百确定是 Some”的场合,None 上会抛 NoSuchElementException。

返回 Option 的函数要用 flatMap 拼

  • 如果映射函数本身返回 Option,map 会得到 Option[Option[B]] 这种嵌套结构。
  • 改用 flatMap,或者写多行 for 推导式,结果会自动拍平。
  • 集合上的 flatMap 还能顺手丢掉 None,只留下 Some 里的值。

把判空挡在系统边界

  • Java API 的返回值先包成 Option,再进入后面的业务逻辑。
  • 业务函数返回 Option,调用方就无法假装空值不存在。
  • 到最后需要展示时才 getOrElse,把“缺失”翻译成页面文案。

代码示例

▶ 用 flatMap 过滤无效分数
SCALA
object OptionBasic {
  def parseScore(s: String): Option[Int] = s.trim.toIntOption

  def main(args: Array[String]): Unit = {
    val raw = List("90", " 82 ", "abc", "75")
    val valid = raw.flatMap(parseScore)
    println(s"有效成绩:$valid")
    println(s"人数:${valid.size}")
    println(s"最高分:${valid.maxOption.getOrElse(0)}")
    println(f"平均分:${valid.sum.toDouble / valid.size}%.1f")
    println(parseScore("abc").getOrElse(-1))
    println(parseScore("66").map(_ + 4).filter(_ >= 70))
    println(parseScore("abc").map(_ + 4))
  }
}
👀 运行结果
有效成绩:List(90, 82, 75)
人数:3
最高分:90
平均分:82.3
-1
Some(70)
None
▶ map 与 flatMap 的嵌套差别
SCALA
object OptionChain {
  def toInt(s: String): Option[Int] = s.toIntOption
  def reciprocal(n: Int): Option[Double] =
    if (n == 0) None else Some(1.0 / n)

  def main(args: Array[String]): Unit = {
    val nested = toInt("4").map(reciprocal)
    val flat = toInt("4").flatMap(reciprocal)
    println(s"map 结果:$nested")
    println(s"flatMap 结果:$flat")

    val combo = for {
      n <- toInt("5")
      r <- reciprocal(n)
    } yield r * 2
    println(s"for 推导式:$combo")

    println(toInt("abc").flatMap(reciprocal))
    println(toInt("0").flatMap(reciprocal).getOrElse(0.0))
  }
}
👀 运行结果
map 结果:Some(Some(0.25))
flatMap 结果:Some(0.25)
for 推导式:Some(0.4)
None
0.0

常见错误

⚠️ 踩坑与解决
  • 对 None 调 get,程序抛 NoSuchElementException
    → get 假定一定是 Some,不做任何兜底;None.get 会抛 java.util.NoSuchElementException: None.get。;用 getOrElse、fold 或模式匹配消费 Option,把 get 留给测试或确定有值的局部。
  • map 里返回 Option,得到 Option[Option[T]]
    → map 只负责把值映射成另一种值,不会自动把两层容器拍平。;把 map 改成 flatMap,或写 for { x <- ...; y <- ... } yield ...。

💡 把 Option 当成“最多装一个元素的容器”:只用 map、flatMap、getOrElse,代码会自然少掉一半判空。

8.3

Either 与 Try:错误处理的三种选型

分清 Option、Either、Try 的适用场景,把失败写进返回类型。

能根据“缺失、业务失败、异常”选择 Option / Either / Try能用 Either 的 for 推导式串联校验并保留错误原因能用 Try 捕获异常并转换成 Either 或默认值

知识点

Either 能携带失败原因

  • Either[L, R] 二选一,通常 Left 放错误、Right 放成功,称为 right-biased。
  • Scala 2.13 的 Either 支持 map / flatMap,可以直接写 for 推导式。
  • 多个校验串联时遇到 Left 就短路,最先发生的错误会原样返回。

Try 是异常的容器

  • Try[A] 只有 Success[A] 和 Failure 两种形态,专门包住可能抛异常的表达式。
  • 适合接住 toInt、第三方 SDK 调用这类会直接抛异常的操作。
  • 可以用 recover、getOrElse、toEither 把 Try 转成更适合上层的类型。

三种类型怎么选

  • 只想表达“有没有值”用 Option;需要说明“为什么失败”用 Either。
  • 要接住别人抛出的异常,用 Try 在边界包一层,再转成 Either 交给业务。
  • 业务校验优先返回 Either,别把可预期的失败做成异常。

Scala 3 的差异(了解即可)

  • Scala 3 可以用 enum 定义 Ok / Err 这样的封闭结果类型,表达力更强。
  • 本课代码与作业统一按 Scala 2.13 提交,不要混用 enum 与 sealed trait。

代码示例

▶ Either 串联年龄校验
SCALA
object EitherValidation {
  def parseAge(s: String): Either[String, Int] =
    s.toIntOption.toRight("年龄不是整数")

  def check(age: Int): Either[String, Int] =
    if (age < 0) Left("年龄不能为负")
    else if (age > 150) Left("年龄不合理")
    else Right(age)

  def register(name: String, rawAge: String): Either[String, String] =
    for {
      age <- parseAge(rawAge)
      valid <- check(age)
    } yield s"$name 报名成功,年龄 $valid"

  def main(args: Array[String]): Unit = {
    println(register("张三", "20"))
    println(register("李四", "abc"))
    println(register("王五", "-3"))
    println(register("赵六", "200"))

    register("张三", "20") match {
      case Right(msg) => println(s"OK: $msg")
      case Left(err) => println(s"ERR: $err")
    }
  }
}
👀 运行结果
Right(张三 报名成功,年龄 20)
Left(年龄不是整数)
Left(年龄不能为负)
Left(年龄不合理)
OK: 张三 报名成功,年龄 20
▶ Try 接住异常并转成 Either
SCALA
import scala.util.{Failure, Success, Try}

object TryDemo {
  def readInt(s: String): Try[Int] = Try(s.trim.toInt)

  def describe(s: String): String =
    readInt(s) match {
      case Success(n) => s"成功:$n"
      case Failure(e) => s"失败:${e.getClass.getSimpleName}"
    }

  def main(args: Array[String]): Unit = {
    println(describe("42"))
    println(describe("abc"))
    println(readInt("7").map(_ * 2).getOrElse(0))
    println(readInt("abc").recover { case _: NumberFormatException => -1 }.get)

    val e: Either[String, Int] =
      readInt("abc").toEither.left.map(_ => "输入不是整数")
    println(e)
    println(readInt("9").toOption)
  }
}
👀 运行结果
成功:42
失败:NumberFormatException
14
-1
Left(输入不是整数)
Some(9)

常见错误

⚠️ 踩坑与解决
  • Try 接到 Failure 后直接 get
    → Failure.get 会把原来的异常重新扔出来,等于白包了一层 Try。;用 getOrElse、recover 或模式匹配消费 Try,只在确定 Success 的测试里 get。
  • for 推导式里混用 Option 和 Either
    → 两者是不同的容器,flatMap 的类型对不上,编译期直接报 type mismatch。;先统一类型,例如用 option.toRight(...) 转成 Either,或用 tryResult.toEither。

💡 能用返回值说清的错误别抛异常;真异常在边界用 Try 接住,再转成 Either 往上交。