第8章 异常与 Option
Scala 用 Option / Either / Try 把“可能失败”写进类型里,异常只留给真正的意外。
本章学习目标
- 会写 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,把出错的数据放进构造参数,例如差多少钱、哪个订单号。
- 调用方用模式匹配捕获具体子类,再决定提示、重试还是上报。
- 自定义异常让“错在哪一步”从消息文本变成类型信息。
代码示例
▶ 自定义异常加分类捕获
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 顺序
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 过滤无效分数
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 的嵌套差别
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 串联年龄校验
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
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 往上交。