Reading 13: 调试(Debugging)

目录 · ← l12 · l14 →

Reading 13: 调试(Debugging)

说明:本讲 sp22 原版使用 TypeScript,本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版)原文保持一致。原文中出现在 Java 中不存在的机制(如 console.logRangeError)已替换为 Java 对应写法(System.out.printlnArrayIndexOutOfBoundsException)。

概述

本讲要解决的问题是:当 bug 已经写进代码、测试已经失败、用户已经报告了异常时,我们该如何系统地把它找出来,而不是靠盯着屏幕发呆或随机改代码。核心方法是把调试当作科学方法来执行:重现失败 → 观察数据 → 提出假设 → 设计最小侵入的探针实验 → 根据观测修正假设;在此过程中用二分查找程序切片(slicing)差分调试(delta debugging)来缩小搜索空间,用断言(assert)与 checkRep()把 bug 的暴露点尽量推近它的源头。它与「Safe from bugs」直接相关——调试就是消灭 bug 并用回归测试防止它复活;同时它也服务于「Easy to understand」(受控的探针与最小作用域让推理更简单)与「Ready for change」(找到 bug 后判断是编码错误还是设计错误,并做定向修复而非贴补丁)。注意:调试是最后手段,不是第一手段——本讲的所有技巧都是为了在 Reading 09(Avoiding Debugging)的 fail-fast 设计与测试防线失效时兜底。

核心概念与设计原则详解

系统化调试(Systematic Debugging)

  • 定义与目的:把「找 bug」这个活动从随机的、靠灵感的过程,变成一个可重复、可记录、可积累的工程流程。它解决的是「安全(Safe from bugs)」问题:随机的调试方式会引入新的 bug(治标不治本)、会浪费大量时间,还会让你在不理解原因的情况下「碰巧」让程序不再报错。
  • 直观解释(”它是什么?”):想象修水管。门外汉会到处敲打,看看哪里不漏了;专业水管工会先确定漏水点,再判定是接头松了还是管子裂了,然后只动那一处。系统化调试就是「先定位、再理解、最后修复」,而不是「先修、再看还漏不漏」。
  • 关键规则与最佳实践
    • 采用科学方法的四步循环:Study the data → Hypothesize → Experiment → Repeat
    • 使用 10 分钟法则:如果已经用非系统的方式找了 10 分钟,立即停下来,转向科学方法。
    • 把调试过程从脑子里搬到纸面上:写下假设、实验、预测、观测四栏。
    • 先让系统进入「可复现的失败状态」,再谈修复。
    • 复现 → 理解原因 → 修复 → 加回归测试,四步都不能跳。

重现 bug(Reproduce the Bug)

  • 定义与目的:找到一个小的、可重复的测试用例,让失败稳定地出现。它服务于「安全」:没有稳定失败,你的每一次「修改后的成功」都可能是巧合;有了稳定失败,你才能验证修复真的起了作用。同时它服务于「易理解」:小用例让数据流一目了然。
  • 直观解释(”它是什么?”):把莎士比亚全集(100,000 行、800,000 多个词)喂给 mostCommonWord(),得到一个莫名其妙的结果 "e"。正常手段(打印调试、断点调试)在这种规模下几乎不可用。聪明的做法是不断缩小输入:前半部莎士比亚还出错吗?(二分查找!)单独一部戏呢?单独一段台词呢?直到得到 "c c b" 这样的小用例。
  • 关键规则与最佳实践
    • 优先用二分查找缩小输入规模:一半、四分之一、单个函数、单个语句。
    • 缩小后的用例要找仍能触发同一个(或极相似)bug 的最小输入,而不是「最简的单测」。
    • 缩小用例的过程本身就是科学方法的应用:观察数据、假设「可能与出现次数有关而与具体单词无关」、用 "a a a b" 做实验。
    • 找到并修复后,回到原始的大输入确认修的是同一个 bug
    • 修复后把这个用例放进回归测试套件(regression suite),让 bug 永不再现。
    • 遇到 GUI 或多线程程序时bug 可能难以稳定复现,此时要先设法固定时序或加日志,再谈重现。

二分查找定位 bug(Binary Search)

  • 定义与目的:在「问题可能出现的位置序列」(输入规模、数据流阶段、版本历史)上每次砍掉一半,用 O(log n) 次实验定位 bug。它服务的是调试效率——把「概率上要靠运气」变成「有保证的对数级收敛」。
  • 直观解释(”它是什么?”)mostCommonWord() 的数据流是 text → splitIntoWords → countOccurrences → findMostFrequent → winner。如果在 countOccurrences() 里抛异常,那么它下游findMostFrequent() 根本还没执行,可以整段排除——搜索空间立刻小了一半。此后再把剩下的部分对半砍,一次实验就能判断 bug 在前半段还是后半段。
  • 关键规则与最佳实践
    • 把程序视为数据流(一系列算法步骤),而不是一堆行号;这样一次就能排除整段代码。
    • 在数据流的中点插探针(打印、断言、断点),看该处的值是好的还是坏的。
    • 探针的结论必须能二值化:好 → 往下游找;坏 → 往上游找。
    • 输入规模也能二分:把 800,000 词的输入砍成 400,000、200,000……直到失败消失,再取回上一层。
    • 版本历史上同样可以二分:用版本控制系统(Reading 05)取出最近一次仍能通过测试的版本,在「工作版本」与「失败版本」的差异中定位引入 bug 的提交。

