1. 现实问题:统计工具为什么越写越难改
成绩、库存或接口结果经常以一批数据出现。把求和、平均值、最高分都塞进 main,很快就会出现重复循环、变量互相覆盖和边界不一致。方法的价值不是让文件变长,而是把“输入是什么、返回什么、是否会修改输入”说清楚。
另一个高频误解是“Java 传引用”。Java 只有值传递:传基本类型时复制数值,传对象时复制引用这个值。复制的引用仍然指向同一个数组对象,所以方法可以改数组元素,但不能把调用方的变量改成指向另一个数组。
2. 最小可运行示例:输入数组,返回统计结果
public class ScoreSummary {
public static void main(String[] args) {
int[] scores = {88, 72, 96, 81};
System.out.println("平均分=" + average(scores));
normalizeFirst(scores);
System.out.println("首项被方法改为=" + scores[0]);
replaceArray(scores);
System.out.println("调用方数组首项仍是=" + scores[0]);
}
static double average(int[] scores) {
if (scores == null || scores.length == 0) {
throw new IllegalArgumentException("成绩不能为空");
}
int total = 0;
for (int score : scores) total += score;
return (double) total / scores.length;
}
static void normalizeFirst(int[] scores) {
scores[0] = Math.max(0, Math.min(100, scores[0]));
}
static void replaceArray(int[] scores) {
scores = new int[]{0};
}
}
average 只读数组并返回一个 double,normalizeFirst 修改对象内容,replaceArray 只修改方法内部的局部引用。三个方法的行为不同,方法名和返回类型应让调用者不需要猜测副作用。真实项目更适合返回不可变的 ScoreSummary 记录,而不是让调用方传一个可被到处修改的累加器。
3. 调用链与对象变化
main 创建数组对象,变量 scores 保存指向它的引用值。调用 average(scores) 时,参数槽得到一份相同的引用副本;循环通过该引用读取元素,返回计算后的基本类型值。调用 normalizeFirst 时,副本仍然指向原数组,所以写入下标 0 会被调用方观察到。
调用 replaceArray 后,方法内部的 scores 指向新数组,但调用方的 scores 槽没有改变,仍指向旧对象。这是“值传递”最好的实验。方法返回值是另一条边界:如果需要让调用方使用新对象,就显式 return newArray,不要期待给参数重新赋值能产生魔法效果。
4. 为什么这样设计
方法参数是契约,返回值是结果,副作用是额外成本。纯计算方法更容易测试、缓存和并行;修改共享数组虽然省一次复制,却把调用顺序变成隐含前提。小程序可以接受直接修改,但 Service 层和领域对象应尽量限制可变范围。
数组访问是连续内存语义的抽象,索引从 0 开始,length 是容量而不是最后一个索引。方法重载允许同一意图接受不同参数类型,但重载过多会让调用选择和装箱变得隐蔽;优先让方法名表达不同业务,而不是用大量重载模拟动态语言。
5. 项目落点:让方法成为业务单元
在博客系统里,可以将文章列表的标题提取、分页边界和标签汇总拆成独立方法,每个方法只接受它需要的数据。分页方法应返回 PageResult<T>,包含列表、页码和总数;不要返回一个裸数组再让 Controller 重新猜“空数组是没有结果还是查询失败”。
练习:实现 findIndex(int[] values, int target)、countEven(int[] values) 和 copySorted(int[] values)。先写空数组、null、没有命中和重复值测试,再决定是否返回 -1、OptionalInt 或抛异常。不同选择要在项目文档里写清楚。
6. 易错排查
NullPointerException:数组引用可能是 null,先决定 null 是非法输入还是空集合语义。ArrayIndexOutOfBoundsException:最后一个合法索引是length - 1,循环条件不要写成<= length。- 修改参数后调用方数据变化:检查传入的是数组/对象还是基本值,并记录方法的副作用契约。
- 方法返回值被忽略:编译器不一定警告,调用处要确认是否遗漏了新对象或计算结果。
7. 一页复习
方法看三件事:参数值是什么、返回值是什么、对象是否被修改。Java 传的是值;对象参数传的是“引用值的副本”,所以能改内容,不能改调用方变量的指向。数组适合固定顺序批量数据,复杂业务结果应升级为有名字的对象和清晰的返回契约。
可以用一个别名实验把值传递说清楚:int[] original = {1, 2}; int[] alias = original; 之后通过 alias 写下标 0,original 读到 9,因为两个变量保存的是同一个数组引用值;int[] copy = original.clone(); 之后改 copy,original 不变,因为堆上有两个数组对象。方法参数只是再复制一份 alias,并没有创造第三种传递机制。
方法契约最好写成输入、返回和副作用三行。average 的输入是非空成绩数组,返回 double,不修改数组;normalizeFirst 输入是可写数组,无返回值,会修改第一个元素;copySorted 返回新数组,不修改输入。这样在项目中的 domain/statistics 包里,每个方法都能对应一组测试,调用方无需打开实现文件猜副作用。
错误排查也要按调用链:平均值为整数时先查强制转换发生在除法之前还是之后;修改后调用方没变化时检查方法是否接住了返回的新数组;结果长度错误时核对空输入和过滤条件;数组越界时把索引、length 和目标元素同时打印。不要通过把异常 catch 成空数组来掩盖输入契约被破坏。
练习:为 copySorted 规定 null、空数组、重复值和负数的行为,并用 Arrays.copyOf 保证输入不被修改。再写一个 PageResult<T>,保存 items、page、size 和 total,用 List.copyOf 固定返回快照。比较裸数组、List 和结果对象在 Controller 输出中的可读性,记录为什么最终选择其中一种。
数组方法的输入输出表还应记录容量和别名:average 读四个元素返回 84.25;空数组没有平均值,应返回错误而不是 0;normalizeFirst 改变原数组第一个位置;replaceArray 只改变方法内部引用。对象变化可用 System.identityHashCode 辅助观察,但它只是身份线索,不能代替 equals 的业务判等。
当数据量和操作变复杂时,数组不一定是最终容器。分页结果、标签集合和批量 SQL 参数更适合 List;固定长度的算法输入才保留数组。项目文件里将算法方法放在 domain/statistics,将数组/列表转换放在 adapter,可以避免数据库结果直接泄漏到计算代码。
错误排查要区分“结果错”和“输入被改”:先复制输入做快照,再调用方法并比较快照;若结果错,打印总和、长度和强转位置;若别名污染,查是否把内部数组直接返回;若 null 被静默转成空数组,确认调用方是否失去了区分“没有数据”和“数据读取失败”的能力。练习是给每个方法写前置条件和后置条件。
方法练习完成后,把每个方法的契约写在测试名称中,例如“空数组拒绝”“复制排序不修改输入”“修改元素会被调用方观察”。这些名称本身就是调用链边界,能防止后来为了省一次复制而悄悄改变副作用。
验证清单:给 findIndex 依次传入空数组、单元素命中、重复目标和 null,核对返回值以及异常/错误结果是否符合约定;给 copySorted 保存调用前快照,调用后同时检查排序结果和原数组顺序。再用一个方法把数组参数重新指向新数组,确认调用方的引用没有被替换。复盘时画出栈上的参数变量和堆上的数组对象,标出哪些箭头共享同一对象,才能把“传值”从口号变成可观察的调用链。
进阶附录:可变参数、递归与栈
int... values 在调用端表现得像多个参数,方法内部实际收到一个数组;它适合少量可选项,不适合替代集合。递归每次调用都会创建新的栈帧,必须有能到达的基线条件;深度过大可能栈溢出。后端分页和树遍历中,先估算深度,再决定递归还是显式栈。
本课按「Java 21 数组、方法调用与参数语义」的学习范围组织,正文与示例均为本站原创整理。