系列导读与能力自测

保存了一个 Supplier,还没有保存结果

一段计算每次执行都增加计数并返回新计数。直接调用得到一;把计算保存在 Supplier 中,不会因为变量赋值就再执行,但连续两次 get 得到二和三。再把同一个 Supplier 包进延迟值,第一次取得四,第二次仍得到四。返回类型看起来都是“以后可取得整数”,执行次数却完全不同。

严格求值关注现在就计算,延迟关注何时开始计算,按需共享还规定已经取得的结果会被复用。三者不能仅凭 lambda、Stream 或 Lazy 这些名字判断。本章用 Java 21 显式记录构造、调用、短路、重复消费与关闭后的失败。

已有 Scala 11 的 Iterator、View 与 LazyList分别讨论游标、重复计算和节点记忆化,尤其指出延迟读取可能越过资源关闭位置。本章复用这些问题,改用 Java Supplier、Stream 与一个教学 Lazy,避免把 Scala 集合行为直接套在 Java 对象上。

严格实参和延迟函数体

Java 方法调用前需要先求出实参表达式的值。use(expensive()) 中 expensive 会在进入 use 之前执行。use(() -> expensive()) 则把一个函数值传入,函数体在被调用时执行。外层方法可以决定调用零次、一次或多次,但只有阅读实现和协议才能知道是哪一种。

Supplier<T> 的类型形状是 () → T。它描述取得结果的能力,没有编码缓存策略。JDK 21 Supplier 文档并不要求每次返回新的或不同的结果,因此计数器、常量函数和缓存包装都可以实现这个接口。接口相同不等于执行协议相同。

还应分开函数体与捕获环境的构造。var config = load(); Supplier<T> s = () -> compute(config) 在保存函数之前已经调用 load;它只推迟 compute。把代码包进 lambda 不能撤销此前发生的 IO。若要推迟 load,必须让它也进入函数体,同时处理随后才会发生的失败与资源生命周期。

延迟不是自动优化。如果结果无论如何都会使用,只改变执行时点未必减少工作。如果计算依赖可变配置,延期还可能改变读取到的版本。把配置值先取得再捕获,与把配置读取延期到调用时,是两种不同的业务语义。

一个只缓存成功结果的延迟值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static final class Lazy<T> implements Supplier<T> {
private Supplier<T> thunk;
private boolean ready;
private T value;
Lazy(Supplier<T> thunk) { this.thunk = thunk; }
public T get() {
if (!ready) {
value = thunk.get();
ready = true;
thunk = null;
}
return value;
}
}

ready 将“还没计算”与“已经计算但结果是 null”分开。若仅检查 value 是否为空,合法 null 结果就会每次重算。实验通过单独计数确认 null 连读两次只执行一次。这个问题与第 08 章假值缓存相通:缓存存在性与结果内容不能混为一谈。

赋值顺序定义了失败策略。thunk.get 抛异常时,ready 仍为 false,下一次 get 会重试。实验第一次抛 first,第二次返回七,第三次复用七,执行次数总计二。若需求是连异常也缓存,必须增加失败状态;不能在 finally 中把 ready 设为 true,否则可能把未生成的 value 当成成功结果返回。

成功后清空 thunk,使不再需要的捕获环境有机会失去这条引用。但返回结果本身可能仍指向同一个大对象图,因此不能由清空 thunk 推导确切回收字节数。缓存把结果生命期延长到 Lazy 实例可达期间,这正是复用的成本之一。

该实现只用于单线程教学。ready 没有同步,多个线程可能同时执行 thunk,读到不同状态;递归 get 自己也没有被检测。完整延迟库可能规定同步、重入与异常记忆化,本章没有冒充那些能力。一个布尔字段解释了共享原理,不等于已经实现效果运行时。

Stream 的工作量由需求决定

实验输入一至五,映射为十倍,再筛选能被二十整除的结果,最后 limit 两项。构造 Stream 时事件列表为空;消费成 List 后结果为 [20,40],映射访问的输入为 [1,2,3,4]。取得两个合格结果需要检查四个候选,因此“只取两个”不能解释为上游只计算两次。

1
2
3
4
5
var stream = Stream.of(1,2,3,4,5)
.map(x -> { seen.add(x); return x * 10; })
.filter(x -> x % 20 == 0)
.limit(2);
var result = stream.toList();

事件副作用只是实验探针,用来观察这一条顺序管道。实际业务不应依赖随意放在 map 中的副作用必然执行;优化与终端操作会影响哪些计算确实被需要。例如只统计已知大小来源的元素个数时,不一定需要执行一个不影响计数的映射。这里 toList 需要真实映射值,断言才与目标工作相符。

limit 放在 filter 前面会改变问题:只看前两项再筛选只能得到二十;先筛选再 limit 则寻找整个来源中的前两个合格结果。移动截断位置必须按业务含义判断,不能只把它当作性能调整。对于永远不满足谓词的无限来源,limit 一项仍可能永远找不到结果。

终端消费后,同一个 stream 再次 toList 在本次实现中抛 IllegalStateException,实验捕获并断言。JDK 21 的 Stream 说明要求一次使用,但也说明并非所有重用都能被检测;本章观察不能扩大为“任意违反协议都必定得到同一个异常”。若需要重读,可以从稳定来源重新创建 Stream,或保存第一次物化的 List。

延期执行必须仍在资源范围内

教学 Port 有 closed 标志,read 在存活时返回 order,关闭后抛 closed。在 try-with-resources 内保存 port::read,内部调用成功;离开作用域之后调用同一个 Supplier,实验确认失败。函数对象仍在,并不表示资源还开放。

