Java常用类库-13-惰性集合视图与Ordering求值边界
一次转换与一份转换规则
商品目录保存整数价格,导出前需要换算成另一单位。若将源 Map 交给 Maps.transformValues,得到的 Map 看起来已经保存换算后的价格;但转换函数没有在构造时执行。返回对象保存的是源 Map 与转换规则,读取某个值时才进行计算。
这种设计可以让结果随目录修改持续变化,也使同一次导出中的重复查询可能重复计算。若转换函数执行远程查询、记录日志或读取当前汇率,重复读取不只增加工作量,还可能得到不同结果。选择集合工具之前,必须确定需要“实时读取源状态”的视图,还是“在某个时间点完成计算”的结果集合。
实验固定 Guava 33.5.0-jre,源码 SHA 为 8868c096cfdabbe38170b6e395369c315cfb72a1,使用 Java 8 语法。完整测试源码包含 Map 转换、Iterable 转换、分区和集合运算、排序四组场景,RUN.md给出 JDK 8 与 JDK 21 的复跑命令。容器与元素的共享边界见 第 02 篇。
用计数器辨认真正的计算时机
源 Map 按顺序保存 sku-a=10、sku-b=20。转换函数每次被调用时增加计数器,并返回价格乘以 100。实验先创建视图,再访问元数据、重复读取、重复遍历,最后复制成独立 Map。断言关注确切的调用数,不把一次打印输出当作计算次数的证明。
| 操作完成后 | 累计转换次数 | 结果意义 |
|---|---|---|
| 构造视图 | 0 | 尚未读取任何转换值 |
| 查询 size 与 containsKey | 0 | 键与大小直接来自源 Map |
| 对 sku-a 执行两次 get | 2 | 同一键不会自动缓存转换结果 |
| 两次完整遍历 values | 6 | 两个元素,每次遍历重新转换 |
new LinkedHashMap<>(view) |
8 | 复制过程读取并保存两个结果 |
| 修改源 sku-a 为 30,再 get | 9 | 视图得到 3000,旧副本仍为 1000 |
这个序列在 JDK 8 与 JDK 21 上均通过。计数器只是观察工具,生产转换函数通常不应依赖调用次数。具体一次操作触发多少次计算,还与入口有关;不能把这张表推广成所有 Map 方法的通用次数表。
固定版本 Maps 的契约与实现明确描述了惰性计算,并提醒 containsValue、toString 等操作可能多次应用函数。TransformedEntriesMap 的 size 与 containsKey 直接委托源 Map;get 查出源值后调用 transformer;entry 迭代器通过转换包装逐项生成值。没有额外缓存存储,自然也没有“第一次 get 后固定结果”的状态。
日志尤其容易隐式触发这种工作。为了排查一次数据错误打印整个视图,会让全部值被转换;随后正式导出再次遍历,转换又发生。若转换含 I/O,日志不再只是观察已有数据。这里不需要先引入缓存框架,先把需要复用的导出结果物化一次,往往更接近任务要求。
视图可删除,不代表转换可逆
transformValues 没有提供从新值恢复旧值的逆函数,因此不能通过该视图 put 一个转换后的值。实验断言 put 抛出 UnsupportedOperationException,但 remove 可以删除源 Map 中相应的键。把“不支持 put”理解成完全不可修改,会错误扩大保护范围。
remove 也可能调用转换函数,因为 Map.remove 需要返回被删除的旧值。在固定实现中,它先移除底层值,再转换这个旧值。因此,如果转换函数可能抛异常,删除路径有额外的失败状态需要分析:不能把“调用抛异常”等同于“底层未改变”。本篇测试验证了正常删除联动;转换异常的删除状态没有在本组实验中运行,结论来自固定源码分支,标记为源码分析而不是实测结果。
null 同样要沿着数据流判断。源 Map 可以存在 null 键;若值为 null,转换函数必须能处理该输入。转换结果也可能为 null。缺失键不会仅因为 get 默认返回 null 就被送入转换函数,源码会结合 containsKey 判断这个 null 是否代表已有条目。只测试非空价格,不能证明一个转换函数对全部 Map 输入都安全。
第一项可迁移模式是把惰性读取当作函数调用边界。转换越昂贵、越依赖外部状态,重复求值的代价与不一致风险越大。若业务要求一次性结算汇率或一次性渲染文案,应在定义好的时间点计算并保存,而不是把活动视图留到未知的消费者手中。
Stream 的惰性终止在收集时
Iterables.transform 也保存可重复使用的迭代规则。实验对 [1,2] 转换为十倍值,构造时调用数为零,两次完整遍历后调用数为四。随后向源列表追加 3,再遍历得到 [10,20,30],累计调用数为七。这里既展示重复求值,也展示后续源修改传播。
Java 8 Stream 的中间 map 操作同样是惰性的,但收集完成的 List 是已经物化的结果。实验在追加 3 之前执行 source.stream().map(...).collect(Collectors.toList()),源列表随后变化,已经收集出的列表仍为 [10,20]。Java 8 Stream 文档规定终端操作触发计算;流本身不能被当作可反复遍历的集合使用。
1 | |
物化只固定已经收集的容器内容,不自动递归复制元素。如果转换返回原来的可变对象,源列表与结果列表仍会共享这些对象。本文用 Integer 值避开元素内部修改,目的是隔离“是否重新执行转换”这个变量。若用于真正的商品对象,必须另行定义对象的复制边界。
快照也需要时间条件。复制过程中其他线程继续修改源,单凭一次 new LinkedHashMap<>(view) 不能保证得到跨所有字段的原子业务快照。该实验在单线程中顺序修改,源码还明确表示转换 Map 本身不提供线程安全保证,即使底层 Map 是线程安全的也不能直接推出包装后的所有操作具有同样性质。
分区是子列表视图,集合差也是活动结果
Lists.partition(source, 2) 常被用于批量请求。外层列表不能添加任意新分区,但内部列表是由源列表的 subList 按需产生的视图。Lists 固定源码中,get 按索引计算 start/end,再返回 list.subList(start, end),并没有复制对应元素。
实验把第一个分区的第一项从 1 替换成 9,源列表同时变为 [9,2,3]。之后向源追加 4,再重新取得第二个分区,结果为 [3,4]。这里特意重新取得分区;长期保留已有 subList,然后从外部结构性修改父列表,受 List.subList 契约约束,不能据此保证旧子列表仍可正常使用。
异步任务若拿到分区视图,而提交线程随后继续修改源列表,任务可能读取到变化后的批次,也可能触及子列表失效条件。若任务要求提交时确定批次,可以对分区建立独立容器,再交给任务;元素对象是否继续共享仍需单独判断。分区大小为零属于非法输入,实验要求抛出 IllegalArgumentException。
Sets.difference(left, right) 的结果也不是静态集合。初始 left 为 sale、new,right 为 sale,差集只有 new;向 right 加入 new 后,已有差集视图变空。这来自 Sets.SetView 的关联语义。视图拒绝 add,但来源仍能改变它。需要保存比较当时的结果时,应显式复制集合。
第二项可迁移模式是根据交付时间选择视图或快照。同步读取、短生命周期、需要追随源变化时,视图可以减少物化工作;跨线程、延后执行、重复消费且要求结果固定时,显式快照更容易表达契约。这个判断比笼统的“惰性更快”更接近可验证的需求。
Ordering 的组合顺序包含 null 策略
价格排序还需要规定缺失值放在哪里。Ordering 文档的 nullsFirst 与 reverse 都返回新的排序规则,而调用先后会改变 null 的位置。输入为 [2,null,1]:
| 组合 | sortedCopy 结果 |
|---|---|
natural().nullsFirst().reverse() |
[2,1,null] |
natural().reverse().nullsFirst() |
[null,2,1] |
第一行反转了包含 null 位置在内的整套顺序,因此 null 最后;第二行先让非空自然顺序降序,再将 null 放到最前。自然顺序直接处理含 null 的输入会抛出 NullPointerException。两种 sortedCopy 都没有修改原输入列表,实验保留对原列表的断言。
Java 8 的 Comparator.nullsFirst、reverseOrder、reversed 等可以表达相同需求,已有 JDK 比较器足够时无需仅为排序引入 Guava。使用哪种类型都应先写出三个代表值的预期顺序,尤其包括 null、相等值与极值,再组合比较器;链式调用读起来顺畅并不构成正确性证明。
求值次数先于性能比较
把活动视图和一次性收集直接放进计时循环,会产生不等价工作。计时区间若只包含视图构造,它没有执行价格换算;若另一组包含完整收集,已经处理全部价格。这样的结果只能说明测试遗漏了前一组的读取阶段,不能说明同一导出任务的速度差异。
公平的对照至少需要相同输入、相同转换规则和相同消费次数。若业务最终只读取一个指定 SKU,逐项惰性转换可能避免计算其他项;若业务完整导出两遍,实验已经显示活动视图会重复工作,而物化结果可以复用。这个差别应先用调用次数说明,再决定是否需要耗时与内存测量。数据规模、转换成本和结果保留时间不同,结论也可能不同。
转换异常还会改变错误出现的位置。立即收集通常把转换失败暴露在收集阶段;视图则可能在后续日志打印、序列化或消费者读取时才抛出异常。因此,返回一个构造成功的转换视图,不等于证明全部元素都能完成转换。需要在导入阶段拒绝非法价格时,应显式验证输入或完成规定的物化步骤,不能把错误拖到任意读取入口。
保留视图还会保留对源结构和转换函数的引用。函数若捕获一份大型配置,视图的生命周期可能延长这份配置的存活时间。是否造成实际内存问题需要测量,不能把“没有复制元素”直接写成“内存开销最低”。容器共享、计算复用和对象生命周期是三个不同变量,选择视图时需要分别判断。
实验边界与练习
四项测试在 JDK 8 与 JDK 21 均为 Tests run: 4, Failures: 0, Errors: 0, Skipped: 0。调用次数是实际断言结果,不是耗时基准;这些实验不能证明视图在任意数据量下更快,也没有覆盖并发修改时的安全性。
手算题:复制转换 Map 之后,再对原视图完整遍历一次,副本是否触发重新计算?答案是副本没有参与;原视图仍会按源条目重新转换。改动练习:将换算函数改成返回包含可变字段的对象,在快照建立后分别修改源容器和对象字段,分别断言容器成员关系与对象内容是否变化。两类断言必须分开,才能识别浅复制边界。
| 判断关键词 | 可迁移模式 | 具体选择 |
|---|---|---|
| 重复遍历、昂贵函数 | 读取也是求值边界 | 统计调用数,必要时物化一次 |
| 延后提交、跨消费者 | 按交付时间选择快照 | 复制分区或差集,说明元素共享 |
| null 与逆序 | 组合顺序也是契约 | 用代表输入断言完整排序 |
| 不支持 put/add | 单个写入口不代表只读 | 同时检查删除与源修改路径 |
前篇:BiMap 与 Table。系列起点:可复现基线。