切片(Slicing)

  • 定义与目的:找出「为计算某个具体值做出贡献」的那些代码行,这个集合就是该值的切片。导致该值出错的 bug 必然落在切片内,因此切片就是你的搜索空间——它同时告诉你「bug 可能在哪儿」和「bug 不可能在哪儿」。
  • 直观解释(”它是什么?”):假设局部变量 x 不应为负,但某处的调试打印显示它是负数。那么所有直接改写 x 的行、所有参与计算被加进 x 的值(例如 bonus)的行、所有控制这些语句是否执行的条件与循环(if (isWorthABonus(s))for (final Sale s : salesList))、以及这些控制语句所依赖的数据来源,统统属于切片。而方法里那些与 x 无关的 ... 代码,则可以放心排除。
  • 关键规则与最佳实践
    • 切片可以在脑中完成,不需要工具;先写下「坏值的名字」,再顺着赋值与控制流往回追。
    • 不可变性(immutability)极大加速切片:看到 final int bonus = getBonus(); 时,你可以立刻停止追踪——没有任何其他行能进入 bonus 的切片。而 final Sale sSale 是可变类型,你还得继续检查后面有没有人对 s 或其他别名调用了 mutator。
    • 作用域最小化(scope minimization)同样加速切片:局部变量的切片就在附近;实例变量要把整个类纳入搜索;全局变量(static 可变字段)则要把整个程序纳入搜索。
    • 好设计让切片更省力,坏设计让切片代价爆炸——这是「设计影响调试效率」的直接体现。
    • 切片得出的结论应转化为具体的、可检验的假设,例如「getBonus() 返回了负数」「isWorthABonus() 对太多销售返回 true 导致 x 溢出」。

差分调试(Delta Debugging)

  • 定义与目的:通过对比成功的运行与失败的运行之间的差异来定位 bug。它解决的是「知道哪两种情形一好一坏,但不知道原因」的问题。
  • 直观解释(”它是什么?”):也许 mostCommonWords("c c, b") 出错,而 mostCommonWords("c c b") 正常。两者只差一个逗号。于是问:哪些代码在通过的用例里执行了、在失败的用例里被跳过了(或反之)?差异点就是最可疑的地方。另一个版本是「时间上的差分」:回归测试开始失败时,取出最近一次仍然通过的版本,系统地探索两次之间的代码改动,直到找到引入 bug 的那一次改动。
  • 关键规则与最佳实践
    • 主动构造成对的用例:一个通过、一个失败,且两者尽量只差一点点。
    • 用版本历史做时间维度的差分,是定位「昨天还好好的」这类回归 bug 的首选方法。
    • 差分调试与切片互为补充:切片给出静态的候选集合,差分调试给出动态的差异集合。
    • 自动化工具(delta debugging、slicing 工具)存在,但课程明确指出它们目前并不实用,手工推理仍是主力。

假设的优先级(Prioritize Hypotheses)

  • 定义与目的:不同代码的出错概率差别巨大,先怀疑最可能出错的部分,能把实验次数降到最低。
  • 直观解释(”它是什么?”):可信度从低到高大致是:你刚写的新代码 < 老旧且被充分测试的代码 < Java 标准库 < 编译器与运行时 < 操作系统 < 硬件。就像家里的灯不亮,先怀疑灯泡和开关,最后才怀疑发电厂。
  • 关键规则与最佳实践
    • 优先怀疑最近改动的代码、很少被测试覆盖的代码路径。
    • 只有在你确实怀疑某个模块时,才做「替换组件」实验(例如把 binarySearch() 换成简单的 linearSearch()、把 ArrayList 换成 LinkedList、换 JDK 版本、换操作系统、换机器)。
    • 替换组件很费时间,不要盲目轮换未出错的组件。
    • 代码覆盖率工具可以用来发现问题:quadraticRoots 有时给出错误答案,也许是测试根本没覆盖到某些分支。
    • 优先级排序的作用是「让每一次实验都有最大的信息增益」。

探针(Probes):打印、日志、断言、调试器

  • 定义与目的:实验的形式就是「观察系统」;最好的实验叫探针——尽可能少扰动系统的温和观察。它服务于「安全」:改动越少,你越不会在调试过程中引入新 bug(海森堡 bug)。
  • 直观解释(”它是什么?”):医生诊断时会先量体温、听心跳这些无创检查(探针),而不是先开刀(修改代码)。四种常用探针:
    1. 打印语句:适用于几乎任何语言;缺点是需要事后撤销,容易在代码里留下成堆的垃圾。写的时候要写清楚上下文,例如打印 start of calculateTotalBonus,而不是在 15 个地方都打印同一句 hi!,否则你分不清哪句是哪句。
    2. 日志(logging):把有信息量的打印语句永久留在代码里,用一个全局开关(DEBUG 常量或日志级别)控制开关。更成熟的框架(Java 生态中的 Log4j/SLF4J,属补充说明)可以把日志写到文件或网络服务器、记录结构化数据,并且可用于生产部署环境——大型系统没有日志几乎无法运维。
    3. 断言:直接检查变量值或内部状态,不需要人工读输出。优点是可以保留在代码中(若该断言普遍为真),缺点是Java 默认不开启断言,你必须用 -ea(enable assertions)运行,否则断言根本不执行,会出现「断言看起来通过了其实没运行」的欺骗。
    4. 调试器断点:在指定行暂停程序,单步执行并查看变量值。区分 Step Over(执行完当前行,停在下一行)与 Step Into(进入当前行调用的方法内部,粒度更细);也常用 Resume/Continue 配合断点快速跳过无关代码。调试器功能强大,值得专门学习。
  • 关键规则与最佳实践
    • 先做「只观察不修改」的探针,不要一上来就做「顺手把 bug 改掉」的实验。
    • 打印语句要写清位置与语义;同一会话中保持探针可识别。
    • 提交前清理所有调试探针:注释掉的代码、临时打印、只对某个用例成立的断言都要撤销。
    • 找到原因并修复后,把「普遍为真」的检查转成正式的 checkRep() 或断言保留下来。

