订单可能没有优惠券。计算优惠券名称长度时,调用者需要区分“没有优惠券”和“有一张名称为空的优惠券”;继续套一层 try/catch 并不能建立这种区别。缺席如果是预期结果,应出现在返回值结构中,而不是让所有使用者从某次空指针异常倒推它。

Maybe 是一种常见的抽象形状:结果可以是一个 A,也可以没有 A。Scala 用 Option、Some、None 表达这类结构,JDK 提供 Optional。本章先手写一个明确拒绝 null 的 Maybe,再观察 JDK 的方法行为。相似的数据形状并不意味着它们对 null、默认值和对象身份采用同一契约。

系列导读与能力自测

缺席增加了一个分支

若有限域 A 只有红、绿、蓝三个值,Maybe<A> 有四种情况:Some(红)、Some(绿)、Some(蓝)、None。它不是为每个值额外增加一个“是否为空”的可变标志,而是选择有值或无值其中一个构造分支。有值分支携带数据,无值分支不携带 A。

这个表达适用于查询不到可选优惠券、尚未设置昵称等场景。若失败时需要告诉用户“格式不合法”或“优惠券已过期”,单独一个 None 不够用。不同原因一旦合并成缺席,后续组合无法凭空恢复它们。是否丢弃错误信息,应在接口边界决定。

Java 的 Optional 主要定位是表达可能无结果的方法返回类型,这是 API 文档给出的用途说明。它没有禁止所有参数或字段使用,更没有保证任何返回 Optional 的方法都不会返回 null。项目约定可以比语言更严格,但需要把约定与编译器保证分开。JDK 21 Optional API

最小 Maybe 的两种构造

1
2
3
4
5
sealed interface Maybe<A> permits Some, None { /* 组合方法见下文 */ }
record Some<A>(A value) implements Maybe<A> {
Some { Objects.requireNonNull(value); }
}
record None<A>() implements Maybe<A> {}

本章选择 Some 拒绝 null。这是教学实现的显式约束,不是“所有 Maybe 都必须如此”。Scala 的 Some(null) 可以保留 null,而 Option(null) 会变成 None;Option 工厂 API明确规定了后者的转换。这里不能把工厂与构造分支混为一谈。进入某种库之前,应先确定它允许的值域。

None 带着类型参数,却没有对应值。new None<String>() 与 new None<Integer>() 分别可用于不同类型的计算链,无需伪造一个默认字符串或默认整数。缺席仍然保留“如果存在,将会是什么类型”的静态信息,这是 null 本身表达不了的接口关系。

这份模型没有提供 get。消费时应给两种分支安排结果,或继续在 Maybe 内变换。删除 get 不能阻止所有不安全操作,调用者仍能强制转换或主动抛异常,但它能让默认使用路径不依赖“此处肯定有值”的隐含假设。

map 只改变存在的值

1
2
3
4
5
6
7
default <B> Maybe<B> map(Function<? super A, ? extends B> f) {
Objects.requireNonNull(f);
return switch (this) {
case Some<A>(var a) -> new Some<>(f.apply(a));
case None<A> n -> new None<>();
};
}

输入为 Some(“sku”) 时,String::length 产生整数三,结果为 Some(3)。输入为 None 时,没有字符串可交给函数,结果仍然缺席。函数没有被执行,因而不会出现“给缺席计算一个长度”这样的额外政策。

? super A 允许接收 A 的父类型作为参数的函数;? extends B 允许函数返回 B 的子类型。本章没有依靠这些通配符改变语义,它们只使接口更容易接收已有函数。可以先把签名简化理解为 (Maybe<A>, A -> B) -> Maybe<B>,再回到 Java 的可赋值规则。

如果 f 返回 null,Some 构造器会抛异常。这个约束让调用方尽早发现“本来声明生成一个 B,却没有生成合法 B”的错误。若业务确实允许变换后缺席,函数应返回 Maybe<B>,再选择不同的组合方法,而不是隐式借 null 增加第三种通信方式。

实验同时测试空输入和非空输入,还用计数器确认 None.map 没有调用回调。计数器是测试观察工具,不是业务实现的一部分;它帮助界定组合器负责的执行范围。构造回调之前已经执行的动作,不可能被 map 的缺席分支撤销。

