订单第七行的数量是 bad。返回 empty 可以阻止继续报价,却不能告诉导入界面哪个字段出了问题。若数量能解析为四,但模拟库存只有三,又是另一种失败:输入格式正确,业务拒绝本次请求。两者都与代码内部意外抛出的 IllegalStateException 不同。

本章用 Java 21 手写 Result<E,A>,把成功值与结构化错误分开,并演示短路组合。异常只在字符串解析这个狭窄边界被翻译,业务回调中的程序缺陷继续向外传播。这里的 Result 名字是教学选择;Scala Either 常用 Left 表示错误、Right 表示成功,表达的两分支结构相同,但具体库方法仍应按契约使用。

系列导读与能力自测

错误需要保留哪些信息

错误类型取决于消费它的程序。导入界面至少需要行号、字段名与稳定代码;库存拒绝则需要请求量与可用量。把所有信息塞进一条字符串,展示容易,程序化处理却会依赖自然语言格式。

1
2
3
sealed interface Problem permits InputError, Rejection {}
record InputError(int row, String field, String code) implements Problem {}
record Rejection(String code, int requested, int available) implements Problem {}

InputError 与 Rejection 构成和类型,各自携带不同的积数据。输入错误能够被定位到第七行 quantity 字段,库存拒绝能够说明四大于三。这个模型没有记录原始输入文本,既减少了不必要的数据保留,也避免展示层为了获得字段名去解析异常消息。

代码应稳定于语言文案。malformed 可以翻译为不同语言,但仍然是同一错误类别;如果把中文提示直接当作业务分支键,文案修改就可能改变程序控制流。错误值可以同时拥有机器代码与展示参数,真正的本地化留在界面边界完成。

类型结构不自动保证行号为正数、字段名来自合法集合。本章的构造位置固定这些值,实验只证明当前路径保留它们。扩大为公开库时,可以继续收窄行号类型与字段枚举;没有必要在最小示例里提前建立覆盖所有业务的错误注册中心。

Result 的成功与错误分支

1
2
3
4
5
6
7
sealed interface Result<E,A> permits Ok, Err { /* map 与 flatMap 见源码 */ }
record Ok<E,A>(A value) implements Result<E,A> {
Ok { Objects.requireNonNull(value); }
}
record Err<E,A>(E error) implements Result<E,A> {
Err { Objects.requireNonNull(error); }
}

Ok 保存 A,Err 保存 E,两者都不允许内部 null。Err 仍带着成功类型 A,因为同一条计算链需要知道成功后本该产生什么。泛型参数不表示错误分支隐式存了一份默认 A;它保存的是类型关系。

这里没有同时保存成功值和错误。若批量导入需要“部分行成功,同时有失败列表”,应设计 BatchResult(successes, errors) 这样的积,或选用表达部分成功的其他结构。单次 Result 的二选一契约不能承担所有批次语义。

同样,成功空值需要合理编码。一个没有业务返回数据的操作可以返回明确的单位值,不能因为“只是通知一下”就在 Ok 中放 null。允许 null 会让成功、缺席与接口违约重新混在一起,也削弱模式匹配后的局部保证。

在解析点翻译预期异常

数量解析把缺失、格式错误与非正数分开。先检查 null;再只围绕 Integer.parseInt 捕获 NumberFormatException;有整数后才检查正数范围。

1
2
3
4
5
6
7
8
9
10
static Result<Problem,Integer> quantity(String raw, int row) {
if (raw == null) return new Err<>(new InputError(row, "quantity", "missing"));
final int n;
try { n = Integer.parseInt(raw); }
catch (NumberFormatException expected) {
return new Err<>(new InputError(row, "quantity", "malformed"));
}
return n > 0 ? new Ok<>(n)
: new Err<>(new InputError(row, "quantity", "non-positive"));
}

解析 bad 得到 malformed,解析 0 得到 non-positive。2147483648 超过 int 正数上界,同样进入解析失败分支。本章没有专门区分文本格式与数值溢出,因此它们共用 malformed;需要更细提示时,应换用能够明确区分原因的解析步骤,而非从异常消息中猜测。

