Java常用类库-24-集合装饰器的校验转换与同步入口
包装集合后,原引用仍然存在
目录模块用 PredicatedCollection 限制标签不能为空。通过包装的 add 插入空字符串会抛异常,但另一个调用方持有原 ArrayList,直接 add 空字符串后,包装中仍然能读到这个非法值。包装没有复制底层集合,也无法拦截绕过它的调用。
同步包装和转换包装面临相同的入口问题。同步方法只在通过包装调用时持锁;转换只在指定入口发生。把原始集合继续交给其他模块,就同时保留了未校验、未同步或未转换的写入路径。类型增加一层装饰,不等于对象图中所有路径都受到相同约束。
本篇固定 Commons Collections 4.6.0、Guava 33.5.0-jre,以 Java 8 API 编译,在 Zulu 8.0.472 与 Corretto 21.0.11 运行。完整测试用返回值、转换次数和 Thread.holdsLock 观测包装行为;运行说明给出复跑命令。前置为 第 02 篇的引用关系。
PredicatedCollection 检查经过它的写入
实验创建空 ArrayList,再套上谓词“非 null 且非空串”。包装 add(“”) 抛 IllegalArgumentException,原集合 add(“”) 成功,包装 contains(“”) 为 true。再次尝试对已经含空串的原集合创建同谓词包装,也会抛异常,因为构造过程验证既有元素。
PredicatedCollection 固定源码在构造时遍历已有内容,在 add 之前调用 validate,addAll 则先检查待添加项。它提供一条统一校验入口,但没有把底层集合藏起来,也没有把旧引用失效。已经拿到原对象的调用方不需要经过 validate。
业务若要让该规则成为完整边界,需要控制底层引用的生命周期:在模块内部创建集合,只向外暴露预定能力,避免其他入口直接写入。即便如此,谓词只检查元素添加时的状态。若元素是可变商品,添加后价格从正数改为负数,不会自动再次触发校验。对元素内部约束,应使用相应对象模型或受控修改方法。
谓词本身也应有明确语义。依赖当前时间、远程调用或可变外部开关的谓词,可能在两次添加之间作出不同判断;构造时合法并不代表永久合法。本实验仅使用纯粹字符串条件,没有测试外部状态校验器,也不把装饰器当成动态审计机制。
可迁移判断是枚举所有可达写路径,再确认哪条路径执行校验。数据库访问封装、缓存包装和配置代理都可能存在同类旁路。一个入口有检查,只能证明通过它的操作符合该检查,不足以证明整个状态集合长期满足业务不变量。
transforming 与 transformed 在何时转换
两个工厂方法只差一个词尾,却对已有数据不同。transformingCollection 保留既有元素,只转换后来通过包装添加的元素;transformedCollection 会先转换已有内容,再对后续添加进行转换。实验给底层列表放入带空格的 " a ",转换函数为 trim,并用 AtomicInteger 记录调用次数。
创建 transformingCollection 时计数为零,原元素仍为 " a "。通过包装添加 " b " 后计数为一,底层变成 " a "、“b”。连续创建迭代器读取两次,没有增加转换次数。随后对这份底层集合创建 transformedCollection,既有两个元素各转换一次,累计计数变成三,底层成为 a、b。
TransformedCollection 固定实现解释了时间点:transformingCollection 只创建包装,transformedCollection 先取得现有元素数组,清空底层,再逐项转换并写回。后者会修改调用者传入的集合,不是生成一份独立转换结果。
这段实现还决定失败的影响范围。如果转换函数在处理中抛异常,原集合可能已经被清空并回填部分结果,不能把工厂调用当成事务。本章没有执行失败转换实验,因此这里只按固定源码说明可能的部分状态;要求全有或全无时,可以先在独立结果中完成全部转换与校验,成功后再在受控入口替换。
转换发生在写入时,还意味着查询使用的是转换后的表示。实验 contains(" b ") 为 false,contains(“b”) 为 true;查询本身不会先帮忙 trim。这与“对所有传入参数都做规范化”的业务门面不同。删除同样应使用存储表示,或在应用接口明确统一规范化规则。
与读时惰性视图比较
Guava Lists.transform 在访问时调用转换函数。对同一个位置 get 两次,实验计数增加两次;没有缓存转换结果。Commons TransformedCollection 的例子则在 add 时调用一次,后续读取不再调用。两者都涉及 transform,但计算时点和共享状态不同。
| 方式 | 既有数据 | 新写入 | 重复读取 |
|---|---|---|---|
| transformingCollection | 不转换 | 经过包装时转换 | 读取已存结果 |
| transformedCollection | 创建包装时转换并写回 | 经过包装时转换 | 读取已存结果 |
| Guava Lists.transform | 保留源表示 | 按其视图契约处理 | 每次访问求值 |
从业务需要可以直接选时机。规范化后再存入目录,适合写入前转换;输出 DTO 依赖当前元素状态,可以采用读时映射,但昂贵转换应考虑显式物化。若转换包含副作用,重复读取可能重复执行,调用方不能把惰性视图当成已计算列表。
底层引用旁路在转换包装中仍存在。通过原 List 添加 " c " 不会触发 trim。包装内因此可能同时存在规范化和未规范化字符串,contains 等操作会按它们实际保存的内容判断。若必须保证统一存储表示,应集中写入口,而不是依赖调用方自觉选对引用。
可迁移模式是把计算时机写进接口契约。入库前转换、查询时转换和缓存计算结果,分别影响异常位置、重复成本及源状态变化传播。测试用调用计数提供直接证据,比根据类名中的 Transformed 推断“已转换”更可靠。
partition 的外层不可改不代表内层独立
ListUtils.partition 把源列表分成固定大小的分组。实验源为三个元素,分组大小为二;修改源索引零,第一组也读到新值。外层列表不支持 add,但第一组的 set 可以回写源列表。分组是视图,不是对每组复制一次数据。
ListUtils 固定源码通过源列表 subList 创建分组,因此继承子列表的共享关系。外层的不可修改限制只针对分组列表本身,没有冻结底层元素。拆批发送前如果源集合仍被改动,批次内容可能变化。
对于批量导入,明确的快照时刻可以放在解析与校验完成之后:先建立稳定输入列表,再按批次读取。若直接对外部持续修改的 ArrayList 分组,既可能改变内容,也可能触发迭代或子列表的修改检查。本章只对受控 set 验证共享,没有依赖并发修改异常作为正确性机制。
分组大小还需要单独验证为正数,并考虑最后一组不足整批的情况;这种参数校验不等于并发安全。集合工具解决分组索引计算,批次一致性由源数据的所有权和生命周期决定。
同步包装的锁必须覆盖完整遍历
SynchronizedCollection 的普通方法在其 lock 上同步,但 iterator 直接返回底层迭代器,要求调用方在外部同步整个遍历。本章通过一个测试用 ArrayList 子类观测锁:add 时记录 Thread.holdsLock(expectedLock),iterator 创建时也记录相同信息。
把 expectedLock 指向同步包装对象后,调用包装 add 得到 true,直接调用包装 iterator 得到 false。在 synchronized(guarded) 块中使用增强 for,iterator 创建时得到 true;直接调用原 source.add 又得到 false。这个实验没有依赖线程争抢,也没有把“没有抛并发修改异常”当作同步证据,而是直接观察实际监视器持有情况。
SynchronizedCollection 固定源码的普通工厂把 lock 设为 this;可接收显式锁的构造路径则保存指定 lock。若自定义子类或关联视图使用共享锁,应锁住约定对象,不能随便挑一个包装引用。JDK Collections.synchronizedCollection 也要求按其契约保护遍历,换成标准库不会消除这项责任。
只在 iterator() 调用瞬间加锁也不够。hasNext 与 next 发生在之后,整个循环应处于同一临界区,所有修改入口也必须使用相同锁。若另一线程持原 source 引用直接写入,它不参与该监视器协议,外层循环持锁仍无法约束这条旁路。
复合业务动作同样需要边界。例如先 contains 再 add,两个单独同步方法之间仍可能发生其他操作;需要“不存在才插入”时,应将整段流程放在适当临界区,或采用本身提供该原子操作的并发集合。本章没有并发负载与吞吐测量,不对锁竞争成本作定量结论。
装饰顺序也是可观察行为
同一底层集合叠加校验与转换时,还需要决定先检查原值还是先检查转换结果。例如原值只含空格,trim 后变成空串;检查“长度大于零”如果发生在转换前,会接受这份输入,发生在转换后则会拒绝。两个包装分别正确,不代表任意组合顺序都满足目标规则。
判断时可以沿一次 add 的调用路径逐层展开:外层首先执行自己的逻辑,再委托内层,最后到达底层。规范化之后必须非空的要求,应通过路径顺序或显式门面确保,而不是只确认两种包装都已创建。本章尚未执行叠加顺序实验,这一例子用于推导新增测试条件,不能计入现有四项通过数。
如果包装链已让异常位置、锁对象和规范化时机难以说明,集中到一个小型业务入口反而容易审查。该入口可以先计算新值、验证,再在同一同步边界内修改集合,并只暴露需求允许的读取能力。是否需要实时共享、是否复制元素,仍应单独决定,不能由入口封装替代语义选择。
实验与改动题
Chapter24Test 双 JDK 各通过四个方法,失败、错误、跳过均为 0。实验覆盖校验旁路和构造验证、既有数据转换时机、写入及读取调用次数、partition 共享、同步包装与迭代器持锁。原始日志、XML 与源码分支在 evidence/24。锁观测是功能检查,不证明所有并发调度或外部代码都遵守协议。
反例题:所有公开方法都返回包装后的集合,能否证明原集合无人可改?不能。原引用可能已在构造参数、闭包或其他字段中被保存。需要检查创建及传递过程,不能只审查返回语句。类似地,一个元素内部暴露可变列表,也可能绕过最外层包装。
改动练习:让目录模块只接收输入集合并建立内部副本,返回只读快照,外部继续修改输入。测试应断言内部状态不受影响,再加入可变元素检查浅复制边界。若需求仍是实时共享,则不应偷偷改成快照,而应明确统一写入口和同步协议。
| 需求信号 | 判断模式 | 验收动作 |
|---|---|---|
| 校验包装 | 枚举全部写路径 | 包装写入与底层写入对照 |
| 文本规范化 | 确定计算时点 | 既有数据、add、重复get计数 |
| 批次分组 | 识别子视图共享 | 改源与改组双向观察 |
| 同步集合 | 使用同一锁保护完整动作 | holdsLock与整段遍历 |
| 只读接口 | 区分能力限制和所有权 | 源引用及元素可变性检查 |