flatMap 对应可能再次缺席的步骤

假设优惠券名称查询返回 Maybe<String>。对一个 Maybe<Coupon> 直接 map 查询函数,结果是 Maybe<Maybe<String>>。外层表示有没有优惠券,内层表示这张券有没有名称。这个嵌套可能有用,但如果接口只关心最终有没有名称,就需要把同类缺席传播规则合起来。

1
2
3
4
5
6
7
default <B> Maybe<B> flatMap(Function<? super A, Maybe<B>> f) {
Objects.requireNonNull(f);
return switch (this) {
case Some<A>(var a) -> Objects.requireNonNull(f.apply(a));
case None<A> n -> new None<>();
};
}

Some 分支直接返回 f 的 Maybe 结果,None 分支保留缺席。这里不能把结果再放进 Some,否则又得到嵌套。第 19 篇会展开 map、join 与 flatMap 的对应关系;本章先通过返回类型识别什么时候需要继续处理可能缺席的步骤。

flatMap 也拒绝回调返回 null,因为 null 连 Maybe 结构都没有。返回 None 是合法缺席,返回 null 是接口违约,两者不能互换。程序对 None.flatMap 的回调调用次数同样断言为零,确认 map 与 flatMap 都没有在无值时执行成功分支。

默认值的计算位置

orElseGet 从 Maybe 退出到普通 A。Some 返回已有值,None 才调用 Supplier。默认值的选择是业务规则:没有优惠券可以按无折扣处理,没有商品数量却未必可以默认为零。把类型拆出来不会替调用者决定哪种默认合理。

JDK 的 orElse(value) 接收的是一个已经求值的普通参数。Optional.of(7).orElse(++count) 会先增加 count,再把参数传入方法;即使方法最终返回七,增加已经发生。orElseGet(() -> ++count) 则把待执行函数传进去,有值时不运行它。

1
2
3
4
int[] defaults = {0};
Optional.of(7).orElse(++defaults[0]);
Optional.of(7).orElseGet(() -> ++defaults[0]);
check(defaults[0] == 1, "orElse-eager-orElseGet-lazy");

这段实验观察的是回调与参数表达式的执行,不测对象分配或性能。只要默认表达式有调用数据库、记录指标等动作,就需要认真判断执行时机;默认值只是常量时,选择应更看重可读性。不能从“Supplier 没执行”推导整个表达式零成本。

把默认结果提前保存再捕获它,同样不会恢复惰性。例如先执行 fallback() 得到 defaultValue,之后传 () -> defaultValue,只延迟了读变量。惰性边界必须包含希望延迟的那段计算,函数包装的外观本身不提供保证。

Optional 对 null 的具体处理

Optional.of(null) 拒绝 null;ofNullable(null) 得到 empty。它们适合不同边界:前者表达调用者确信存在值并要求立刻暴露违约,后者把外部可空返回值翻译成缺席。把所有 of 都机械替换成 ofNullable,可能把本应发现的程序错误隐藏起来。

有值 Optional 的 map 若得到 null,会返回 empty。flatMap 若得到 null,则抛 NullPointerException。两个方法接收的函数类型不同,它们对 null 的处理也不同。实验明确执行两个路径;不能根据方法名相似推导相同结果。

本章手写 Maybe 的 map 对 null 更严格,因此并不是 JDK Optional 的逐方法复刻。在非 null 的普通变换中,两者可以给出相同值结果;在返回 null 的变换上,它们分开。迁移代码时应把这些差异列为独立用例,而非只跑几个正常字符串。

第 16 篇将以 JDK 的 null 处理构造 map 组合律反例。那是比较“分开 map 两次”与“一次 map 组合函数”的结果,不能叫作 flatMap 的结合律反例。判断一条定律之前,需要明确正在比较的操作、函数域与相等关系。

相等不依赖对象身份

Optional 是 value-based 类。比较业务结果应使用值语义,不应依赖两个 empty 是否恰好是同一个对象,也不应把包装对象当作锁。API 的 empty 说明没有给调用者单例身份承诺。Optional 的类说明及 empty 契约

