导入失败是否应该成为一个值

商品数量解析可能成功,也可能遇到非法文本。普通方法可以抛异常,也可以返回包含错误信息的结果。Vavr 的 Try 把许多异常转换为 Failure,Option 表达有值或无值,不可变集合则让更新生成新结构。它们分别改变错误传播、值表示与更新方式,不能只用“函数式写法”概括全部行为。

本章固定 Vavr 0.10.7,而非追随默认分支。官方 v0.10.7 对应源码提交 3af14df99c6a99ecd984e516d5f5294335cfbe84,POM 的 Java 目标为 1.8;实验在 Zulu 8u472 和 Corretto 21.0.11 执行。这里选择的是与本系列 Java 8 基线相容的版本,不声称它是当前最新版本。固定 POM

需要研究的重点是转换损失:Some(null) 转成 JDK Optional 后会怎样,Failure 转成 Option 是否还保留原因,旧集合与新集合是否共享元素。编译器能检查类型,却不会替接口设计者决定哪些状态可以被合并。

Option.map 不自动消除 null

JDK Optional.of(“sku”).map 返回 null 时,结果是 Optional.empty。Vavr Option.of(“sku”).map 返回 null 时,得到的是有定义的 Some(null):isDefined 为 true,get 返回 null。两者语法相似,结果集合却不同,迁移时不能直接替换类型名。Vavr 用户指南

固定源码中的 map 对非空 Option 调用 some(mapper.apply(get())),并未再次经过 Option.of。of(null) 则产生 None。实验分别断言这两条构造路径,避免把“Option.of 不保存 null”错误推广为“Option 内永远没有 null”。Option 源码

如果业务约定映射得到 null 就代表缺失,可以通过 flatMap(Option::of) 规范化结果。若业务区分“存在但值是 null”与“不存在”,则必须保留这两个状态,不能随意进行这种规范化。关键不是选择哪种写法更短,而是确定输入输出的状态空间。

实验再把 Some(null) 调用 toJavaOptional,得到 JDK 的 empty,随后适配到 Guava Optional 得到 absent。固定 Value 源码使用 Optional.ofNullable(get()),因此转换会合并 Some(null) 与 None;它不是可逆的表示转换。Value.toJavaOptional

输入状态 Vavr是否有定义 转成JDK Optional 再转Guava Optional
Some(“sku”) 是 非空 present
Some(null) 是 empty absent
None 否 empty absent

表中的 null 行是本章重点实测。跨库适配通常放在边界方法中更容易审查:先声明是否允许 null,再执行转换,把有损规则集中在一个位置。若不同调用点各自转换,很容易出现一部分保留状态、一部分合并状态的差异。

Try 保存异常原因,不替代错误处理

Try.of 调用 Integer.parseInt(“bad”),得到 Failure,原因是 NumberFormatException。对指定异常类型 recover 为 0,结果才变成 Success(0)。这表示应用主动决定把非法文本当作零,不是 Try 自动修复输入。库存导入若不允许这种策略,就应保留错误并向上返回。

Failure.toOption 变成 None,异常原因不再存在于返回的 Option 中。它适合只关心有没有结果的边界,不适合要求逐行错误报告的导入接口。把所有失败都转成 None,会让“字段为空”“格式错误”“外部读取失败”失去区别;日志系统也不会因为类型转换而自动收到错误。

以下独立 Java 8 示例保留失败分支,并只对一种预期解析错误作明确恢复。示例输出不是生产默认策略,真实接口应按商品数量规则决定是否允许默认值。

1
2
3
4
5
6
7
8
9
10
11
import io.vavr.control.Try;

public final class ParsedQuantity {
public static void main(String[] args) {
Try<Integer> parsed = Try.of(() -> Integer.parseInt("bad"));
if (!parsed.isFailure()) { throw new AssertionError(); }
String reason = parsed.getCause().getClass().getSimpleName();
int fallback = parsed.recover(NumberFormatException.class, ex -> 0).get();
System.out.println("reason=" + reason + ", explicitFallback=" + fallback);
}
}