assertcheckRep() 缩小 bug 范围(Assertions and checkRep)

  • 定义与目的:断言可以把「坏值被发现的位置」推到离它的产生点更近的地方。它服务于「安全」与「易理解」:越多不变量被显式检查,故障点就越接近 bug 源,同时不变量本身也成为可读的文档。
  • 直观解释(”它是什么?”):如果 x 永远不该为负,就在每次可能改动 x 的地方后面加 assert x >= 0;。这样一旦某次加法出错,程序会立刻在那里炸掉,而不是等到几百行之后用一个荒唐的结果(或者一个莫名其妙的 ArrayIndexOutOfBoundsException)间接暴露。
  • 关键规则与最佳实践
    • 把不变量写成 checkRep() 私有方法(表示不变量 RI 的代码化,见 Reading 11),在每个构造器、每个 mutator 的出口、每个 observer 的入口调用它。
    • 断言应表达规格与不变量,例如 assert fromCurrency != null && toCurrency != null;assert !fromCurrency.equals(toCurrency);
    • 不要写只在某个特定测试用例下成立的断言并把它留下——那会把「调试期的观察」误当成「类型的不变量」。
    • 记住 Java 断言默认关闭;测试时务必加 -ea(JUnit 配置中也要打开),否则你的 fail-fast 防线是纸做的。
    • 断言与 checkRep() 属于同一个思想:让错误在最早、最局部的位置失败,这与 Reading 09 的 fail-fast 设计一脉相承。

一次只处理一个 bug(One Bug at a Time)

  • 定义与目的:调试过程中经常顺手发现别的问题(读自己代码时发现明显的错误——相当于一次自我代码评审)。此时要保持专注,避免「递归调试」。
  • 直观解释(”它是什么?”):你的大脑栈很浅。开始调一个「顺带发现」的 bug,你可能就很难「弹栈」回到原来的 bug,而且你随手改的代码可能影响原实验的可解释性。
  • 关键规则与最佳实践
    • 维护一份 bug list(纸、文本文件,或团队用的 issue tracker),把新发现的问题记下来,稍后处理。
    • 思考新问题是否是当前 bug 的信息性数据(是否带来新假设),但不要立刻开始调试它。
    • 调试期间不要随意修改代码;所有改动都应是为当前 bug 设计的、受控的探针。
    • 如果新问题妨碍了当前调试(例如导致程序间歇性崩溃,实验无法稳定执行),则应重新排序:放下当前 bug(撤掉探针、确保记录在 bug list 上),先解决新 bug。

不要过早修复(Don’t Fix Yet)

  • 定义与目的:把「实验」误做成「修复」是调试中最常见、代价最大的错误之一。
  • 直观解释(”它是什么?”):看到 ArrayIndexOutOfBoundsException,马上加 try/catch 吞掉,或先 if (index < size) 绕过——程序不报错了,但你并不知道为什么会越界。这是「治标不治本」,把疾病藏了起来。
  • 关键规则与最佳实践
    • 先理解异常被抛出的原因,再改代码。
    • 警惕「猜—试」式编程:它会产生难以理解的复杂代码,并且掩盖真正的 bug。
    • 只有当假设被实验证实、原因被理解之后,才写下修复。
    • 修复前先问一句:这是编码错误(拼错变量、参数顺序颠倒)还是设计错误(接口规格不足/不充分)?设计错误意味着要回头看设计,至少检查该接口的其他客户是否也中招。
    • 修复时顺带搜索同类错误(这里除零了,别处是否也除零?)并评估修复的副作用(会不会破坏别的代码?)。

审计轨迹、检查插头、以及”不是你修好的就不算修好”(Audit Trail / Check the Plug / If YOU didn’t fix it, it isn’t fixed)

  • 定义与目的:这三条来自 Agans 的《调试九条规则》,解决的是「调试过程本身失控」以及「bug 只是暂时隐藏」两类问题。
  • 直观解释(”它是什么?”)
    • Keep an Audit Trail:只要一个 bug 花了超过几分钟、或超过了 study–hypothesis–experiment 循环的两三次迭代,就必须动笔。记录:当前假设、正在做的实验、实际观测(测试通过还是失败、程序输出特别是你自己的调试信息、任何栈轨迹)。缩小用例与差分调试往往需要多轮迭代,不记下来你很快就会忘记「刚才试的是 "c c b" 还是 "c b",它到底通过没有」。
    • Check the Plug:如果一切都不合逻辑,就质疑自己的假设。机器按了开关却不启动,也许该检查的不是开关而是插座有没有电。在编程中这意味着:确认你运行的代码(class 文件)与你阅读的代码(源文件)一致——从仓库拉取最新版本,删除所有编译产物并全量重新编译(Eclipse 中是 Project → Clean)。
    • If YOU didn’t fix it, it isn’t really fixed:系统突然「好了」,而你说不出它为什么好,那它多半没好——bug 只是被环境变化掩盖了,随时可能重新抬头。并发 bug 尤其如此(Reading 21)。这正是系统化调试的价值:先复现失败、再理解原因、再修改、最后看到系统因为你这次改动从失败转为成功,你才有资格说它被修好了。
  • 关键规则与最佳实践
    • 每轮循环都写下假设、实验、预测、观测四要素。
    • 观察与预测不符 → 否定该假设;相符 → 精化假设、继续缩小范围。
    • 出现「不合逻辑」的现象时,第一件事是检查构建产物是否最新。
    • 不要因为「现在不报错了」就认为任务完成。

