Java常用类库-E02-原始数组数值边界与IO所有权
导入商品数量时有三种不同边界
商品导入流程从文件读入数量,经过数值转换,再交给使用 List
本章固定 Guava 33.5.0-jre,所有代码采用 Java 8 基线,分别在 Java 8 和 Java 21 执行。实验输入很小,目的在于验证边界,不测吞吐。文件来源用可控的 InputStream 与 Reader 实现,明确制造短读与读取异常,避免只有理想内存流的成功路径。
Ints.asList 是数组视图
输入数组为 [1,2,3],调用 Ints.asList 后得到 List
视图长度固定,add 被拒绝。它可以 set,并不意味着能够扩容;它不能 add,也不意味着不可修改。判断传递给校验器的对象是否安全,应分别检查内容修改与结构修改。只通过一次 add 失败来认定数据不可变,会遗漏 set 对调用方数组的影响。
固定源码的 IntArrayAsList 保存原数组和区间边界,get、set 通过这个数组访问元素,因此共享行为有直接实现依据。Ints.toArray 则提供另一份原始数组;实验修改这份新数组,不影响原先的 raw。复制消除了这组容器之间的数组别名,但需要承担新的存储与复制成本。Ints 固定源码
下面的完整示例展示调用边界:第三方校验器如果可能修改列表,调用者应决定允许修改原数组,还是先复制。不要在接口文档里只写“接收整数列表”,却让调用者不知道结果还会写回数组。
1 | |
这种视图并不把原始数组变成普通 ArrayList 的内部结构,也不能根据返回类型猜测内存布局。本章不提供装箱次数或内存节省数字。需要优化时,应先确定下游是否真的要求集合接口,再用适当的基准区分转换、遍历与修改成本。
收窄转换和算术溢出采用不同策略
把 long 数量收窄为 int 时,Java 强制类型转换会丢弃高位。对于超出 int 上界的 2147483648,Ints.checkedCast 抛 IllegalArgumentException;saturatedCast 则返回 Integer.MAX_VALUE。前者拒绝不合法的表示,后者把值限制到边界,两种结果代表不同业务决策。
若字段是导入商品的库存数量,静默饱和会让错误输入看起来像有效的大库存。若字段只是图形亮度或可接受截断的展示值,边界限制可能正是需要的策略。工具方法不能替业务判断哪一种正确;选择后应把异常或饱和值如何记录纳入导入结果。
加法溢出又是另一个问题。IntMath.checkedAdd(Integer.MAX_VALUE,1) 与 Java 8 的 Math.addExact 都抛 ArithmeticException。若项目只需要这一项检查,JDK 已经能表达契约,没有必要为了方法名称不同引入 Guava。IntMath、Math.addExact
检查必须发生在溢出之前。先用普通 int 加法计算,再把结果交给 checkedCast,原始数学结果已经丢失,后面的类型检查无法恢复。对于多个数量汇总,可以使用每一步的精确加法,或先在足够宽的表示中计算再检查目标范围;仍需考虑中间结果的范围。
| 场景 | API策略 | 本次结果 |
|---|---|---|
| long超出int范围 | Ints.checkedCast | IllegalArgumentException |
| 同一输入限制到边界 | Ints.saturatedCast | 2147483647 |
| int最大值再加1 | IntMath.checkedAdd / Math.addExact | ArithmeticException |
| 数组经列表set | Ints.asList | 写回原数组 |
舍入方向在负数上尤其容易混淆
整除并不总是向负无穷舍入。IntMath.divide(-7,3,DOWN) 得到 -2,DOWN 表示朝零;使用 FLOOR 得到 -3,FLOOR 表示朝负无穷。正数样本经常让两个模式看起来一致,负数能够暴露区别。本章把这两个值同时放入测试,避免只靠模式名称理解。
HALF_EVEN 在恰好一半时选择偶数结果,因此 5/2 得到 2,7/2 得到 4。它不是所有情况下都向下,也不是只看被除数的奇偶。UNNECESSARY 则要求结果无需舍入,7/3 会抛 ArithmeticException。这个模式适合表达“输入必须整除”,而不是给出近似结果。
源码先计算商和余数,根据结果符号与 RoundingMode 决定是否调整商。阅读这段分支有助于分清舍入与溢出检查:指定舍入模式不会自动把所有整数计算变成任意精度运算。对于金额、比率或需要小数精度的领域,仍应选择与业务单位一致的数值模型。
手算时可以先写出精确值,再确定方向:-7/3 约为 -2.333,朝零移动到 -2,朝负无穷移动到 -3。这个步骤比记忆一组正数案例更容易迁移到负数。实验不把舍入模式解释为财务政策,接口层还应说明单位和精度。
ByteSource 是可重新打开的来源
InputStream 是一次打开后的读取状态;ByteSource 描述如何打开流。实验的 ByteSource 每次 openStream 都创建新的 ShortInput,并记录打开次数。调用 source.read,再调用 source.asCharSource(UTF_8).read,两次分别得到完整字节与“商品”字符串,且打开次数为二。
ShortInput 的批量 read 每次最多返回一个字节,因此一次请求多个字节不会一次满足。这符合 InputStream 允许短读的契约。完整读取必须继续读到结束,不能把“本次返回数量小于缓冲区长度”误认为已经到文件结尾。Guava ByteStreams 的循环读取路径重建了完整 UTF-8 字节序列。ByteStreams
字符解码还需要跨读取边界保存状态。中文“商品”的 UTF-8 字节被拆成多次返回,asCharSource 使用指定编码读取仍得到原文本。把每一次单独返回的字节都直接 new String 再拼接,会错误处理跨块的多字节字符。字节读取与字符解码应在正确层次衔接。
可重新打开并不保证内容永远相同。文件路径对应的内容可能改变,网络来源也可能每次取得不同结果。本章的固定数组来源具有稳定内容,不能据此把任意 ByteSource 当成持久快照。若业务需要多次读取一致,应明确文件版本或先保存不可变副本。
关闭规则跟随资源的打开者
ByteStreams.toByteArray 接收调用者已经打开的流。实验读取完 borrowed 后,closed 仍为 false,由调用者随后 close。ByteSource.read 则在内部打开流,读取结束后自行关闭;测试在每次 source.read 后检查刚创建的流已经关闭。ByteSource.read
如果调用者直接执行 source.openStream,拿到的仍是需要自己关闭的资源。ByteSource 不是一个能在任意未来自动收回所有已打开流的管理器。这个区别适合写成接口所有权规则:谁打开、谁关闭,除非被调用接口明确承担关闭责任。
CharSource 实验同样每次只返回一个字符,成功读取多行文本后关闭 Reader。随后注入一个 read 立即抛 IOException 的 Reader,断言异常消息仍是 injected read failure,同时 close 计数增加。成功与失败两条路径都验证了资源收尾,不靠“没有报资源泄漏”推断已经关闭。
固定源码中,ByteSource.read 与 CharSource.read 都通过 Closer 管理内部打开的资源,finally 路径负责关闭。本章只注入读取异常,没有注入 close 自身再次失败;关闭异常与主异常的组合属于另一个应独立验证的契约,不能从当前计数实验推断所有异常优先级。CharSource.read
资源关闭也不等于读取规模受控。read 会把全部内容放入内存,面对没有可信大小上界的输入,需要先建立字节数或记录数限制,或者使用流式处理。本章只使用几个字符,不用巨大输入制造内存不足,也不把小数据成功外推成任意文件大小的保证。
来源抽象仍需要应用层限制
实现 ByteSource 时,每次 openStream 应返回独立的读取状态。若简单地把同一个已经消耗过的 InputStream 反复返回,第二次 read 可能直接遇到末尾,第一次调用关闭后第二次还可能使用已关闭资源。本章记录打开次数并每次创建新流,正是为了避免这个实现错误。可以重新打开的描述与已经打开的句柄,不应只因为都有 read 方法就混为一谈。
同一份输入经过字节限制后再做字符解码,还需要说明限制计量的是字节还是字符。中文 UTF-8 数据中,两者不相等;截断到任意字节边界可能切断一个字符。业务可以先限制原始字节数,再以具有错误处理政策的解码器读取完整内容,不能拿字符数量直接替代传输大小限制。
读取失败后的业务回滚也不属于 close 的职责。一个逐行导入器可能已经写入前十条商品,第十一条读取失败,即使 Reader 已关闭,前十条写入仍然存在。应选择整批校验后提交、分批事务或允许部分成功并返回进度。资源已释放与业务状态已恢复是两项独立验收。
数组视图与数值检查也会影响这种失败模型。校验过程若通过 view.set 原地修正数量,后面读取异常不会自动恢复原数组。需要失败原子性时,应在独立工作副本上处理,并只在全部校验通过后替换结果;允许部分修正时则应明确报告已处理范围。这里的“先复制”是为了定义修改边界,而不是把所有转换都机械地改成复制。
异常类型可以帮助区分表示边界和运算边界,却不应直接成为用户界面的全部信息。checkedCast 的 IllegalArgumentException 应携带字段名与原始值上下文,算术溢出应指出哪一步汇总失败,读取 IOException 应关联输入来源。保留原因和补充业务上下文能够同时满足诊断与稳定接口的需要。
把三种契约放回导入流程
导入接口可以分别定义:原始数量数组在校验期间只读;数量转换超界时返回字段错误;内部打开的文件在成功和失败时均关闭。这样每种失败都有明确责任,而不是出现错误后再猜是哪一个工具类导致。
三个完整测试在 Java 8 与 Java 21 均通过。复现说明给出运行入口;原始记录包含数组双向修改、数值边界、短读重建、打开与关闭行为。它没有覆盖所有原始类型、全部舍入输入或所有 I/O 实现。改动练习:把短读上限从一个字节改为两个,再加入空输入与末尾不完整字符。先约定无效编码应替换还是报错,再选用能够表达该策略的解码器并写测试。仅传入 UTF-8 名称,不等于已经定义了错误字节处理政策。
| 可迁移做法 | 适用边界 |
|---|---|
| 数组视图与独立复制分开验收 | 原始类型数据跨集合接口传递 |
| 将超界与舍入写成输入契约 | 数量转换、批次汇总、整数比例 |
| 按打开责任验证正常和失败关闭 | 文件来源、字符解码、导入流水线 |
JDK 已经能清晰表达的部分可以继续使用 JDK。Guava 的价值在于补充视图、明确数值政策与来源抽象;选择其中一项不要求把整个导入流程改写为 Guava 风格。真正需要统一的是输入、别名和资源所有权的约定。