这个反例是模拟端口,没有声称测试真实文件系统。它隔离了创建与消费分离导致的生命周期问题:若一个 API 在资源块里返回文件行 Stream,而外层稍后才消费,就必须明确谁负责保持资源开放和最终关闭。只返回一个可调用对象不够。

一种修复是在资源块内部完成消费,返回独立结果。对于小输入,物化 List 可以很直接;大文件若全部物化可能超出内存预算,应让消费函数进入资源作用域,或采用具有明确资源协议的流抽象。延迟计算本身不提供取消、释放、异常组合或背压。

把关闭动作也放进另一个 Supplier 并不自动解决配对关系。调用者可能只执行读取而忘记关闭,或先关闭再读。资源获取、使用与释放应有一个统一的控制范围;后续资源章节会验证成功与失败分支。本章只证明逃逸后使用为何失效。

观察值、事件与保留空间

严格结果保存已经取得的值,Supplier 保存可再次执行的计算。Lazy 未就绪时持有计算,成功后持有结果;Stream 还带有消费协议。选择时可以从实际需求倒推:是否需要重复获取,重复应重算还是共享,来源是否稳定,结果是否可能很大。

缓存可变结果会产生别名。若 Lazy 返回 ArrayList,第一次调用者修改它,第二次得到的可能是同一个被修改的列表。共享求值并不等于历史快照。要把结果当作可替换的值,应使用稳定数据,或在出口明确复制和所有权规则。

异常延迟也影响用户可见行为。构造管道成功,只说明描述已经建立,不能宣布文件解析通过;错误可能直到消费才出现。状态记录应以实际消费结果为准。测试中如果只断言 stream 非空而从不执行终端操作,就没有检查回调内部逻辑。

成本可以从实际需求次数推导:本例构造不映射,单次消费映射四次,重新建管道再消费会再做相同工作;Lazy 单个值成功后不重算,但持有结果。这个推导不涉及吞吐数字,也不能证明某种策略在所有负载下节省内存。

需求传播与失败时点

消费者要求两个合格结果,上游必须不断提供候选,直到找到两个或来源结束。这个要求沿管道向前传播,filter 会让一次下游需求对应多次上游计算。若映射步骤在第三个候选上失败,即使此前已经找到一个合格结果,整个 toList 仍不能正常返回两个结果。短路只跳过不再需要的后续工作,不会撤销已经发生的错误或副作用。

因此测试延迟管道应同时观察输入访问边界与输出。只断言 [20,40] 无法区分实现究竟访问四个候选还是先把五个全部映射;只断言访问四次,又无法证明筛选和映射值正确。当前实验同时保存 seen 和结果,分别承担这两项任务。若需求改变为排序后取两项,一般还必须先了解全部有限输入,不能把 limit 的位置与原管道成本照搬过去。

按需共享也有类似的双重观察。两次 get 都返回四,可能是执行一次后复用,也可能是执行两次但恰好都返回四;计数才能区分。反过来,计数一次也不证明第二次结果正确,错误实现可能第一次返回四、第二次返回默认零。值与次数一起断言,才能验证“成功后共享同一个计算结果”的承诺。

延期还会改变错误归属。严格读取在请求建立时失败,调用方可能尚未返回响应;延迟读取若发生在响应流发送途中,则可能已无法返回一份完整错误页面。业务选择延迟时,应明确错误最晚允许发生在哪个阶段,而不是仅讨论省掉多少次计算。本章模拟端口只验证关闭后调用失败,没有模拟响应发送、取消与释放竞争;这些场景需要相应的协议测试。

如果只需要延迟一次初始化,又允许多个线程访问,不能直接把这个教学 Lazy 放进共享单例。即使给 ready 加 volatile,也还需要处理同时未就绪时的重复计算,以及结果、失败和 thunk 清空的发布关系。线程安全延迟值应作为另一项有并发测试的实现,而不是在当前通过的单线程代码上增添一个修饰符就宣布完成。

自测与修改练习

手算:计数 Supplier 已执行三次,再新建 Lazy 包装它,连续读两次。得到四与四,计数四。若每次读取前都新建一个 Lazy,则两个实例各自需要第一次计算,结果可能为四与五。共享发生在同一个延迟实例,不能跨实例凭空成立。

类型题:Supplier<List<Order>> 是否说明结果列表不可变、仅执行一次、线程安全?三项都不能。类型只说明调用后可取得 List,额外性质应由实现与协议给出。将结果放进 final 变量也不会补齐这些保证。

修改练习以 exercise-null-cached-once 和 failure-retries-success-shares 为基线,增加一个显式失败状态,使同一 Lazy 第一次失败后重复 get 不再执行 thunk,而是再次抛出保存的失败。保留成功值与 null 的断言,再把失败计数预期改为一。不要用 null 同时表示未计算、失败和成功空结果。

另一个可运行改动是把 Stream 谓词改成能被三十整除,仍取两个结果。当前有限输入只会产生一个值为三十的结果,却会扫描全部五个候选。记录值和 seen,两者一起才能说明数据不足时如何结束。这个扩展由读者修改运行,现有证据只覆盖二十整除的基线。

完整 Main.java运行方式:

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

result.json保存编译、实际事件次数、重复消费拒绝、端口存活与关闭后读取、失败重试和 null 共享的结果。并发 Lazy、真实文件资源释放与无限来源取消没有在此实验中实现,不能以这些单线程检查替代。