实验创建两个分别包装相等字符串的 Optional,使用 equals 检查相等。没有加入“两个 empty 引用必须不同”这种反向断言,因为不承诺同一对象也不意味着必须不同。对未承诺的实现细节,正确测试方式是不要把它设成验收条件。

值相等也不会让被包装的对象自动不可变。Optional<List<Order>> 仍可能引用可变列表。若需要稳定快照,应先建立列表及元素的不可变边界,再包装结果。缺席建模、对象相等与不可变性解决的是不同问题。

运行与既有材料

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

完整源码、公共运行器与结果证据保存本章测试入口和实际结果。运行覆盖 Some/None、map/flatMap 缺席传播、默认回调次数、四种 null 边界与值相等。所有检查都能使进程失败,打印 PASS 只发生在对应检查之后。

Optional 迁移的值语义与类型边界具体比较 Guava 与 JDK 的迁移,本章只承接其“相似 API 仍需验证失败语义”的问题。Scala 的 Option、Either 与 Try补充三个标准类型的选择。本章新增手写 Maybe 的结构推导,不把那些文章的测试结果当成本章证据。

缺席不能替代所有业务状态

一个查询返回 None,需要先约定它表示什么。若它表示指定标识没有匹配记录,就不应再用同一个 None 表示数据库不可达、用户无权访问和输入格式非法。调用方可能把“没有记录”显示为空页面,但对暂时故障应该选择报错或重试。把这些情况压成一个分支以后,后面再增加 map 无法恢复已经丢失的原因。

同样,Optional<List<A>> 和 List<Optional<A>> 表达不同结构。前者允许整份列表缺席,也允许一份明确存在的空列表;后者说明列表已经存在,但某些位置没有值。批量查询若要求结果与请求位置对应,直接过滤掉所有空项可能破坏对应关系。是否保留位置,应先由接口契约决定,再选择列表与可选值的嵌套顺序。

缺席还可能与“尚未加载”不同。若页面状态同时包括未请求、加载中、成功无结果、成功有结果和失败,单个 Maybe 无法准确区分它们。可以为页面生命周期建立有限分支,而在成功载荷内部使用 Maybe 表达无结果。这样不会把时间状态和数据缺席挤到同一个空值里。

默认值也应在能解释它的层使用。核心计算把缺席价格补零,可能导致后续误以为得到了一份真正报价;展示层把缺席昵称显示成匿名,则可能符合明确产品约定。两者都能用 orElseGet 写出来,语法本身不判断替代值是否有业务依据。测试需要验证替代后的行为,而不仅是不会出现空指针。

如果一个方法返回可选值,就应始终返回合法的外层对象,而不是有时返回 Optional.empty、有时直接返回 null。否则调用方在使用可选值前还得再检查外层空引用,原本试图集中表达的缺席语义又分裂成两套。实验的自定义 Maybe 用非空载荷约束区分有值与无值,但同样依赖调用方不返回空的外层引用。

迁移旧接口时,可以在适配层一次性整理这类约定:外层 null 代表什么、内部 null 是否允许、空集合是否为成功值。不要沿调用链到处补相同的 ofNullable,否则很难看清哪一步真正消除了歧义。第十三篇将进一步区分缺席与带原因的失败。

如果旧方法另带一个 found 布尔值,就要检查它与返回数据是否可能矛盾。适配器应拒绝 found 为真却没有合法载荷的组合,而不是直接选择其中一个字段。Maybe 改善的是适配完成后的表示,不能自动修复上游已经违反的约定。

手算与修改练习

类型题:Maybe<String> 上 map 一个返回 Maybe<Integer> 的函数,结果是什么?答案是 Maybe<Maybe<Integer>>;若改用 flatMap,则是 Maybe<Integer>。再解释外层 Some、内层 None 与外层 None 的信息差别,不能只回答“多包了一层”。

修改题:增加 filter(Predicate<A>),要求 None 不调用谓词,Some 满足条件时保留原值,不满足时返回 None。给出非空成功、非空拒绝和缺席三类断言;再让谓词抛一个程序异常,确认它没有被自动转换成缺席。运行结果决定实现是否符合契约。