传入带空格的文本不会在本函数里自动 trim。是否允许周围空白属于输入协议,应该显式决定。教程里的成功用例只有 2 等确定字符串,不能从这些成功例子推出任意数字样式都被接受。数值解析的精度、符号与边界应跟公开协议一起说明。

Java 异常体系中的受检异常、RuntimeException 与 Error 有不同的检查规则和用途。这里把 NumberFormatException 翻译成数据,是因为这个调用点知道它对应预期的用户输入错误;并不意味着所有运行时异常都应被捕获。JLS 21 第 11 章

map 改变成功值,flatMap 接续可能失败的步骤

map 接收 A -> B。Ok 调用函数得到新成功值,Err 保留错误并改变静态成功类型。flatMap 接收 A -> Result<E,B>,Ok 直接返回下一步结果,Err 则不调用下一步。

1
2
3
4
5
6
7
default <B> Result<E,B> flatMap(Function<A,Result<E,B>> f) {
Objects.requireNonNull(f);
return switch (this) {
case Ok<E,A>(var a) -> Objects.requireNonNull(f.apply(a));
case Err<E,A>(var e) -> new Err<>(e);
};
}

库存判断需要已经解析成功的数量,所以签名是 stock(int n, int available)。当 n 不大于 available,返回 Ok(n);否则返回带 requested 与 available 的 Rejection。它没有理由在 bad 上执行,因为根本没有可用的 n。

完整成功链为 quantity("2",7).flatMap(q -> stock(q,3)).map(q -> q*125L)。解析和库存都可能失败,金额计算在当前小域里返回普通 long,所以最后使用 map。固定单价为一百二十五分,结果为二百五十分;这里没有浮点货币或汇率,也没有对更大乘法范围作保证。

若把 stock 放在 map 中,结果会变成 Result<Problem,Result<Problem,Integer>>。外层成功只能说明 quantity 成功,不能说明库存通过。嵌套并不违法,但与“整条顺序流程只有一个成功或错误结果”的接口目标不一致。选择操作应从函数返回类型推导。

短路还有执行范围限制。flatMap 不调用失败后的回调,但无法阻止在回调创建之前已经计算好的值。把远程库存调用提前执行再捕获结果,会让失败输入仍触发请求。本章采用同步模拟 stock,计数器只观察这个组合点是否调用它。

程序缺陷不应改名为用户输入错误

若整个导入链包在 catch (Exception) 中,再统一返回 malformed,业务回调中的空指针、错误状态与配置缺陷也会被归咎于用户输入。调用方可能反复修改本来正确的文件,而真正需要修复的程序缺陷被隐藏。

实验在成功结果的 map 中主动抛出 IllegalStateException("bug")。测试要求异常逃出组合器,并保留消息。它验证的是“Result 的 map 不暗中捕获任意异常”这个接口契约,不是说所有上层请求都应该让异常直接终止进程。最外层应用可以统一记录和返回内部错误,但应保留其类别与诊断因果。

异常与 Result 可以共存。第三方 API 可能以异常报告解析或 IO 问题,适配器可以将其中被业务理解的失败转换成 E。计算核心用显式结果组合,应用外壳负责未预期异常。重要的是转换点知道自己正在接受什么责任,而非追求代码里完全没有 try/catch。

不能通过捕获 Throwable 一并解决资源和取消。严重运行时错误、线程中断、任务取消等有各自的协议,本章都没有模拟。后续效果与资源章节会单独处理生命周期;普通两分支结果结构没有自动释放资源的能力。

错误集合不意味着错误累积

如果把 E 改成 List<Problem>,flatMap 仍然在第一个 Err 停止。列表能存多个值,不等于组合器会执行多个独立校验,也不等于它知道怎样合并错误。操作语义与错误载荷是两项不同的设计。