修复 bug 的收尾流程(Fix the Bug)

  • 定义与目的:找到原因之后的第三步是设计修复,并且要做得干净、彻底、可回归。
  • 直观解释(”它是什么?”):修复不是「贴个补丁走人」,而是一次小型的设计评审 + 清洁 + 加固。
  • 关键规则与最佳实践
    • 撤销调试探针:注释掉的代码、临时打印语句、为加速调试做的改动,在提交前全部撤销。
    • 补一个回归测试:把该 bug 的用例加入回归套件,并跑完整测试,确保 (a) bug 已修复,(b) 没有引入新 bug。
    • 寻找相关 bug:检查同类错误是否在别处出现,让代码对未来同类 bug 免疫。
    • 评估副作用:这次修复会不会破坏其他调用方?
    • 换个视角(Get a fresh view):向别人(哪怕对方完全不懂)解释你的代码为什么应该工作、实际却在做什么,这就是橡皮鸭调试(rubber-duck debugging)。6.031 的助教与同学是更好的对象;在网上提问时,你最小化 bug 的努力正好帮你写出一份最小可复现示例(minimal reproducible example)。
    • 睡一觉(Sleep on it):疲惫的调试者效率极低,用一点延迟换取效率是划算的。

代码示例与对比分析

场景 1:调试完成后,代码里还留着调试探针

❌ 错误代码

/**
 * Convert from one currency to another.
 * @param fromCurrency currency that customer has (e.g. DOLLAR)
 * @param fromValue value of fromCurrency that customer has (e.g. $145.23)
 * @param toCurrency currency that customer wants (e.g. EURO).
 *                   Must be different from fromCurrency.
 * @return value of toCurrency that customer will get,
 *         after applying the conversion rate and bank fee
 */
public static double convertCurrency(Currency fromCurrency, double fromValue, Currency toCurrency) {
    assert fromCurrency != null && toCurrency != null;
    assert ! fromCurrency.equals(toCurrency);

    double rate = getConversionRate(fromCurrency, toCurrency);
    System.out.println("conversion rate is " + rate);      // 调试探针:忘了删

    double fee = getFee();
    assert fee == 0.01;   // right now the bank charges 1%   // 只对今天成立的旧观察

    return fromValue * rate * (1-fee);
}

【错误代码的问题】

  1. System.out.println 会在生产环境持续污染标准输出,在批处理或服务端程序中可能造成日志洪水、性能损耗,且需要重新打包才能去掉。
  2. assert fee == 0.01; 把「当前银行费率」这个会变化的外部事实写成了类型不变量。银行明天把费率改成 1.5%,这条断言会在正确的代码上失败,成为一颗定时炸弹(而且开启 -ea 时才炸,属于典型的「只在测试机上崩」的 bug)。
  3. 断言与打印混在业务逻辑中,读者分不清哪些是规格的一部分、哪些是调试残留,破坏「Easy to understand」。
  4. 一旦有人为了「让测试过」而把断言注释掉,团队就失去了这类 fail-fast 检查的可信度。

✅ 正确代码

/**
 * Convert from one currency to another.
 * @param fromCurrency currency that customer has (e.g. DOLLAR)
 * @param fromValue value of fromCurrency that customer has (e.g. $145.23)
 * @param toCurrency currency that customer wants (e.g. EURO).
 *                   Must be different from fromCurrency.
 * @return value of toCurrency that customer will get,
 *         after applying the conversion rate and bank fee
 */
public static double convertCurrency(Currency fromCurrency, double fromValue, Currency toCurrency) {
    // 前置条件:与规格说明一致,普遍为真,保留下来长期防御
    assert fromCurrency != null && toCurrency != null;
    assert ! fromCurrency.equals(toCurrency);
    checkRep();

    double rate = getConversionRate(fromCurrency, toCurrency);
    double fee = getFee();
    assert fee >= 0 && fee < 1;   // 真正的不变量:费率落在合法区间

    double result = fromValue * rate * (1-fee);
    return result;
}

/** Rep invariant: 换汇结果不得为无穷大或 NaN(参数合法时)。 */
private static void checkRep() {
    // 这里的检查只依赖参数与常量,不依赖外部世界的当前取值
}

【为什么这样更好】 打印语句被删除,标准输出恢复干净;fee == 0.01 这种「只对当前世界成立」的观察被改写成「对任何时刻都成立」的区间不变量 0 <= fee < 1,因此断言可以长期保留并真正发挥 fail-fast 作用;把断言集中成 checkRep(),让「哪些条件是这个类型的规格」变成可读的文档。

【代码对比解说】 关键区别在于断言的内容是「规格」还是「观察」。调试期间我们常常临时写下只对某个用例成立的断言(例如「此时 rate 应该是 1.13」),它们是实验工具,实验结束必须撤销。而像「两个货币参数不能相同」「费率必须落在 [0,1)」这样的性质,是从规格直接推出来的不变量,可以永远留着。错误的做法混淆了两者,导致要么断言被全线关闭,要么生产环境频繁误报。

