同一个 bad,为什么会得到三种失败

订单数量字段收到字符串 “bad”。转换成整数当然失败,但业务接口究竟应返回“没有值”、“数量格式不合法”,还是一个 NumberFormatException?这不是三个容器之间的语法偏好,而是调用方需要获得什么信息,以及失败发生在哪个边界。

Option[Int] 只有存在与缺失两种情况;Either[Rejection, Int] 可以保存结构化业务原因;Try[Int] 保存成功结果或被捕获的异常。三个类型都能参与 map、flatMap 组合,不代表它们表达相同契约。过早互转还可能不可逆地删除信息:把任何 Left 变成 None 后,调用方无法恢复它原来是缺失、格式错误还是非正数。

本章固定 Scala 3.3.7、标准库 2.13.16 和 JDK 21,使用同一数量解析过程比较三个选择。实验不使用 get 作为正常消费方式,也不把异常捕获当作程序正确性的替代。类型保证成功与失败的形状,具体哪类失败应被翻译成哪种业务结果,仍需要接口设计。

Option 表达没有值,不替缺失说明原因

1
2
val parsed: Option[Int] = "bad".toIntOption
assert(parsed.isEmpty)

对于调用方来说,这份结果只表示解析没有产生整数。如果只需要过滤能够解析的数量,这可能足够;如果要提示用户修正输入,None 的信息就太少。Option 最适合“无值是接口内预期情况,并且调用方不需要进一步原因”的场景,例如查询可选优惠券是否存在。

Some 包裹一个值,None 表示没有值。这里的 Some 并不自动保证值非空引用:Some(null) 仍是一个 Some,而 Option(null) 会转换成 None。将 Java 返回值包进 Option 能处理一个特定的 null 边界,但不能保证容器内部深层字段都合法,更不能让任意对象变成已验证的订单。

消费 Option 时,map 只变换存在的值,flatMap 接受可能继续缺失的变换,fold 让两种情况都返回同一结果类型。getOrElse 可以提供默认值,但默认本身是业务决定。例如把缺失数量默认为零,可能把输入错误伪装成合法的空订单;若零本来就不合法,这个默认值只会把错误延迟到别处。

直接调用 get 把缺失情况交给异常处理,会让返回类型失去原本想表达的完整性。与其先声称结果可缺失,再假设永远存在,不如在边界显式分支。断言里的存在性检查也不能代替生产接口对 None 的处理;断言只是验证选定输入的测试工具。

Either 让拒绝成为可检查的数据

1
2
3
4
5
6
7
8
9
10
enum Rejection:
case Missing, Malformed, NonPositive

def positive(raw: String): Either[Rejection, Int] =
raw.toIntOption.toRight(Rejection.Malformed).flatMap { n =>
if n > 0 then Right(n) else Left(Rejection.NonPositive)
}

def validate(raw: Option[String]): Either[Rejection, Int] =
raw.toRight(Rejection.Missing).flatMap(positive)

validate 先判断字段是否存在。有字符串才进入 positive;positive 先解析整数,解析成功才检查是否大于零。数据依赖决定了顺序:一个没有成功解析的输入,不存在可供范围判断的整数。返回 Missing、Malformed、NonPositive 并不是把三个独立判断任意排成列表,而是描述三个不同阶段能观察到的失败。

Either 的两个类型参数分别限定失败和成功值。Either[Rejection, Int] 不允许任意整数出现在错误通道中;编译反例另用 Either[String, Int] 接收 Left(42),验证错误参数同样受类型检查。Left 与 Right 的含义由接口约定,不过 Scala 标准库的组合习惯明确偏向 Right。

“右偏”体现在 map、flatMap 等操作继续处理 Right 中的值,Left 则沿着链原样传递。对 positive(“bad”) 调用 flatMap,后续金额计算不会执行。正常程序把该回调中的计数器初始设为零,组合之后仍断言零,直接观察短路,而不是从方法名称猜测执行时机。

