函数式编程34:Java 容器的组合与生命周期边界
Optional、Stream 和 CompletableFuture 都提供接收函数的方法。相似的方法形状能帮助阅读类型,却不能让它们具有相同的求值、空值或资源语义。把 thenCompose 记成 flatMap 的对应物之后,仍然需要回答:任务什么时候启动,失败以什么形式传播,谁负责关闭文件,重复使用会发生什么。
本章固定 Java 21,用真实临时文件和自有线程池检查这些问题。实验不引入替代 JDK 容器的库,也不尝试给三个 API 颁发统一的 Monad 认证。观察单位分别是返回值、回调执行次数、关闭次数、线程名称和异常原因。
从嵌套类型判断 map 与 flatMap
对于 Optional
1 | |
nested 的类型是 Optional<Optional
对于已经完成的 CompletableFuture
类型推导能够发现“函数返回普通值还是带上下文值”的差别,却不能替代对库契约的检查。Java 的泛型签名不表达函数是否会返回 null,也不表达它是否启动线程或修改共享状态。后面的反例正发生在这些类型没有排除的行为上。
Optional 的 null 规则会影响组合律
JDK 的 map 把 mapper 返回的 null 转成空 Optional;flatMap 的 mapper 必须返回非 null Optional,返回 null 会抛异常。这是两个明确不同的契约,不能把 Optional 当作简单的、无条件保持函数结果的盒子。Optional API
实验构造两个函数:
1 | |
左边第一步已经变成 empty,因此 g 不会执行。右边先在普通函数组合中让 g 接收 null,产生 fallback,再由 map 包装为非空值。两边的不同不需要随机测试,也不涉及线程时序;一个具体输入就足以否定“任意 Java Function 都满足这个 map 组合等式”。
限制 mapper 不返回 null 后,这个反例不再适用,但那是缩小了函数域。还需要排除或明确定义异常、非终止和副作用观察,才能讨论对应的等价关系。不能从“这个反例被禁止了”直接推导出所有实现定律都已经证明。
Optional 的用途也不因此失效。它仍然能清楚表达返回值缺席,减少调用链上直接解引用 null 的机会。工程上需要做的是在边界选定规则:来自旧接口的 null 通过 ofNullable 转换,内部组合函数尽量返回显式 Optional,不能在 flatMap 回调里用 null 表示缺席。
文件 Stream 的终止不等于关闭
实验创建内容为 A、B 两行的临时文件,用 Files.lines 打开,并注册 onClose 计数器。findFirst 得到 A 后,关闭计数仍然是零。随后尝试对同一个流调用 count,预期抛出 IllegalStateException。退出 try-with-resources 后,关闭计数变为一。
1 | |
这里存在三个不同事件:建立流、终端消费、关闭资源。短路终端操作可以停止继续读取,但停止读取并不等于执行 close。若方法把文件流直接返回给调用者,资源所有权也随之转移;接口文档和调用方式必须让调用者知道它负责关闭。
单次消费也不是纯度问题。由 List 创建的 Stream 不一定拥有外部文件,但同样不能在消费后把原流当作可重复集合使用。需要重放时可以保存不可变数据,或者保存创建新流的函数;前者保留数据占用空间,后者重新读取外部状态,可能得到不同结果。两种办法具有不同成本和观察语义。
在文件边界中,返回一个延迟处理对象尤其容易产生误用。若创建它的 try 块已经结束,而真正的读取发生在调用者消费时,就可能在已关闭资源上继续操作。可以在资源范围内完成需要的求值后返回结果,也可以让资源范围本身成为返回的抽象,不能仅凭“流是惰性的”决定生命周期。
关闭计数的断言只证明当前正常关闭路径被调用一次,并且文件读取作用域结束。实验没有制造操作系统关闭失败,也没有证明所有第三方流适配器都正确传播关闭。把 onClose 计数当成资源绝不泄漏的全局证明,会超过这个观察的能力。
CompletableFuture 的启动时间
实验给 supplyAsync 显式传入单线程执行器,线程名为 fp34-owned。任务进入后增加 starts,并通过 CountDownLatch 通知测试线程,然后等待放行。测试先等待 entered,再调用 join。这使“join 之前已经执行”成为受同步原语保证的观察,不依赖睡眠猜测。
因此保存 CompletableFuture 的引用,保存的是已提交任务的结果句柄。它并不意味着把业务动作保存成尚未执行的描述。若想延迟提交,可以保存 Supplier<CompletableFuture
实验在构造 supplier 后断言 starts 仍为一,再调用两次 get,得到计数二和三。两次 get 创建两次执行;对同一个已经返回的 future 调用两次 join,通常只是重复观察同一次完成。把 supplier 与 future 混为一谈,会让重试、缓存和重复写入的次数发生变化。
延迟提交仍不等于安全取消或自动资源管理。supplier 里可以捕获已关闭连接,也可以每次启动一个没有归属的线程池。必须单独定义执行器由谁创建、在哪里关闭,以及调用方放弃等待时任务如何结束。本实验让执行器的作用域覆盖所有任务,退出作用域时关闭它,没有把线程池交给全局变量。
JDK 文档区分了非 async 回调与使用执行器的 async 回调,并指出 CompletableFuture 的取消不直接控制底层计算。不能因为结果句柄进入取消状态,就推断所有后台工作已经停止。CompletableFuture API
本章没有执行取消实验,因此只把这一点作为 API 边界,不报告“中断已经传递”。后续有资源和结构化任务归属的实验需要等待取消完成,并观察活动任务归零,才能给出相应结论。
异常穿过边界时的形状
failedFuture 保存 IllegalArgumentException 后,join 抛出 CompletionException。实验检查 cause 仍是 IllegalArgumentException,而不是只断言“发生了异常”。调用方如果把所有 CompletionException 当成同一种业务错误,就会抹掉非法输入、超时和程序缺陷之间的区别。
同步函数直接抛异常,与异步句柄保存失败并在 join 时暴露,发生位置不同。若日志只记录 catch 所在位置,错误看起来会出现在等待方,而不是最初的业务动作。边界适配器应保留原因和上下文,避免在每一层创建一个丢失 cause 的新 RuntimeException。
同样,Optional.empty 不能表达报价系统故障。把失败统一转成缺席会让“没有报价”和“没能查询报价”无法区分。本章的三个容器不是可以随意替换的错误通道;需要先选定业务错误与技术失败的表达,再决定组合形式。
在迁移旧代码时,可以先保持外部 API 形状,只在内部引入明确的错误类型和依赖参数。直接把返回 T 改成 Optional
边界转换不要丢失语义
将 Optional 转为 future 时,需要决定缺席是否仍然是成功结果。CompletableFuture<Optional
Stream 与 future 的嵌套更需要资源所有权。若在文件流的 map 中提交异步任务,然后立刻退出文件作用域,任务是否还会访问 reader 取决于闭包捕获了什么。捕获已经解析好的不可变行值,与捕获仍需读取的游标,具有完全不同的生命周期要求。检查 lambda 参数之外的捕获变量,是边界审查的一部分。
非 async 的 thenApply 也不是“必定在主线程执行”。注册时任务是否已完成、完成由哪个线程推动,都可能影响回调执行线程。当前实验只断言显式 supplyAsync 的任务运行在自有线程池,没有对所有后续回调线程作未经测试的承诺。若回调涉及线程亲和性,应选择明确执行器并建立针对性观察。
失败适配还应避免把中断语义当作普通业务异常。当前实验中的门闩异常只服务于确定执行顺序,不是一个完整的中断恢复策略。业务代码若捕获 InterruptedException,需要按拥有的任务协议处理,而不是因为已经返回 future 就忽略中断状态。
对 JDK API 的这些检查可以成为迁移清单:输入是否允许 null,返回值是否嵌套,构造是否启动,重复调用是否重做,资源在哪里关闭,异常原因为何。每项都有独立反例,不需要把所有容器归类为同一种代数结构才能使用它们。
审查调用方时也要检查它是否真的消费了返回的 future。如果只是构造并丢弃句柄,失败可能无人观察,资源所有者也无法等待任务结束。当前实验显式 join 所有已提交的成功或失败任务,因此输出通过之前,相关完成状态已经被读取。
与旧文及实验的对应
旧文Optional 的正确用法讨论了创建、map 和缺席处理。本章补充的是 map 与 flatMap 不同的 null 规则及具体组合反例。旧文中关于集合处理的表述不能替代本章的类型推导;这里的 Optional 不被当作任意集合的替身。
旧文Iterator、View 与 LazyList区分延迟求值与缓存。本章在 Java 边界继续追问“可重复的是什么”:是集合、创建动作、已启动任务的结果,还是单次消费的流。相同的延迟外观并不赋予相同的重放语义。
完整Main.java含临时文件清理、超时看门狗和负断言;运行证据记录冻结环境、源码散列及真实输出。执行:
1 | |
本次 optional-nesting、null-counterexample、stream-once、close-once、eager-start、delayed-restart、owned-executor 和 exception-cause 均通过。超时只用于防止同步协议故障后无限等待,没有用耗时阈值判断性能。
类型题:分别写出 map 返回 Optional 的函数、thenApply 返回 future 的函数、Supplier 包住 future 后三者的完整类型,并标出需要几次调用才能拿到整数。修改题:让 supplier 捕获一个计数结果,而不是每次提交时递增,预测两次 get 的结果后运行;再恢复新执行语义,增加“两个 future 不是同一次任务”的观察。不要用对象身份代替业务执行次数。