【设计原则透视】 这正是 Reading 09(Avoiding Debugging)中「fail fast + 显式不变量」的实践:checkRep() 是 Reading 11 里表示不变量(RI)的可执行形式,assert 是前置条件(precondition)的可执行形式。把断言写在方法入口/出口,等于把规格的抽象边界变成运行时检查点——bug 一旦跨越边界,就会在边界处被立即抓住,而不是在系统的另一端以奇怪的形式暴露。同时,Reading 06/07 的规格说明告诉你「哪些条件允许被断言」:断言只能覆盖spec 保证的东西,不能覆盖实现细节(implementation-specific),否则就把客户端不该依赖的实现暴露了出来。


场景 2:用一个笼统的 catch 把真正的 bug 吞掉

❌ 错误代码

/**
 * @return true if and only if word1 is an anagram of word2
 *         (i.e. a permutation of its characters)
 */
boolean isAnagram(String word1, String word2) {
    if (word1.isEmpty() || word2.isEmpty()) {
        return word1.isEmpty() && word2.isEmpty();
    }

    try {
        word1 = sortCharacters(word1);
        word2 = sortCharacters(word2);
        return word1.equals(word2);
    } catch (StackOverflowError e) { return false; }   // 掩盖了真正的 bug
}

【错误代码的问题】

  1. catch (StackOverflowError e) { return false; } 意味着:只要 sortCharacters() 因为递归不收敛而爆栈,这个方法就悄悄返回 false。调用者以为「这两个词不是变位词」,实际上程序已经崩过一次——错误的答案比崩溃更危险
  2. 它让「sortCharacters 的递归步没有缩小问题规模」这个真正的 bug 永远不被发现,StackOverflowError 也不会出现在日志或堆栈轨迹里,调试所需的「数据」被销毁了。
  3. StackOverflowErrorError 而非 Exception)做控制流,违反了 Java 的异常设计意图;在生产代码里捕获 Error 通常意味着放弃了程序的可恢复性。
  4. 这段代码属于典型的「猜测—测试式编程」产物:每次报错就加一个 return,代码越来越复杂,却离正确越来越远。

✅ 正确代码

/**
 * @return true if and only if word1 is an anagram of word2
 *         (i.e. a permutation of its characters)
 */
boolean isAnagram(String word1, String word2) {
    if (word1.isEmpty() || word2.isEmpty()) {
        return word1.isEmpty() && word2.isEmpty();   // 空串只与空串互为变位词
    }
    word1 = sortCharacters(word1);
    word2 = sortCharacters(word2);
    return word1.equals(word2);
}

/** @return a string with the same characters as s, in ascending order */
private static String sortCharacters(String s) {
    char[] chars = s.toCharArray();
    java.util.Arrays.sort(chars);   // 迭代实现:不会爆栈,也没有递归不收敛的风险
    return new String(chars);
}

【为什么这样更好】 去掉了 try/catch 这块「创可贴」,让任何残留的问题以真实异常的形式暴露出来(配合堆栈轨迹就能定位);把 sortCharacters() 换成使用 Arrays.sort() 的迭代实现,从根上消除了「递归步不缩小问题规模导致 StackOverflowError」这一原因,而不是掩盖症状。

【代码对比解说】 这里体现了「Don’t fix yet」与「理解原因后再修」的分工:错误的写法做了两个动作——先猜(用 try/catch 掩盖),再试(看测试过不过)。正确写法先问「栈为什么溢出」,答案是 sortCharacters 的递归没有收敛(例如把 substring(1) 误写成 substring(0),永远处理同一个字符串)。找到这个原因之后,修复方式有两种:修好递归的收敛性,或者干脆改用库里的迭代排序。两者都比 catch 更好,因为两者都保留或消除了原因,而不是遮蔽它。

【设计原则透视】 这个例子把 Reading 14(Recursion)的「递归步必须把问题变小」与 Reading 13 的调试纪律连在一起:递归不收敛在迭代程序里表现为死循环(可能耗尽 CPU 但不报错),在递归程序里表现为 StackOverflowError——递归的 bug 有时失败得更快,这反而对调试有利,前提是你不把它 catch 掉。另一方面,isAnagram 的规格(@return true iff ...)是一个完整的行为规格:任何让它在「无法判断」时返回 false 的实现都违反了规格,因为 false 是一个有明确含义的答案。把 Error 当作 false 返回,等于让实现偷偷返回一个错误的抽象值,这是 ADT 设计中最严重的背离(Reading 10/11)。


场景 3:探针粗暴、作用域过大,妨碍切片

❌ 错误代码

public class BonusCalculator {
    static int x = 0;                       // 全局可变状态:切片范围 = 整个程序
    static final List<Sale> salesList = new ArrayList<>();   // 可变别名,被多个方法共享

    int getBonus() { return computeBonus(); }
    boolean isWorthABonus(Sale s) { return s.amount() >= 100; }

    int calculateTotalBonus(List<Sale> sales) {
        salesList.addAll(sales);
        x = 0;
        int bonus = getBonus();             // 非 final:后续任何一行都可能改写它
        for (Sale s : salesList) {
            System.out.println("hi!");      // 15 个地方都打印 "hi!",分不清谁是谁
            x += bonus;
            x = x - 0;                      // 调试时随手加的探测语句,忘了删
            bonus = bonus;                  // 为了打断点加的假赋值
        }
        return x;
    }

