刚开始写 Java 时,我有一个很朴素的误解:程序出错,多看几遍代码就能看出来。真正坐在电脑前才发现,最折磨人的不是编译器报错,而是程序有时对、有时错。那时我写了一个成绩统计小程序,从控制台读入人数和分数,再输出平均分。输入三个人时正常,输入空行、负数或在数字后多敲一个空格,就会出现异常结果。
我最初的处理方式是不断加 System.out.println,每次换一组输入,打印也跟着换。半小时后屏幕上堆满日志,我仍然不知道哪一次结果对应哪组输入。后来我把那几组会失败的输入抄在纸上,给它们编号,问题突然变得可控了。
图 1:错误只有能够稳定重放,后面的假设和修改才有比较基础。
把输入和计算拆开
原来的程序把读取、校验、计算、输出全部写在 main 方法里。每想验证一次平均值,都要重新在控制台敲输入。第一步不是修算法,而是把纯计算抽出来:
static double average(int[] scores) {
if (scores.length == 0) {
throw new IllegalArgumentException("scores must not be empty");
}
long total = 0;
for (int score : scores) {
if (score < 0 || score > 100) {
throw new IllegalArgumentException("score out of range: " + score);
}
total += score;
}
return (double) total / scores.length;
}这个版本没有直接读键盘,也不负责打印。它只接受一组已经解析好的整数。这样我可以反复调用它,而不必把“输入解析失败”和“平均值算错”混在一起。
一次只验证一个假设
当时的一个错误是整数除法。total / scores.length 两边都是整数,结果会先截断,再赋给 double。我曾经同时改了变量类型、循环和输入逻辑,虽然结果变对了,却不知道究竟是哪一处起作用。
后来我给自己定了一条很笨但有效的规则:一次只改一个变量,并在修改前写下预期。
| 用例 | 输入 | 预期 | 它验证什么 |
|---|---|---|---|
| D01 | [80, 90, 100] |
90.0 |
正常求和 |
| D02 | [80, 81] |
80.5 |
不能发生整数截断 |
| D03 | [] |
明确报错 | 空集合语义 |
| D04 | [-1, 80] |
明确报错 | 分数范围 |
| D05 | [100, 100, 100] |
100.0 |
上边界 |
当 D02 从 80.0 变成 80.5,同时其他用例没有变化,才能说明强制转换修对了目标问题。调试从“我觉得这里不对”变成了一个可以被证伪的小实验。
日志要回答问题,不是展示变量
那时我也第一次体会到,打印所有变量不等于获得信息。真正有用的日志应该包含用例、阶段和关键值:
System.out.printf(
"case=%s stage=average count=%d total=%d result=%.2f%n",
caseId, scores.length, total, result
);看到 case=D02 count=2 total=161 result=80.00,问题几乎已经写在日志里。相反,如果只打印三行 2、161、80.0,过几分钟就不知道它们分别代表什么。
修复之后,把失败输入留下来
最初我会在结果正确后删掉调试代码,然后继续下一节课程。几天后相似问题再次出现,又要从头回忆。后来我把失败用例留在一个最简单的测试类里:
static void assertClose(double expected, double actual) {
if (Math.abs(expected - actual) > 0.0001) {
throw new AssertionError("expected=" + expected + ", actual=" + actual);
}
}
public static void main(String[] args) {
assertClose(90.0, average(new int[]{80, 90, 100}));
assertClose(80.5, average(new int[]{80, 81}));
}这还不是正规的测试框架,却已经有了回归测试最核心的价值:过去付过代价的错误,不应该只留在记忆里。
这套方法后来一直没变
几年后排查接口超时、内存泄漏或 Agent 工具调用失败,系统复杂了很多,步骤却没有本质变化:保存失败输入,建立最小复现,一次只改一个因素,用证据比较前后,最后把案例加入自动验证。
2016 年的我只是想把平均分算对。真正沉淀下来的不是那段 Java,而是一个很具体的习惯:如果一个错误不能稳定发生,就先不要急着解释它。