这个性质既有用也有限。它保证这个组合点不会调用成功回调,不保证回调创建之前的表达式没有副作用。如果先执行远程请求并把结果保存在 val 中,再把读取该 val 的函数传给 flatMap,请求早已发生。错误短路控制的是组合所负责的求值范围,不能撤销已经完成的工作。

map 与 flatMap 的区别是结果形状

假设成功值是数量 2,单价固定为一百分。map 接受 Int => Long,可以直接把数量变成金额。如果下一步还可能因为超过库存而拒绝,则回调返回 Either[Rejection, Int],应该使用 flatMap 保持一层 Either。

如果把可能失败的回调用 map 包起来,类型会变成 Either[Rejection, Either[Rejection, Int]]。外层 Right 只说明第一步成功,内层仍可能是 Left。嵌套并非编译错误,有时还具有明确含义,但对顺序校验通常不是想要的接口。flatten 或 flatMap 消除的是已选择的同类组合层次,并不自动合并任意不同类型的效果。

Option 与 Either 之间需要明确转换点。toRight 在缺失时构造一个指定错误;toOption 则舍弃 Left 的内容。Try 转 Either 通常保留 Throwable 为错误值,转 Option 通常丢弃异常原因。每次转换都应问:调用方还有机会区分原来的失败吗?如果没有,这是否正是接口承诺?

同理,Either[Throwable, A] 与 Try[A] 不能因为外观相似就互换所有行为。前者本身不会在普通 map 回调抛异常时自动执行 Try 的捕获协议。错误通道保存 Throwable 和某个组合器负责捕获异常,是两件不同的事。对接第三方接口时应明确捕获发生在哪一行,而不是只看最终类型。

Try 在明确的异常边界处使用

1
2
3
4
5
import scala.util.Try

val attempted = Try("bad".toInt)
assert(attempted.failed.toOption
.exists(_.isInstanceOf[NumberFormatException]))

Try.apply 接收按名表达式,在执行时把符合 NonFatal 条件的异常封装进 Failure,成功则得到 Success。表达式在构造这个 Try 时执行,Try 本身不是通用的延迟任务描述。把它存入 val 后反复读取,不会自动重新解析,也不会自动重试失败的网络请求。

标准库 Try 的 map、flatMap 对其负责调用的函数有异常处理行为,但不能把这一点扩成“Try 相关的所有方法都会捕获所有异常”。尤其 foreach 的回调异常会传播,非致命异常边界也不能捕获所有 Throwable。具体组合器应按冻结 API 与源码检查,而不是套用一个过宽的口号。

NonFatal 排除了需要特殊对待的异常类别,包含中断相关异常等。实验使用 InterruptedException 验证 Try.apply 没把它包装成普通 Failure:外围的显式 catch 接收到该异常并置位标记。这里只验证协议分类,不宣称测试了线程取消或中断标记恢复;抛一个 InterruptedException 对象与真实线程被中断是不同场景。

捕获边界还会决定堆栈和原因是否保留。正常程序创建一个 IllegalArgumentException 对象,让 Try 捕获它,再用引用身份检查 Failure 中保存的就是该对象。把它转换成 Left(“failed”) 虽然类型更简单,却丢失了诊断所需的异常对象。业务响应可以使用稳定错误码,内部诊断仍应保留原始原因,两者不必共用同一种展示格式。

区分可预期拒绝和程序缺陷

库存不足是业务拒绝,字符串格式错误是外部输入问题,代码意外访问越界则可能是实现缺陷。把三者全部封装成 None,会使接口看似容易调用,却让恢复策略失去依据。反过来,把每次字段不合法都抛成异常,也可能让正常校验路径难以组合和统计。

一种具体分层是:外部字符串先解析为 Either[InputError, Quantity];业务计算返回 Either[DomainError, Receipt];调用会抛异常的外部 API 时,在适当边界用 Try 或明确 catch 翻译成 TransportError。这个分层不要求所有项目采用同一错误层级,但要求原因在跨层时被有意识地转换。