    int computeBonus() { return -5; }       // 真正的 bug:奖金为负
}

【错误代码的问题】

  1. static int x 使 x 的切片范围扩大到整个程序——任何类、任何线程都可能改它,你无法用「读代码」的方式排除任何一行。
  2. bonus 不是 final,观测者必须继续扫描循环体内部,确认没有别的地方改写 bonus;如果它是 final int bonus = getBonus();,切片在那一行就结束了。
  3. System.out.println("hi!") 重复 15 次且没有上下文,输出无法对应到具体位置;x = x - 0;bonus = bonus; 这类探测语句不但无信息量,还会改变程序行为、干扰后续实验的可解释性。
  4. salesList 是共享的可变静态字段,多个调用之间会互相污染,实验无法重复——这直接违反「重现 bug」的要求。

✅ 正确代码

public class BonusCalculator {
    private int x = 0;                                  // 实例状态,作用域限于本类

    private int getBonus() { return computeBonus(); }
    private boolean isWorthABonus(Sale s) { return s.amount() >= 100; }
    private int computeBonus() { return 10; }

    /** Rep invariant: x >= 0. */
    private void checkRep() {
        assert x >= 0 : "x went negative: " + x;
    }

    /**
     * @param salesList 需要计算奖金的销售列表
     * @return 总奖金,要求 salesList 中每笔销售都非 null
     */
    public int calculateTotalBonus(final List<Sale> salesList) {
        x = 0;
        final int bonus = getBonus();               // final:切片到此为止
        checkRep();

        for (final Sale s : salesList) {            // s 不可重新赋值
            if (isWorthABonus(s)) {
                x += bonus;
                checkRep();                         // 探针:坏值刚产生就被抓住
            }
        }
        // 描述性探针(调试期临时使用,事后删除)
        // System.out.println("end of calculateTotalBonus: x=" + x + ", bonus=" + bonus);
        checkRep();
        return x;
    }
}

【为什么这样更好】 x 是实例字段而不是全局字段,切片只需在本类内搜索;bonussfinal/循环变量,观测者在读到声明那一行就知道「没有别的行会影响它」;checkRep() 在每个可能改坏 x 的点上做检查,于是 bug 会在第一次变负的那一刻被抓住,而不是等到最后返回一个负数;调试打印语句带上下文并明确标注为事后删除,输出因此可读、可解释。

【代码对比解说】 两种写法功能「差不多」,但可调试性完全不同。错误的写法让「找出谁把 x 变负」变成在整个程序里的大海捞针;正确的写法把搜索空间压缩到几行代码内。再看二分查找:在这个循环里,我们可以只在循环中点(例如第 500 笔销售)插一个 checkRep(),如果此时 x 已经为负,bug 在前半段;否则在后半段——每次砍掉一半。这种「探针 + 二分」的组合,正是本讲的核心套路。

【设计原则透视】 这里把 Reading 08(Immutability)与调试效率显式联系起来:不可变引用(final)让切片在声明处终止,可变类型(如 Sale)则迫使你继续检查别名上的 mutator 调用。同时,作用域最小化(scope minimization)是 Reading 04(Code Review)与 Reading 10(ADT)中的设计美德,它在调试阶段的回报就是「搜索空间更小」。此外,checkRep()assert x >= 0 把 Reading 11 的表示不变量从注释升级为可执行的契约,保证任何违反 RI 的状态在产生的瞬间就 fail fast。


场景 4:在错误的地方找 bug,并用 try/catch 掩盖症状

❌ 错误代码

/**
 * Find the most common word in a string.
 * @param text 零个或多个单词组成的字符串
 * @return 出现次数最多的单词(忽略大小写)
 */
public static String mostCommonWord(String text) {
    List<String> words = splitIntoWords(text);
    Map<String,Integer> frequencies = countOccurrences(words);
    String winner = findMostFrequent(frequencies);
    return winner;
}

// 现象:countOccurrences 内部抛出 NullPointerException。
// 错误反应:跑到下游的 findMostFrequent 里“找 bug”,并顺手加个保护:
private static String findMostFrequent(Map<String,Integer> frequencies) {
    try {
        return Collections.max(frequencies.entrySet(),
                Map.Entry.comparingByValue()).getKey();
    } catch (NullPointerException e) {
        return "";          // 治标:把异常变成空答案,原因仍然存在
    }
}

【错误代码的问题】

  1. 失败点在下游之前的 countOccurrences,而 findMostFrequent 根本没有被执行——在它里面找 bug 是纯粹的浪费时间(切片/数据流分析当场就能排除它)。
  2. catch (NullPointerException e) { return ""; } 让方法在出错时返回一个「合法的抽象值」"",客户端无法区分「text 中没有单词」与「程序已经出错了」,bug 被藏进数据里。
  3. 它破坏了 NullPointerException 携带的堆栈轨迹信息;堆栈轨迹是最有价值的「数据」,被吞掉后你连「哪一行解引用了 null」都不知道了。
  4. 用「保护性判断」绕过异常(例如先检查 index < size)同样治标不治本:真正的原因可能是上游产出了一个 null 元素。

✅ 正确代码

/**
 * Find the most common word in a string.
 * @param text 零个或多个单词组成的字符串
 * @return 出现次数最多的单词(忽略大小写)
 */