结果类型让失败能够沿 map、recover 等操作继续传递,但若调用方丢弃整个 Try,错误仍然可能无人处理。可以在应用入口统一检查结果、转换成稳定的领域错误码并记录必要上下文。若外部接口直接暴露 Throwable,则还要考虑具体异常实现是否应成为接口契约的一部分。

并不是所有 Throwable 都进入 Failure

Try 的 Failure 构造路径调用 isFatal。此版本将 InterruptedException、LinkageError、ThreadDeath 与 VirtualMachineError 归入直接重新抛出的类别。普通解析异常进入 Failure;合成的 AssertionError 也进入 Failure。因而“Error 总被重抛”和“所有异常都成为值”都不正确。Try 及 TryModule 源码

实验直接创建一个普通 LinkageError 对象并抛出,确认同一个实例逃出 Try.of;又创建 InterruptedException,确认它也逃出。测试没有破坏类加载器、耗尽内存或停止线程,只验证类型分类分支。不能把合成异常实验解释成对实际 JVM 灾难恢复能力的测试。

官方 issue 2657 对 InterruptedException 被归为 fatal 提出讨论。这是反向核验的重要线索:库中的 fatal 是实现分类,不等同于业务上不可恢复。本文以固定源码和实验为准,没有把问题讨论中的修改诉求当成已经落地的行为。官方问题 2657

中断通常承担取消与协作退出的信号。把阻塞代码包进 Try 之前,应检查是否依赖捕获 InterruptedException 后恢复中断标记、关闭资源或退出循环。这里的合成异常没有设置线程中断标记,实验仅证明异常传播,不证明实际阻塞操作或线程中断状态的完整处理。

结构共享保留旧版本,但元素仍可能变化

Vavr List 的 prepend 创建一个新 Cons,头部保存新元素,尾部指向原 List。实验令 old 只包含一个 StringBuilder,next 为 old.prepend(new StringBuilder(“new”))。next.tail 与 old 是同一对象,old 的长度仍为一,next 的长度为二。这是具体可观察的结构共享,不是靠“不可变”标签推断。List.prepend

随后把原 StringBuilder 追加感叹号,old.head 与 next.tail.head 都读到 old!。容器链接未变,元素对象却被共同引用。需要稳定商品快照时,元素也应采用不可变值,或在明确边界复制领域对象。结构共享不能免除深层对象的别名分析。

prepend 的共享路径也不能推广为所有操作都只分配一个节点。列表位置访问、末尾追加与其他集合类型有各自复杂度。选择持久化结构的理由可以是保留多个版本、便于分支计算,但不能据这个两元素实验声称它比 ArrayList 更快或更省内存。

在批量校验中,可以让每一阶段返回新的错误集合,把前一阶段结果保留用于诊断。若每个阶段都把集合立即转换回可变 JDK List,再在下一阶段重建 Vavr List,转换成本和类型复杂度可能抵消结构共享带来的好处。应让共享结构在一个明确模块内部连续使用,在公开边界进行一次适配。

Lazy 延迟到第一次取值,而非自动异步

实验用 AtomicInteger 作为 supplier 调用计数。创建 Lazy 时计数为零,第一次 get 后为一,第二次 get 仍为一。它把成功计算结果保存下来,后续访问复用结果。源码的 computeValue 在锁内调用 supplier,成功赋值后将 supplier 清空,因此不会创建后台任务。Lazy 源码

把远程读取包进 Lazy,并不会消除首次读取的阻塞,只是把它移动到第一次 get 的线程。资源、超时和取消仍需原有设计。缓存后的值也不会自动随外部商品数据更新,因此延迟计算适合什么生命周期,需要由持有 Lazy 的组件决定。

