Scala 09:Option、Either 与 Try,给失败保留含义
同一个 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 | |
对于调用方来说,这份结果只表示解析没有产生整数。如果只需要过滤能够解析的数量,这可能足够;如果要提示用户修正输入,None 的信息就太少。Option 最适合“无值是接口内预期情况,并且调用方不需要进一步原因”的场景,例如查询可选优惠券是否存在。
Some 包裹一个值,None 表示没有值。这里的 Some 并不自动保证值非空引用:Some(null) 仍是一个 Some,而 Option(null) 会转换成 None。将 Java 返回值包进 Option 能处理一个特定的 null 边界,但不能保证容器内部深层字段都合法,更不能让任意对象变成已验证的订单。
消费 Option 时,map 只变换存在的值,flatMap 接受可能继续缺失的变换,fold 让两种情况都返回同一结果类型。getOrElse 可以提供默认值,但默认本身是业务决定。例如把缺失数量默认为零,可能把输入错误伪装成合法的空订单;若零本来就不合法,这个默认值只会把错误延迟到别处。
直接调用 get 把缺失情况交给异常处理,会让返回类型失去原本想表达的完整性。与其先声称结果可缺失,再假设永远存在,不如在边界显式分支。断言里的存在性检查也不能代替生产接口对 None 的处理;断言只是验证选定输入的测试工具。
Either 让拒绝成为可检查的数据
1 | |
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 | |
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。
- Option 2.13.16 API:核对缺失、map、flatMap 和转换。
- Either 2.13.16 API:核对右偏组合及错误参数。
- Try 2.13.16 API:核对异常边界及 foreach 限制。
- NonFatal 2.13.16 API:核对排除类别。容器之间的信息损失和订单错误划分由本章示例独立推导。
前置阅读:模式匹配与提取器。