public static String mostCommonWord(String text) {
    List<String> words = splitIntoWords(text);

    // 二分查找:在数据流的中点插探针,检查上游的后置条件
    // (调试期探针,定位后删除;或改写成长期保留的 checkRep 断言)
    assert words != null : "splitIntoWords violated its postcondition";
    for (String w : words) {
        assert w != null && !w.isEmpty() : "splitIntoWords produced a bad word";
    }

    Map<String,Integer> frequencies = countOccurrences(words);
    return findMostFrequent(frequencies);
}

/** @return 出现次数最多的单词;要求 frequencies 非空 */
private static String findMostFrequent(Map<String,Integer> frequencies) {
    // 不放 try/catch:让问题以真实异常暴露出来
    return Collections.max(frequencies.entrySet(),
            Map.Entry.comparingByValue()).getKey();
}

【为什么这样更好】 探针被放在了数据流的中点——splitIntoWordscountOccurrences 之间。这正是二分查找的做法:如果断言在这里就失败,说明假设「bug 在 splitIntoWords:输入合法但输出错误」成立,搜索空间立刻缩小到上游;如果断言通过,说明两个方法之间的契约衔接(前者的后置条件不满足后者的前置条件)或 countOccurrences 本身有问题,搜索空间缩小到下游。任何一条假设都对应一小块代码,与「在 findMostFrequent 里瞎找」形成鲜明对比。

【代码对比解说】 这段对比同时演示了三条原则:其一,故障点 ≠ bug 位置。程序在 countOccurrences 里崩,但 bug 可能在更早的 splitIntoWords 里(它产出了 null),坏值沿着好代码一路传播后才爆掉。其二,探针应该放在能二值化判断的位置:数据流的中点天然满足这一点。其三,不要用 catch 掩盖。把 NullPointerException 转成 "" 之后,程序「不崩了」,但错误答案开始悄悄流动——这类 bug 往往要等到很久以后在完全无关的地方才暴露,代价高得多。

【设计原则透视】 这段代码把 Reading 06/07(Specifications)中的前置条件与后置条件当成了调试工具:splitIntoWords 的后置条件是「返回一个非 null、元素非 null 非空的单词列表」,把它写成断言就是在两个模块的抽象边界上设检查站。它也体现了 Reading 09 的 fail-fast:错误要么在源头立刻抛出,要么就会被传播到很远的地方再以晦涩的方式出现。最后,它示范了 Reading 03(Testing)中「回归测试」的闭环——定位并修复后,mostCommonWord("c c b") 这个最小用例要进入回归套件,让这个 bug 永不复活。


与其他设计原则的关联

  • Reading 03(Testing):调试的起点是「重现 bug」,而这通常就是写一个失败的测试用例;修复的终点是把它放进回归测试套件。调试与测试是一枚硬币的两面:测试负责发现 bug,调试负责消除 bug。
  • Reading 09(Avoiding Debugging):本讲所有技巧都是在「测试与 fail-fast 设计失效」时的兜底。断言、checkRep()、快速失败都是 Reading 09 的产物,它们在 Reading 13 中变成缩小搜索范围的探针。
  • Reading 11(Abstraction Functions & Rep Invariants)checkRep() 就是 RI 的可执行形式;切片时判断「哪些行可能影响这个值」时,AF 与 RI 帮你知道「哪些状态是合法的、哪些操作是允许的」。
  • Reading 06/07(Specifications / Designing Specs):调试时用前置/后置条件划分数据流阶段,是二分查找与切片的理论基础;而修复 bug 时要判断「这是编码错误还是规格/设计错误」,后者要回到规格层面重新设计。
  • Reading 05(Version Control):差分调试中的一个重要变体(找出「哪个提交引入了 bug」)完全依赖版本历史;git bisect 式的二分是这一思想的自动化。
  • Reading 04(Code Review):调试时阅读自己的代码本质上是一次自我代码评审;而「一次只处理一个 bug + 维护 bug list」的习惯也会反哺评审质量。
  • Reading 08(Immutability):不可变性让切片在声明处终止,极大降低调试成本;反之,可变别名的共享是「递归调用之间互相污染」这类难调 bug 的根源。
  • Reading 21(Concurrency):并发 bug 是「突然自己好了但其实没修好」的典型——时序相关的失败极易被环境变化掩盖,因此本讲的「如果不知道它为什么被修好,它就没被修好」在并发章节会被反复强调。

关键要点

  • 先重现,再动手:把失败变成一个小而可重复的用例,写进回归套件;在没有稳定失败之前,任何「修好了」都是幻觉。
  • 10 分钟法则:非系统化摸索超过 10 分钟就停下来,转为「观察数据 → 假设 → 探针实验 → 修正假设」的科学方法循环,并把每一步写进审计轨迹。
  • 用二分与切片缩小搜索空间:在数据流中点插探针;用 final + 最小作用域让切片在声明处终止;用成功/失败成对用例做差分调试。
  • 探针最小侵入,修复要等理解原因:优先用打印/日志/assert/checkRep()/调试器做只观察的实验,不要用 try/catch 把异常变成返回值;并且记住 Java 断言默认关闭,必须用 -ea 运行,否则 fail-fast 防线形同虚设。
  • 收尾四件事:撤销所有调试探针、补回归测试、检查同类 bug、评估副作用;只有当你因为自己的改动让系统从失败转为成功时,才叫修好了。