不应该把“未预见的异常”一律映射成“用户参数有误”。这样会把程序缺陷归因给用户,也容易掩盖需要修复的问题。若选择把异常变成业务层可恢复错误,应列出允许翻译的类别,并让其他异常继续进入统一故障处理。Try 的存在让异常成为值,不代表每个异常都适合重试或继续运行。

错误对象本身也可以包含敏感内容。保留 Throwable 有助于内部诊断,不代表可以把完整堆栈或外部接口响应直接回给客户端。容器解决的是程序里的表示,日志和对外协议还有独立的信息边界。本例只使用固定测试字符串,未覆盖真实输入脱敏。

验证失败保留与短路,而不只验证成功值

入口 scalaexamples.Chapter09 对同一个 “bad” 分别验证 None、Left(Malformed) 与 Failure(NumberFormatException)。它另测缺失输入、零数量、正数量,保证不同错误没有被统一压成同一分支。后续金额回调的执行次数为零,说明 Left 保留并短路。

失败案例也不只存在于编译阶段。正常程序有意触发业务拒绝和中断逃逸,再用断言判断;编译目录则用错误通道类型不匹配检验静态界限。运行时边界与编译时边界必须分别留证据,不能用一个类型错误替代真实异常行为。

这些断言没有证明所有整数范围的业务正确性。例如数量乘单价是否溢出、超大输入是否应给单独错误、空白字符是否允许,都需要进一步定义。最小实验的作用是让每种容器的行为可以被观察;进入真实订单处理时,应在同样的错误模型下增加领域边界。

默认值与恢复必须具有业务依据

考虑报价读取失败之后返回零元的实现。它能够把失败变成成功类型,也可能让后续计算顺利通过,但类型系统并不知道零元是否意味着免费、尚未报价还是不可售。恢复操作只有在替代结果符合业务契约时才成立。否则,容器层面的成功只是在隐藏原来的失败。为了避免这种误用,可以让未报价和已报价使用不同分支,只有已报价结果允许进入扣款。

重试也不是对 Failure 无条件再执行一次。数量字符串无法解析,重复解析通常不会改变结果;远程服务短暂不可用可能可以重试,但提交付款这类操作还涉及重复副作用。Try 保存的是一次尝试的结果,既不持有通用的重试策略,也不证明重复执行安全。若需要重试,应保留一个能够重新执行的操作描述,明确次数、错误分类和幂等前提,并对调用次数另行断言。

这些选择可以通过一个审阅问题暴露出来:失败被转换之后,接收者是否仍能采取正确行动?若缺失字段被默认为零,接收者可能继续创建空订单;若超时被当作付款失败,接收者可能再次扣款;若所有异常都变成通用字符串,维护者可能失去定位线索。信息删减应服务于明确接口,而不能只为了缩短类型签名。

手算与修改练习

手算:validate(Some(“0”)).map(_ * 100) 的结果是什么,乘法执行几次?答案是 Left(NonPositive),乘法零次。若将 map 中的乘法提前放到表达式外执行,再把结果作为常量传入回调,外部表达式就不受这次短路保护。

修改练习:增加 TooLarge 错误,只接受一到一百的数量。参考实现先保留 toIntOption 的 Malformed,再在成功回调中依次判断非正数和大于一百。新增 “101”、“100”、“-1”、“bad” 的断言,确认三类失败不混淆。随后把错误展示函数写成对 Rejection 的完整匹配;加新分支时同时检查展示函数的穷尽诊断。

另一个可执行修改是对原始异常做稳定错误码映射,同时把异常对象保存为可选 cause 字段。断言应既检查外部错误码,也检查 cause 的身份。只断言错误码相等,无法证明排障原因没有丢失;只断言 cause 存在,也无法证明对外接口没有泄露内部实现。

实验记录与依据

源文件为 examples/scala-lab/snippets/09/Chapter09.scala,隔离反例为 negative/09/wrong-error-channel/。实际命令与输出见同名素材目录的 RUN.md。

前置阅读:模式匹配与提取器。

顺序导航:系列入口:00 · 上一篇:08 · 下一篇:10。