数量解析与数量范围有依赖,短路合适;商品名非空与数量解析没有数据依赖,可以各自检查后合并。第 18 篇用同一组输入对照 Either 与 Validated,明确字段内依赖和字段间独立性。提前理解这个区别,可以避免给所有校验链强加同一种风格。

错误顺序同样需要契约。即使采用累积结构,按输入字段顺序还是按严重程度展示,也应由明确的组合与排序政策决定。当前 Result 保留第一个被观察到的错误,没有声称给出完整错误列表。

错误转换放在负责解释它的层

同一个解析错误,在文件导入接口需要行号,在单字段表单接口可能只需要字段名。底层解析器不必直接生成最终展示文案,但应保留上层转换所需的结构。如果底层只返回“失败”字符串,上层很难可靠地区分格式、范围和业务拒绝,只能再次解析文本或重复计算。

错误侧的映射可以把细节转换为另一种协议,而不触碰成功载荷。例如服务边界将内部库存拒绝映射成公开代码,避免直接暴露可用库存数字。这是一种明确的信息删减,应在边界测试里验证,而不是把所有异常消息原样复制到响应。内部可诊断与外部可公开,往往不是同一套字段。

Result 也没有规定错误必须立即打印。纯解析函数返回结构错误,由调用它的入口决定记录一次还是向调用方返回。若每层 flatMap 都记录同一错误,最终可能出现多条重复日志,让一次失败看起来像多个故障。可组合的错误值使处理位置可选择,但日志策略仍需要单独设计。

重试则需要比一个 Err 分支更多的信息。格式非法通常无法靠重试改变;一次远端超时是否适合重试,还取决于操作是否幂等、是否已经产生外部影响。不能因为两个操作都返回 Result,就对它们采用同一自动重试策略。本章不包含网络调用,因此没有提供任何重试正确性的证据。

批量输入还有一个层次差别:List<Result<E,A>> 保留每条输入各自的成败,Result<E,List<A>> 则表示整体成功或整体失败。把前者转换成后者,需要选择遇到失败时保留哪份信息,不能只依靠泛型参数顺序交换。若希望保留成功项并汇总失败项,就需要一个明确描述部分成功的输出结构,而不是强行把所有语义塞进 Ok 或 Err。

实验中每次调用只处理一行数量,没有实现整批导入。因此行号只是错误定位的一部分,不意味着程序已经实现事务、部分提交或多行累积。解释这些边界能避免将一个方便复用的小类型误认成完整的错误管理框架。

可重放实验

1
node examples/functional-programming/run.mjs 13

完整源码与公共运行器产生本章结果证据。正例得到 Ok(250L)。负例分别核对 missing、malformed、non-positive、stock 的完整记录,还断言解析失败后的库存回调计数为零、程序缺陷没有被翻译为输入错误。

Scala 的 Option、Either 与 Try已经介绍三种失败表示及转换的信息损失。本章从 Java 手写类型展示组合器分支和异常翻译范围,不重复 Scala 标准库 API 清单。旧文的成功状态不作为本章测试依据;当前命令独立编译本章源码。

手算与修改练习

类型题:设 parse: String -> Result<Problem,Integer>,price: Integer -> Long,reserve: Integer -> Result<Problem,String>。分别写出 map(price)、map(reserve)、flatMap(reserve) 的结果类型。答案依次为 Result<Problem,Long>、Result<Problem,Result<Problem,String>>、Result<Problem,String>。

修改题:增加 mapError(E -> F),只转换 Err 的载荷,Ok 的成功值保持相等。断言成功路径不调用错误转换函数,失败路径保留行号并把机器代码映射为展示文本。再故意令转换函数抛异常,验证异常仍向外传播。不得用返回空字符串让负例表面通过。

扩展输入协议时,可以把空白归一化放在 quantity 前面,但先决定空串应得到 missing 还是 malformed。两种选择都可以实现,调用方需要稳定的约定及新的回归样例,不能依赖一次字符串替换碰巧产生的结果。