常见陷阱与注意事项

  1. 把「不报错了」当成「修好了」 → 用 try/catch 或提前 return 掩盖异常,症状消失但原因仍在,错误答案在系统中悄悄传播,日后在完全无关的地方爆发。正确做法是先理解原因,再修改代码,并用回归测试确认从失败到成功的转变。
  2. 在故障点找 bug → 程序在 countOccurrences 抛出异常,却在 findMostFrequent 或调用方里反复查看;坏值常常是在上游产生的,必须用数据流/切片向前回溯。
  3. 调试期间随手改代码,或同时追多个 bug → 为了「试试看」而改动无关逻辑会使实验失去可解释性;一发现问题就立刻开始递归调试则会导致「脑内栈」溢出、忘记原进展、实验互相干扰。调试期的改动应仅限于受控探针,新问题记入 bug list,除非它妨碍了当前调试否则不要切换。
  4. 忘记撤销探针 → 打印语句、被注释的代码、只在特定用例下成立的断言被提交到仓库,污染日志、误导读者,甚至让断言在生产环境误报。System.out.println 尤其容易留在库里。
  5. 依赖默认关闭的断言 → Java 断言只有加 -ea 才执行;以为「断言没报错就说明状态合法」,实际上它根本没运行(「看起来通过的断言」是最危险的假安全感)。
  6. 未最小化就去求助 → 把 800,000 词的输入直接贴到论坛/助教面前,没人能帮你;先做二分缩小,得到一个最小可复现示例,求助效率与成功率都会成倍提升。

思考题(带答案)

问题 1:用户报告 mostCommonWord("chicken chicken chicken beef") 返回了 "beef" 而不是 "chicken"。为了在开始调试前把输入缩小、简化,下列哪些输入值得一试,为什么? (a)"chicken chicken beef" (b)"Chicken Chicken Chicken beef" (c)"chicken beef" (d)"a b c" (e)"c c c b"

答案:(a)(b)(c)(e)都值得试,而且应当全部尝试,而不能只挑最简单的。理由:(a)减少重复次数,检验「次数是否为关键」;(b)改变大小写,检验「忽略大小写」这个规格点是否是 bug 所在;(c)是(a)的进一步缩减,检验「三次重复」是否为触发条件;(e)把单词缩写为单个字符,保留「三次 + 一次」的模式,若仍然失败,你就得到了最简洁的失败用例(本轮中最终正是 "c c b" 仍然出错)。至于(d)"a b c",它取消了「某个单词重复出现」这一结构,很可能不再触发 bug——但它依然值得一试,因为它能确认「平局/无重复」这条路径是正常的。这正是「不要只取最简输入」的原因:最简输入有时不再复现 bug。另外,缩小用例本身也是科学方法的实例:报告是 study the data,猜想「关键也许是出现次数而不是具体单词」是 hypothesize,运行 "a a a b" 是 experiment。

问题 2:假设 calculateTotalBonus() 返回了负数,而 x 本应始终 ≥ 0。请描述你会如何用「切片 + 二分查找 + 断言探针」在最多几次实验内定位问题,并说明为什么 10 分钟法则要求你在此刻放弃「盯着代码看」。

答案:第一步(切片)列出所有能影响 x 的行:x = 0;、循环 for (final Sale s : salesList)、条件 if (isWorthABonus(s))x += bonus;,以及 bonus 的来源 final int bonus = getBonus();。如果 xbonus 都是 final/局部的最小作用域,切片就在这几行内闭合;如果 xstatic 字段,切片就扩大到整个程序——所以先确认作用域。第二步(二分)把循环对半分:在第 250 笔、第 500 笔、第 750 笔销售处理完之后分别插一个 assert x >= 0 : "x=" + x;(或用调试器在这三处下断点观察)。第一次实验就能确定坏值是出现在前半段、后半段,还是在循环之外(若 x 在进入循环前就为负,则 bug 在 getBonus():它返回了负的 bonus)。第三步(继续二分)在确定的半段里重复对半,直到锁定到某一次迭代、某一笔销售。整个过程只需 O(log n) 次实验,而不是 n 次。关于 10 分钟法则:人的短期记忆容量极小,靠「盯着代码猜」的方法没有任何收敛保证——你无法记住已经排除了哪些可能,也无法保证每次实验都在缩小范围;而切成「假设——探针——观测」并写下来之后,每一次实验都能确定性地排除一半空间,这才是把随机搜索变成系统化搜索的关键。

问题 3:你在调试一个并发程序:某个偶发失败已经连续两天没有再出现,同事说「bug 已经自己好了」。请用本讲的规则说明为什么不能接受这个结论,以及你应该做什么。

答案:这直接对应 Agans 的规则「If YOU didn’t fix it, it isn’t really fixed」。系统「看起来正常」但有三种可能:你真的改了某个东西(那就应该能说出改的是什么、为什么它有效);环境变了(例如机器更快、负载更低、JDK 升级),时序窗口关闭了;或者 bug 只是被掩盖(例如异常被别的路径吞掉)。在并发场景下,第二种和第三种极其常见——Reading 21 会展示数据竞争(data race)如何依赖于线程交错,而交错又依赖于负载、调度与硬件。因此正确的做法是:(1)先复现:用压力测试、-ea 断言、Thread.sleep/CyclicBarrier 人为放大交错窗口、降低并发度、在生产环境加入日志,把失败拉回可重复状态;(2)在失败状态下观察:收集堆栈轨迹、日志、断言信息,形成关于「哪个共享状态被谁改了」的假设;(3)理解原因后再改动,并确认改动导致系统从失败转为成功;(4)把复现手段固化为回归测试/压力测试,纳入持续验证。如果做不到复现,就应该诚实地把该 bug 记录在 bug list 上并保留诊断线索,而不是宣称问题已解决。