本次只测成功值的缓存。源码显示,如果 supplier.get 抛异常,清空 supplier 的语句不会执行;不应把当前成功实验泛化成失败也被缓存。需要缓存失败时,可以明确使用 Lazy<Try>,让可捕获的失败成为正常返回值,但 fatal 分类仍然适用。这个组合属于可进一步验证的设计,本文没有将其标成实测。

类型收益与依赖传播一起计算

若公开接口直接返回 Option、Try 和 io.vavr.collection.List,就有三种 Vavr 类型进入调用方编译契约。将其限制在实现模块,再分别适配为 Optional、领域结果对象与 JDK List,公开接口中的 Vavr 类型数量可降为零;代价是需要定义有损转换和复制规则。

这是接口类型数量的静态比较,不是运行时内存测量。依赖也不会因为公开接口改成 JDK 类型而自动从实现模块消失,部署仍需包含实际依赖。本章固定坐标 io.vavr:vavr:0.10.7;其发布 POM 还声明普通依赖 vavr-match:0.10.7,匹配处理器则标为 optional。实验的真实测试 classpath 包含前两个 JAR。一个直接坐标因此不等于只有一个运行包;引入前应检查现有版本与调用方是否已经使用其他版本。

适配层需要决定错误的稳定表示

若模块内部返回 Try,外部 HTTP 接口需要一个字段错误,适配层可以将成功值转换为数量,将 NumberFormatException 转换为稳定的格式错误码。未预期异常应进入另外的错误处理路径,而不应和格式错误都合并成零。这样外部调用方不必依赖 Vavr,也不必依赖某个 JDK 异常类名作为长期协议。

这种适配并不要求每一层都重复包装。可以在解析与校验集中完成的模块内部连续使用 Try,到应用边界再转换一次。若每次调用后立刻 get,再在上层重新 Try.of,值式错误传播已经被反复转换回抛异常,代码会同时承担两套习惯的复杂度。应选择一个明确范围,让成功与失败路径在这个范围内保持一致。

恢复函数也需要可观察。批量解析一百条记录,十条错误都 recover 为零,最终得到一百个成功整数并不代表原始输入全部有效。应在恢复前保留错误数量和来源,或者使用包含原始错误的领域结果。与前面的 Option 状态合并相同,恢复会主动改变信息,信息一旦丢弃就不能依靠后续集合操作自动找回。

对不可变集合的适配也应区分复制容器与复制元素。生成 JDK List 可以改变容器接口,但同一可变商品对象仍可能被两边共享。若外部接口允许修改元素,而内部需要保留历史版本,就应输出独立的不可变数据对象;仅仅把 List 类型换成 JDK 类型,既没有解决别名,也没有定义修改所有权。

最后,依赖版本验证应覆盖实际使用的语义,而不只检查构建成功。本章可以作为升级回归的最小矩阵:null映射与适配、普通和fatal失败、共享尾部与可变元素、成功惰性值的执行次数。新版本若改变其中任一项,调用方应先判断是否影响既有领域契约,再决定如何迁移。

适合引入的情形是一个模块内部确实需要组合错误值、保留不可变版本或清晰表达延迟求值。若只有一个可空返回值,JDK Optional 已能表达所需状态。应根据业务契约与团队维护成本选择,不必为了统一风格把每个集合和返回值都换成第三方类型。

完整测试的三个场景在 Java 8、21 均通过,复现说明列出固定版本与运行入口。证据区分 DOC、SOURCE、LAB 与未执行范围,不把官方指南中的全部能力视为已验证。

手算题:Some(null) 转成 JDK Optional 后再转回 Vavr,会恢复 Some(null) 吗?不会,空状态已经与 None 合并。改动练习:为导入结果定义明确的 Missing、Invalid 和 Valid 三种领域状态,再检查哪些状态可以安全适配为 Optional,哪些必须保留错误信息。

可迁移做法 适用边界
对跨库转换列出状态合并表 Optional迁移、失败结果适配
分别检查容器结构与元素可变性 历史快照、分支计算、共享列表
把惰性值生命周期与执行线程写清楚 延迟配置、计算缓存、资源访问