Reading 9: 避免调试(Avoiding Debugging)
Reading 9: 避免调试(Avoiding Debugging)
说明:本讲 sp22 原版使用 TypeScript,本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版)原文保持一致。
概述
本讲的主题是「调试」,但真正讲的是如何写代码,使我们根本不需要调试,或者在不得不调试时能够轻松找到原因。6.031 的原始结构是两道防线:第一道防线——让 bug 不可能发生(make bugs impossible),用静态检查、动态检查、不可变性与 final 把一整类错误消灭在设计层面;第二道防线——把 bug 局部化(localize bugs),用快速失败(fail fast)、断言(assertion)、增量开发、单元测试、模块化、封装与作用域最小化,把 bug 的影响限制在尽可能小、尽可能新的代码范围内。
为便于学习与自检,本笔记把它整理为四个层层递进的防御层次:① 让 bug 不可能(静态/动态检查 + 不可变性 + final)→ ② 让 bug 显而易见(断言与类型即文档、把不变量写出来、作用域清晰)→ ③ 让 bug 尽早失败(防御式编程、前置条件检查、checkRep、失败时抛异常)→ ④ 把 bug 彻底隔离并测试(增量开发、单元测试与回归测试、模块化与封装、作用域最小化、DRY)。这四个层次共同服务于三大目标:Safe from bugs(预防并消灭 bug)、Easy to understand(断言与 final 是机器可检查的文档)、Ready for change(断言让未来的改动一旦破坏假设就立刻报错)。
核心概念与设计原则详解
四层防御总览(Four Lines of Defense)
- 定义与目的:把「避免调试」的手段按「事前预防 → 显式表达 → 就近暴露 → 隔离与验证」排序,形成一个可操作的检查清单,覆盖 Safe from bugs、Easy to understand、Ready for change 三个目标。
- 直观解释(”它是什么?”):把它想象成防洪:第一层是不让水出现(类型系统与不可变性直接排除某类错误);第二层是让水位可见(断言、类型、
final把隐含假设写在代码里,读者一眼可见);第三层是让堤坝在最初渗漏时就报警(fail fast:错误在离原因最近的地方抛出,而不是让脏数据漂流到远处才爆炸);第四层是挖好分区的水渠并反复巡检(增量开发、单元测试、回归测试、模块化与封装、作用域最小化,把任何 bug 限制在一个小格子里)。 - 关键规则与最佳实践:写代码时按此顺序自问——「这个错误能不能让编译器/运行时替我拒绝?」「这个假设能不能用断言或类型写出来,让读者与编译器都看得见?」「一旦违反,这里能立刻失败吗?」「如果仍然出错,我要搜多大范围的代码?」;四层都要用,因为任何单一手段都有盲区:静态检查管不了语义错误,断言管不了外部故障,测试也不可能穷尽所有输入。
第一层:让 bug 不可能(Make Bugs Impossible)
- 定义与目的:最好的防御是设计上不可能出错。这一层包括静态检查(static checking)、自动动态检查(automatic dynamic checking)与不可变性(immutability),直接服务于 Safe from bugs。
- 直观解释(”它是什么?”):静态检查在编译期就拒绝类型错误、拼写错误、参数个数错误——这类 bug 根本没机会进入运行期。自动动态检查则在运行期主动拦截:Java 会在数组或
List下标越界时立刻抛错(而 C/C++ 会静默地越界读写,造成 bug 与安全漏洞;TypeScript 在这点上也不如 Python/Java,因为它对越界读取只是安静地返回undefined)。不可变性同样属于「让 bug 不可能」:String没有任何方法能改变它所表示的字符序列,因此字符串可以随意传递共享,无需担心被别的代码改动。 - 关键规则与最佳实践:优先选择静态类型语言与静态检查友好的写法(用接口类型、避免强制类型转换、启用编译告警);优先选择会主动报错的容器与 API;默认使用不可变类型(见 Reading 8);把「这一整类错误能不能从设计上消除」当作设计评审的常规问题。
不可重赋值的引用与 final(Unreassignable References with final)
- 定义与目的:
final声明的变量只能被赋值一次,之后永不重赋。它是「让 bug 不可能 / 让假设显而易见」的低成本手段,也是给读者和编译器的文档。 - 直观解释(”它是什么?”):
final char[] letters = new char[] {'a','e','i','o','u'};之后,letters = new char[]{'x','y','z'};会被编译器静态拒绝,而letters[0] = 'z';完全合法——因为final只让引用不可重赋,数组元素照样能改。这一点必须牢牢记住,否则会写出「我明明标了final,为什么状态还是变了」的代码(详见 Reading 8 的不可变类设计规则)。 - 关键规则与最佳实践:给方法参数、局部变量、字段尽可能加
final;在 TypeScript 中对应的是const(局部)与readonly(实例字段),约定是「能用const就用const,绝不用var」;把final与不可变性配合使用——final锁引用,不可变类型锁值,两者共同生效才能保证「对象整个生命周期表示同一个值」。
第二层与第三层:防御式编程、快速失败与断言(Defensive Programming, Fail Fast, Assertions)
- 定义与目的:当我们无法阻止 bug 时,就让它就近、尽早失败。防御式编程(defensive programming)通过运行时检查减轻 bug 的破坏力,而断言是把这种检查标准化的手段,服务于 Safe from bugs 与 Ready for change。
- 直观解释(”它是什么?”):若
sqrt(x)的规格写requires x >= 0,调用方传了负数,那么按契约sqrt已不受约束,理论上可以返回任意值、死循环、甚至烧掉 CPU。但既然这是调用方的 bug,最有用的行为就是在离 bug 最近的地方指出它:插入对前置条件的运行时检查并抛出异常。真实的程序几乎不可能没有 bug,防御式编程提供了「即使不知道 bug 在哪,也能限制其影响」的手段。更早观察到问题,就更容易修复——这就是 fail fast。 - 关键规则与最佳实践:在方法入口检查前置条件(参数要求);在方法出口检查返回值要求(self check,如
assert Math.abs(r*r - x) < .0001;);在对象的每次状态变化后检查表示不变量(checkRep());断言应当边写代码边写,而不是事后补——写代码时你脑子里正好装着那些不变量,事后再补一定会漏;把「抛异常」与「用断言」区分开:内部假设用断言,外部故障用异常。
Java 断言机制(The assert Statement in Java)
- 定义与目的:
assert是 Java 语言内置语句(不是一个方法),用于表达「此处程序状态应当满足某条件」。它既是可执行的检查,也是文档(Easy to understand),并让未来的改动一旦破坏假设就立刻暴露(Ready for change)。 - 直观解释(”它是什么?”):最简形式
assert x >= 0;在布尔表达式为 false 时抛出AssertionError;也可以附带描述表达式(通常是字符串,也可以是基本类型或对象引用),用冒号分隔:assert x >= 0 : "x is " + x;。当x == -1时,错误消息是x is -1,并附带堆栈轨迹,告诉你断言在代码中的位置以及到达该处的调用序列——这些信息通常足以开始定位 bug。与注释的区别在于:断言是可执行代码,会在运行时强制检查假设。 - 关键规则与最佳实践:Java 的断言默认关闭(语言设计者考虑到检查有时代价高昂,例如用二分查找要求数组有序,若真去验证有序性就把对数时间变成线性时间),必须显式用
-ea(enable assertions)打开;测试时应当乐意(甚至渴望)付出这个代价,发布给用户后则未必;在 Eclipse 中可通过 Run → Run Configurations → Arguments 的 VM arguments 填-ea,或直接在 Preferences → Java → Installed JREs → Edit → Default VM Arguments 中默认开启;始终确保跑 JUnit 测试时断言是打开的,可以用下面这个测试来验证。此外要区分两套机制:Java 的assert语句用于实现代码内部的防御式检查,JUnit 的assertTrue()、assertEquals()等方法用于测试代码中检查测试结果,前者不打开-ea就完全不起作用,后者永远生效。 补充代码(课程原文给出的断言启用自检):
@Test public void testAssertionsEnabled() { assertThrows(AssertionError.class, () -> { assert false; }); }说明:
assertThrows接收「期望的错误类型」与「应当抛出该错误的函数(此处用 lambda)」。若断言已开启,lambda 中的assert false会抛出AssertionError,测试通过;若断言被关闭,lambda 什么也不做,不会抛出预期异常,测试失败——这样就能保证测试环境确实启用了断言。
应该断言什么(What to Assert)
- 定义与目的:断言要选在「假设最容易被破坏、而破坏后果最严重」的位置,从而在 Safe from bugs 上获得最大收益。
- 直观解释(”它是什么?”):两类最值得断言的检查是——方法参数要求(如
sqrt的x >= 0)与方法返回值要求(self check,如把结果平方回去看是否接近x)。第三类是对象层面的表示不变量(rep invariant),即用checkRep()断言「对象的内部表示此刻是合法的」(见下文)。 - 关键规则与最佳实践:在方法入口断言参数约束;在返回前断言结果性质(self check);在构造器、生产者(producer)与修改器(mutator)末尾调用
checkRep();观察器(observer)不必强制调用,但顺手调用是好的防御实践,因为这样更容易抓到由表示暴露(rep exposure)导致的不变量破坏——这正是 Reading 11 的结论;断言要写在方法体内部(面向实现者),而不是规格注释里(面向客户);写断言的时机是写代码的当下。
不应该断言什么(What Not to Assert)
- 定义与目的:运行时断言不是免费的:它会增加代码噪声与执行开销,因此必须节制使用,否则反而损害 Easy to understand。
- 直观解释(”它是什么?”):四类应当避免的断言是——(1)平凡断言:
x = y + 1; assert x == y + 1;只能证明编译器或虚拟机有 bug,而这二者在你有充分理由怀疑之前都应当被信任;(2)外部条件断言:文件是否存在、网络是否可用、用户输入是否正确,这些不是程序内部状态,失败不表示程序出了 bug,而且无论你怎么改程序都无法阻止它们发生——外部故障要用异常处理(如FileNotFoundException、NoRouteToHostException);(3)带副作用的断言表达式:因为断言可能被关闭,assert list.remove(x);在关闭断言时整个表达式被跳过,元素根本没被删除;(4)用断言处理「不应到达」的分支:switch的default分支若用assert兜底,一旦断言被关闭就形同虚设,应当抛出异常(如throw new AssertionError(...)),这样检查永远会发生。 - 关键规则与最佳实践:断言只检查程序内部状态是否落在规格允许的范围内;绝不要让程序的正确性依赖断言是否被执行;凡是「这段代码绝不能走到」的地方,用抛异常而不是断言;断言一句话说不清时,先问「这是不是该用异常或直接删掉」。
checkRep() 与表示不变量(checkRep() and the Rep Invariant)
- 定义与目的:表示不变量(representation invariant, RI)是「对象的内部字段必须始终满足的性质」;
checkRep()是把它变成运行时断言的方法,用于在每次改动的当下抓住错误,是第三层防御(尽早失败)的核心(关联 Reading 11)。 直观解释(”它是什么?”):课程 Reading 11 给出的
RatNum例子是:// Check that the rep invariant is true // *** Warning: this does nothing unless you turn on assertion checking // by running Java with -enableassertions (or -ea) private void checkRep() { assert denominator > 0; assert gcd(Math.abs(numerator), denominator) == 1; }它断言「分母为正」且「分子分母互质」。每次创建或修改 rep 之后调用它,就能在表示被破坏的那一刻发现问题,而不是等到很久以后某个无关的运算得出荒谬结果。
- 关键规则与最佳实践:在构造器、生产者、修改器的末尾调用
checkRep();观察器也建议调用(用于抓表示暴露);checkRep()声明为private,因为「检查并维护 rep 不变量」是实现者的责任,不是客户的责任;checkRep()不是抽象函数(AF)——它检查的是 rep 是否合法,而不是 rep 代表什么抽象值;记住它同样只在-ea打开时有效,所以团队约定必须保证断言始终开启。
第四层:把 bug 局部化——增量开发与测试(Incremental Development, Unit & Regression Testing)
- 定义与目的:把 bug 限制在「刚写的那一小块代码」里,是让调试变便宜的最有效手段,服务于 Safe from bugs。
- 直观解释(”它是什么?”):增量开发(incremental development)的意思是一次只构建程序的一小部分,并在继续之前把这一部分测透;这样一旦发现 bug,它极可能就在你刚刚写的那部分里,而不是散落在成千上万行代码中。测试课(Reading 3)中的两个技术正好配合它:单元测试——孤立地测试一个模块,因此你找到的 bug 一定在这个单元里(或者就在测试用例本身里);回归测试——给大系统加新功能时尽可能频繁地跑回归测试套件,一旦失败,bug 大概率就在你刚改的代码里。
- 关键规则与最佳实践:先写规格与测试策略,再写实现(test-first programming);小步提交、小步验证,不要一次写完整个模块才运行;每次改动后跑完整测试套件,把「测试失败」当作定位 bug 的指针;为被测单元提供足够小的接口,使其能被孤立测试(这正是模块化的价值)。
模块化与封装(Modularity & Encapsulation)
- 定义与目的:良好的软件设计本身就降低调试成本。模块化(modularity)把一个系统划分为可独立设计、实现、测试、推理与复用的组件;封装(encapsulation)在模块周围筑墙,使模块对自己的内部行为负责,其他部分的 bug 无法破坏它的完整性。二者共同服务于 Safe from bugs 与 Easy to understand。
- 直观解释(”它是什么?”):一个由单个超长函数组成的程序是单体(monolithic)的——难以理解,也难以隔离 bug;拆成许多小函数与小类则更加模块化。封装有两种主要形式:访问控制(
public/private)与变量作用域。public变量或方法能被任何代码访问,private的只能在同一个类内访问;尽可能把东西(尤其是变量)设为private,就限制了可能无意中造成 bug 的代码范围。同时,把只供内部使用的辅助方法设为public会让接口变得杂乱——公开接口越小、越连贯(只做一件事并做好),代码越容易理解;反过来,若让外部依赖了本应私有的辅助方法,将来就难以改动内部实现(Ready for change)。 - 关键规则与最佳实践:默认
private,只在确实要对外提供服务时用public;用「一个模块只承担一件事」的标准检查接口是否连贯;把内部状态保护起来(在后续讲持久内部状态的类时,这还会成为防 bug 的关键);宁可多传参数,也不要为了让多个部分「方便共享」而扩大可见性。
作用域最小化(Minimizing Variable Scope)
- 定义与目的:变量的作用域(scope)是程序文本中该变量可见、可被引用的那部分。作用域越小,需要搜索 bug 的代码范围越小,服务于 Safe from bugs 与 Easy to understand。
- 直观解释(”它是什么?”):假设你发现某个循环永远跑不完(
i始终到不了 100),那么一定有「某人」在改i。如果i是全局变量,它的作用域是整个程序:可能是doSomeThings()改的,可能是doSomeThings()调用的另一个方法改的,可能是某个并发线程改的——你必须搜遍全局。如果i在for初始化器里声明为局部变量,那么能改它的地方只有for语句本身(实际上只有循环体里那些你没写出来的部分);doSomeThings()根本访问不到这个局部变量,因此可以直接排除。 - 关键规则与最佳实践:始终在
for初始化器中声明循环变量(for (int i = 0; ...)),而不是在循环之前声明(后者会把作用域扩大到外层整个花括号块);在 TypeScript 中始终使用const或let、绝不用var——const/let的作用域是最小的花括号块,而var的作用域是整个函数;只在第一次需要时声明变量,并放在包含所有使用点的最内层花括号块里(不要在函数开头一次性声明所有变量);避免全局变量——它们常被当成「给多处传参数的捷径」,正确的做法是把参数传给真正需要它的代码,而不是放在全局空间里等着被无意间重赋。
避免重复代码(Don’t Repeat Yourself, DRY)
- 定义与目的:重复代码(duplicated code)是安全性的风险:如果两处有相同或相似的代码,根本风险就是「bug 存在于两份拷贝中,而某个维护者只修好了其中一份」。DRY 服务于 Safe from bugs 与 Ready for change。
- 直观解释(”它是什么?”):Reading 4(Code Review)里的
dayOfYear是经典反例:它用一长串if/else if把每个月之前的天数硬编码进去,31 + 28 + 31 + 30这样的累加表达式被反复抄写。假设日历改变(例如二月真的有 30 天),你要改的地方不止一处,改漏一处就会产生难以察觉的日期错误。复制粘贴是极具诱惑力的编程手段,但每当你按下粘贴键时,都应该感到一丝危险——拷贝的代码块越长,风险越大。 - 关键规则与最佳实践:把重复的值抽成常量(如各月天数表),把重复的控制流抽成循环或辅助方法;用表驱动(table-driven)写法替代枚举式分支;把「同一份逻辑出现两次」当作必须重构的信号;注意 DRY 也有边界——不要为了复用而破坏契约(例如为了复用
sum()而修改调用方的列表,见 Reading 8),DRY 不能以牺牲正确性为代价。
代码示例与对比分析
场景 1:不检查前置条件,让非法输入一路漂到远处才爆炸
❌ 错误代码
// 错误:前置条件只写在注释里,实现完全不检查,
// 负数输入会得到毫无意义的结果,并且错误会在很远的地方才显现。
/**
* @param x 要求 x >= 0
* @return x 的平方根近似值
*/
public static double sqrt(double x) {
double r = x / 2.0; // 对负数照样能算出结果
for (int i = 0; i < 20; i++) {
r = (r + x / r) / 2.0; // 迭代到 NaN 也不报错
}
return r; // 传入 -4 时返回 NaN
}
【错误代码的问题】
- 错误被掩盖:调用方传了负数(违反前置条件的 bug 在调用方),但
sqrt安静地返回NaN,bug 的现场被破坏。 - 影响范围扩散:
NaN会继续参与后续计算并污染其他结果,最终在离 bug 原因很远的代码处爆发,调试成本骤增。 - 规格与实现不一致:注释承担了契约角色,却没有任何机制保证契约被遵守;对读者来说「要求」二字形同虚设。
- 难以定位:即便最终发现是
NaN,也需要反向追踪到这次调用,而调用点可能离得很远。
✅ 正确代码
// 正确:在方法入口做防御式检查,让违反前置条件的调用立刻失败。
/**
* @param x 要求 x >= 0
* @return x 的平方根近似值
*/
public static double sqrt(double x) {
if (!(x >= 0)) {
throw new IllegalArgumentException("required x >= 0, but was: " + x);
}
assert x >= 0; // 与上面的显式检查二选一,或同时保留
double r = x / 2.0;
for (int i = 0; i < 20; i++) {
r = (r + x / r) / 2.0;
}
assert Math.abs(r * r - x) < 0.0001 : "bad approximation: r = " + r; // self check
return r;
}
【为什么这样更好】 非法调用在离 bug 最近的地方(即调用点之后的第一条语句)就抛出带清晰消息的未检查异常 IllegalArgumentException,堆栈轨迹直接指向调用者;调用方的错误假设立刻暴露,NaN 不会流向程序的其余部分。返回前的 assert 进一步检查实现自身的正确性(self check),把「结果是 x 的平方根」这一后置条件也变成可执行检查。
【代码对比解说】 两种写法的差别是「检查放在哪里」。这里要注意选择机制的原则:x >= 0 是方法参数要求,代表调用方可能犯的错,因此使用异常(且必须是未检查异常,否则会强迫客户写 try-catch)更合适;而 Math.abs(r*r - x) < 0.0001 检查的是实现自身的正确性,属于内部假设,用 assert 更合适。两者都体现了 fail fast,区别在于「谁该为失败负责」。
【设计原则透视】 这正是 Reading 7「前置条件还是后置条件」讨论的落地:如果要求可以低成本检查,就把它写成「参数不合适时抛出异常」这样的后置条件,让错误尽早可见。它同时把契约从纸面变成了可执行代码——注释不会失败,断言会。
场景 2:断言表达式带有副作用
❌ 错误代码
// 错误:把“删除元素”和“断言删除成功”合并成一句,
// 一旦断言被关闭(Java 默认如此),元素根本不会被删除。
public static void removeAllInstances(List<String> list, String target) {
while (list.contains(target)) {
assert list.remove(target); // 断言里的副作用!
}
}
【错误代码的问题】
- 行为随
-ea开关而改变:开着断言时元素被删除,关闭断言时循环体什么都不做——while (list.contains(target))变成死循环。 - 违反「正确性不得依赖断言」原则:程序的正确性绝不能取决于断言表达式是否被执行。
- 测试环境与生产环境行为不一致:本地(开了
-ea)测试通过,部署(未开-ea)后挂起,属于最难排查的一类问题。 - 可读性差:把「做一件事」和「检查一件事」写在同一个表达式里,读者很难一眼看出副作用。
✅ 正确代码
// 正确:先执行副作用,再用断言检查结果。
public static void removeAllInstances(List<String> list, String target) {
while (list.contains(target)) {
final boolean found = list.remove(target); // 副作用在这里发生,一定会执行
assert found; // 断言只负责检查
}
}
【为什么这样更好】 副作用被移到断言之外,因此无论断言是否启用,删除都会发生;断言退化成了纯粹的检查,符合「断言表达式不得有副作用」的规则。这样即使生产环境关闭断言,程序语义也完全不变。
【代码对比解说】 这条规则常被误解为「断言不重要」,其实相反:正因为断言可能被关闭,才要求它无副作用,从而可以被安全地开关。同类错误还有 assert (x = compute()) > 0;(赋值写在断言里)等。更好的做法通常是连 while 也不需要:list.removeIf(target::equals)(补充说明,Java 8+ 惯用法)。
【设计原则透视】 这是「断言可能被关闭」这一事实的直接推论,也是 Reading 8 中「隐藏的副作用很危险」的另一种形态:写代码时必须能一眼看出「状态在哪里被改变」。它把「可读性」与「正确性」这两件事绑在一起。
场景 3:断言平凡事实,以及断言程序无法控制的外部条件
❌ 错误代码
// 错误 1:平凡断言——只能证明编译器或 JVM 有 bug。
public static int increment(int y) {
int x = y + 1;
assert x == y + 1; // 这句话对定位 bug 毫无帮助
return x;
}
// 错误 2:断言外部条件——文件是否存在、网络是否可用不是程序的内部状态。
public static String readConfig(String filename) {
File f = new File(filename);
assert f.exists() : "config file missing"; // 断言挡不住外部故障
return readAll(f);
}
【错误代码的问题】
- 浪费且误导:平凡断言没有发现 bug 的能力,只会让代码变吵;读者会误以为这里有某种不明显的风险。
- 概念错位:
assert f.exists()把「外部环境不满足」误当成「程序内部出了 bug」,而当程序真的遇到缺失文件时,断言失败的消息(”config file missing”)会与真正的 bug 报告混在一起,难以区分。 - 无法预防:无论怎么改程序,都无法阻止用户删掉文件或网络断开;用断言处理这类情况等于放弃处理。
- 被关闭后形同虚设:最需要报错的场景(生产环境、断言关闭)反而什么都不会发生。
✅ 正确代码
// 正确 1:删掉平凡断言,或改为真正有价值的断言。
public static int increment(int y) {
final int x = y + 1;
return x; // 这种“不变式”无需断言,本地上下文已显然
}
// 正确 2:外部故障用异常处理,让调用方决定如何应对。
/**
* @param filename 配置文件名
* @return 文件内容
* @throws IOException 文件不存在或读取失败时
*/
public static String readConfig(String filename) throws IOException {
try (BufferedReader in = new BufferedReader(new FileReader(filename))) {
StringBuilder sb = new StringBuilder();
for (String line = in.readLine(); line != null; line = in.readLine()) {
sb.append(line).append('\n');
}
return sb.toString();
}
}
【为什么这样更好】 断言集中用于程序内部状态是否符合规格,噪声降低、信号变强;外部条件则通过异常显式声明在规格中(@throws IOException),调用方必须面对它,无法忽视。注意 readConfig 内部并未修改任何状态,因此也不需要 checkRep。
【代码对比解说】 判断标准只有一条:失败的根源是「程序写错了」还是「环境不配合」。前者用断言(且应当是内部假设),后者用异常。注意 Java API 中很多方法的文档把要求写成前置条件却又承诺特定异常,那在语义上就是后置条件(Reading 7)——这正是「外部故障用异常」的标准做法。
【设计原则透视】 这是本讲两层防线的分工:断言负责「保持内部一致性」(内部状态是否仍在规格范围内),异常负责「与外部世界打交道」。混淆二者会让 bug 报告失去意义,也会让测试无法判断「这次失败是代码问题还是环境问题」(影响 Reading 3 的测试可信度)。
场景 4:用 assert 兜底「不可能到达」的 switch 分支
❌ 错误代码
// 错误:default 分支用 assert 兜底,断言一旦关闭,非法输入会被静默接受。
public static String vowelClass(char vowel) {
switch (vowel) {
case 'a': case 'e': case 'i': case 'o': case 'u':
return "A";
default:
assert false : "must be a vowel, but was: " + vowel; // 关闭 -ea 后彻底失效
return "A"; // 于是非法输入也返回 "A"
}
}
【错误代码的问题】
- 检查会消失:Java 断言默认关闭,因此这个「兜底」在生产环境根本不存在。
- 静默给出错误答案:非法输入(例如
'x')会被当成元音处理,错误结果继续传播。 - 误导读者:
assert false看上去像是严密的分支覆盖,实际提供了虚假的安全感。 - 难以测试:若测试环境开了
-ea会通过,生产环境行为不同,形成「测不出来的生产 bug」。
✅ 正确代码
// 正确:抛异常兜底,检查永远发生(即使断言被关闭)。
public static String vowelClass(char vowel) {
switch (vowel) {
case 'a': case 'e': case 'i': case 'o': case 'u':
return "A";
default:
throw new AssertionError("must be a vowel, but was: " + vowel);
}
}
【为什么这样更好】 抛出 AssertionError(或更贴切的 IllegalArgumentException)不依赖 -ea,因此无论运行配置如何,非法输入都会立刻失败;这既让 bug 尽早暴露,也让「这里绝不能到达」这一假设成为始终生效的可执行文档。
【代码对比解说】 语言层面的差别很小(assert false 换成一个 throw),语义差别却很大:断言是「可以被关掉的检查」,异常是「永远执行的检查」。因此凡是正确性所必需的检查,都必须用异常;只有「额外的自查」才适合用断言。这也是为什么本讲强调「断言用于内部一致性、异常用于所有必须保证的行为」。
【设计原则透视】 这条对比把 Reading 7 的契约语言接了过来:后置条件(「只接受 5 个元音字母,否则失败」)必须由始终生效的机制保障。同时它体现了「让 bug 尽早失败」的工程取舍——宁可立刻崩溃,也不要带着错误结果继续跑。
场景 5:循环变量声明在循环外,作用域覆盖整个方法甚至整个程序
❌ 错误代码
// 错误:i 的作用域是整个类(静态字段),谁都能改它。
public static int i; // 全局可变状态
public static void doSomeThings() {
// 恶意或无意地改动了 i:
i = 0;
}
public static void countTo100() {
for (i = 0; i < 100; ++i) { // 用的是全局 i
doSomeThings(); // 每次调用都把 i 归零 —— 死循环
}
}
【错误代码的问题】
- 死循环且原因隐蔽:
i永远到不了 100,而要找出「谁改了i」,你必须搜索整个程序——包括doSomeThings()调用的所有方法,甚至并发线程。 - 无法并行/递归:
countTo100不能重入,两次调用会互相干扰。 - 修改的可见性失控:
public static字段把内部计数暴露为全局状态,任何模块都能写(违背封装)。 - 测试困难:测试之间会通过共享状态相互污染,出现「单独跑可以、整套跑就挂」的现象。
✅ 正确代码
// 正确:循环变量在 for 初始化器中声明,作用域仅限于循环本身。
public static void doSomeThings() {
// 这里根本访问不到 countTo100 的局部变量 i,因此不可能破坏它
}
public static void countTo100() {
for (int i = 0; i < 100; ++i) { // i 的作用域仅限这个 for 语句
doSomeThings();
}
}
【为什么这样更好】 能修改 i 的代码范围被压缩到 for 语句内部,doSomeThings() 可以直接排除在嫌疑范围之外——这正是「局部化 bug」想要的效果;方法可以安全地重入与递归;不存在跨测试的状态污染。
【代码对比解说】 Java 中变量作用域以花括号块为单位,因此「在 for 初始化器中声明」能把作用域缩到最小;把变量集中声明在函数开头(老式 C 风格)会让作用域不必要地变大。TypeScript 还有一条对应的规则:始终使用 const/let,绝不用 var——const/let 的作用域是最小的花括号块,而 var 的作用域是整个函数。补充说明:Java 10+ 的 var(局部变量类型推断)与 JavaScript 的 var 完全不同,它只是省略类型名,作用域仍是所在块。
【设计原则透视】 作用域最小化是「封装」在方法内部的对应物:封装限制谁能看到内部状态,作用域限制谁能访问变量名。两者共同决定「一个 bug 可能来自多大的代码范围」,因此与 Reading 8 中「别名越多、推理越难」是同一个道理。
场景 6:表示不变量只写在注释里,没有任何运行时检查
❌ 错误代码
// 错误:RatNum 的表示不变量(分母为正、分子分母互质)只存在于注释中,
// 字段可变且可以被任意设置,破坏不变量后要很久以后才会暴露。
public class RatNum {
private int numerator;
private int denominator; // 可变字段,没有任何约束
public RatNum(int numerator, int denominator) {
this.numerator = numerator;
this.denominator = denominator; // 可能为 0,可能为负,可能未约分
}
public void setDenominator(int d) {
this.denominator = d; // 任何值都能设进去,包括 0
}
public double toDouble() {
return (double) numerator / denominator; // 分母为 0 时得到 Infinity
}
}
【错误代码的问题】
- 不变量被静默破坏:
new RatNum(2, 4)没有被约分,setDenominator(0)直接制造出非法对象,任何依赖「分母为正且互质」的代码都会出错。 - 错误延迟暴露:非法状态会在很久以后的某次运算中表现为荒谬结果(如
Infinity、错误的相等性判断),届时已难以追溯是何时被破坏的。 - 可变字段使类无法成为不可变类型:字段非
final,客户可以随时改变对象表示的值(违背 Reading 8 的不可变设计规则)。 - 观察器无防护:每个方法都必须自行假设 rep 合法,重复且容易遗漏。
✅ 正确代码
// 正确:final 字段 + 构造器建立不变量 + checkRep() 在每次改动后立即验证。
// 以下 RatNum 与 Reading 11 的示例一致(checkRep 与其注释取自课程原文)。
public class RatNum {
private final int numerator; // 不可变字段:无修改器可破坏不变量
private final int denominator;
public RatNum(int numerator, int denominator) {
if (denominator == 0) {
throw new IllegalArgumentException("denominator is zero");
}
final int g = gcd(Math.abs(numerator), Math.abs(denominator));
final int sign = denominator < 0 ? -1 : 1;
this.numerator = sign * numerator / g;
this.denominator = Math.abs(denominator) / g;
checkRep();
}
public double toDouble() {
return (double) numerator / denominator;
}
// Check that the rep invariant is true
// *** Warning: this does nothing unless you turn on assertion checking
// by running Java with -enableassertions (or -ea)
private void checkRep() {
assert denominator > 0;
assert gcd(Math.abs(numerator), denominator) == 1;
}
private static int gcd(int a, int b) {
return b == 0 ? a : gcd(b, a % b);
}
}
【为什么这样更好】 不变量在每一次创建对象的瞬间就被建立(约分、把符号移到分子、强制分母为正),并立即由 checkRep() 验证;final 字段让对象创建后不可能被改坏,因此「表示不变量始终成立」由结构保证,而不仅仅靠断言。若将来增加了修改器(mutator),规则是:在每个修改器末尾再调用 checkRep()。
【代码对比解说】 注意这里同时用了两种机制:用非法参数抛异常(denominator == 0 是调用方的错误,属于契约层面)与用断言检查内部不变量(rep 是否合法,属于实现层面)。这与「外部故障用异常、内部假设用断言」的分工完全一致。另外,checkRep() 是 private 的——维护不变量是实现者的责任,客户端无权也无必要调用它;观察器方法虽然不改变 rep,但顺手调用 checkRep() 是好的防御实践,因为这样更容易抓到由表示暴露(rep exposure)引起的破坏。
【设计原则透视】 这是 Reading 11(Abstraction Functions & Rep Invariants)与 Reading 9 的交汇点:RI 是「让 bug 不可能 / 尽早失败」在对象层面的具体形式,而 AF 与 RI 的区分提醒我们——checkRep() 检查的是 rep 是否合法,而不是 rep 代表什么抽象值。把 final、异常、断言三者配合使用,才能让「对象永远处于合法状态」成为结构性保证。
场景 7:dayOfYear 中的重复代码(DRY)
❌ 错误代码
// 错误:一模一样的累加表达式被反复抄写,同样的事实在多处重复。
public static int dayOfYear(int month, int dayOfMonth, int year) {
if (month == 2) {
dayOfMonth += 31;
} else if (month == 3) {
dayOfMonth += 59;
} else if (month == 4) {
dayOfMonth += 90;
} else if (month == 5) {
dayOfMonth += 31 + 28 + 31 + 30;
} else if (month == 6) {
dayOfMonth += 31 + 28 + 31 + 30 + 31;
} else if (month == 7) {
dayOfMonth += 31 + 28 + 31 + 30 + 31 + 30;
} else if (month == 8) {
dayOfMonth += 31 + 28 + 31 + 30 + 31 + 30 + 31;
} else if (month == 9) {
dayOfMonth += 31 + 28 + 31 + 30 + 31 + 30 + 31 + 31;
} else if (month == 10) {
dayOfMonth += 31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30;
} else if (month == 11) {
dayOfMonth += 31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30 + 31;
} else if (month == 12) {
dayOfMonth += 31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30 + 31 + 31;
}
return dayOfMonth;
}
【错误代码的问题】
- 修一处漏一处:假设日历改变(例如二月真的有 30 天),需要修改的数字散布在多个分支里,漏改任何一处都会得到错误的日期。
- 没有前置条件检查:传入
month = 13或dayOfMonth = 40都会安静地返回一个「看起来合理」的日期,错误被掩盖(违背 fail fast)。 - 难以阅读与验证:读者必须逐条核对每一行的加法是否正确,而不是一次性验证一张天数表。
- 难以扩展:想支持闰年(二月 29 天)就必须再复制一套分支,重复度进一步上升。
✅ 正确代码
// 正确:把各月天数抽成一张表,用循环累加;同时断言前置条件。
private static final int[] MONTH_LENGTH =
{ 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
/**
* @param month 月份,要求 1 <= month <= 12
* @param dayOfMonth 当月第几天,要求 1 <= dayOfMonth <= 该月天数
* @param year 年份(本示例未处理闰年,与课程原文一致)
* @return 该日期在当年是第几天
*/
public static int dayOfYear(int month, int dayOfMonth, int year) {
assert 1 <= month && month <= 12 : "month out of range: " + month;
assert 1 <= dayOfMonth && dayOfMonth <= MONTH_LENGTH[month]
: "day out of range: " + dayOfMonth;
int total = dayOfMonth;
for (int m = 1; m < month; ++m) { // 每个月的天数只写一次
total += MONTH_LENGTH[m];
}
return total;
}
【为什么这样更好】 每个月的天数只出现一次(DRY),日历改变时只需改一处;for 循环取代了长长的 if 链,逻辑一眼可验证;入口处的断言把「月、日必须在合法范围内」这一前置条件变成可执行的检查,非法输入立刻失败而不是返回错误日期;将来要支持闰年,只需在计算前对二月长度做一次修正,而不必改动整个分支结构。
【代码对比解说】 两版程序对合法输入的输出完全相同,差别在可维护性与安全性。这也是 Reading 4(Code Review)中 dayOfYear 例子的核心教训:重复不只是「不好看」,而是「bug 被复制了多份」。同时要注意 DRY 的边界——本讲的 DRY 不应走向 Reading 8 场景 1 那种「为了复用而修改调用方数据」的歧路;正确的做法是复用计算逻辑,而不是复用可变状态。
【设计原则透视】 这条对比把本讲的多个层次串在一起:表驱动是「让 bug 不可能」的一种形式(数字只写一次),断言是「让 bug 尽早失败」,循环内的局部变量 m 与 total 是「作用域最小化」,而常量表的 private static final 是「封装 + 不可重赋值引用」。这正是四层防御在同一段小代码里协同工作的样例。
与其他设计原则的关联
- Reading 2(Basic Java)与 Reading 6(Specifications):静态检查与类型系统(Reading 2)是「让 bug 不可能」的第一层;而本讲所有防御式检查的判断依据都是规格(Reading 6)——「检查什么」由前置条件决定,「检查结果是否符合预期」由后置条件决定。没有规格,断言就无从下手。
- Reading 3(Testing):增量开发、单元测试与回归测试是「把 bug 局部化」的核心手段:单元测试保证找到的 bug 属于被测单元,回归测试保证新改动引入的 bug 出现在刚改的代码里。此外,本讲强调测试时必须开启
-ea(并可写testAssertionsEnabled这类测试来验证),否则断言全部形同虚设,测试的可信度会大幅下降。 - Reading 4(Code Review):本讲 DRY 与「模块化/封装」的很多结论直接来自代码评审课:
dayOfYear的重复代码、countLongWords的全局变量与打印副作用,都是同一批「坏味道」。断言与final是把 Code Review 中「要在注释里写清假设」升级为「用可执行代码强制假设」。 - Reading 7(Designing Specifications):本讲「前置条件还是后置条件」与 Reading 7 完全衔接——当检查代价低、方法对外公开时,把要求写成「参数非法则抛出未检查异常」这样的后置条件,是本讲的 fail fast 在契约层面的表达;而
sqrt的例子正是 Reading 7 讨论过的atan(y, x)前置条件检查的同类。 - Reading 8(Mutability & Immutability):不可变性既是本讲第一层防御(让 bug 不可能),也与
final/const一起构成「不可重赋值引用」的规则;而「为复用而修改调用方数据」类 bug(sumAbsolute)与本讲的副作用禁忌同源。表示不变量检查(checkRep)又要求 rep 尽可能不可变,才能保证不变量不被静默破坏。 - Reading 11(Abstraction Functions, Rep Invariants):
checkRep()的完整规则在此展开:在构造器、生产者与修改器末尾调用,观察器也建议调用(用于抓表示暴露),必须声明为private,且它检查的是 RI 而非 AF。本讲的场景 6 是这一章节的预习。 - Reading 13(Debugging):本讲讲「如何避免调试」,下一讲则讲「当 bug 真实存在时如何系统性地定位它」——两者的关系是:本讲的所有手段(断言、作用域最小化、模块化、可复现的测试)都会显著缩短 Reading 13 中调试所需的时间。
- Reading 21、23(Concurrency;Locks):全局可变状态、作用域过大的变量在并发下会变成数据竞争;断言与不可变对象在并发程序中的价值更高,因为并发 bug 难以复现,而「设计上不可能出错」与「就近失败」是最可靠的对策。
关键要点
- 防御要分层,四层都要用。 ① 让 bug 不可能(静态检查、自动动态检查、不可变性、
final)→ ② 让 bug 显而易见(断言、类型与final作为机器可检查的文档)→ ③ 让 bug 尽早失败(防御式编程、前置条件检查、checkRep、非法分支抛异常)→ ④ 把 bug 隔离并验证(增量开发、单元测试、回归测试、模块化与封装、作用域最小化、DRY)。 - 断言是可执行的文档,但只在
-ea打开时生效。 Java 的assert默认关闭;跑测试时必须开启,并可用assertThrows(AssertionError.class, () -> { assert false; })验证;程序的正确性绝不能依赖断言表达式是否被执行,因此断言表达式不得有副作用。 - 断言内部假设,异常处理外部故障与本该永不发生的分支。 参数检查常用未检查异常(如
IllegalArgumentException);返回值自查(self check)与表示不变量用断言;文件、网络、用户输入等外部条件用异常(IOException);switch的default分支要抛异常而不是assert,因为断言可能被关闭。 - 不变量要写出来并检查。
checkRep()在构造器、生产者、修改器末尾调用(观察器也建议调用),声明为private;它检查的是表示不变量(RI),而不是抽象函数(AF)。 - 作用域最小化,避免全局状态。 循环变量在
for初始化器中声明;只在第一次使用时、在最内层花括号块中声明变量;TypeScript 中只用const/let、绝不用var;避免全局变量,改为显式传参——能改某个变量的代码范围越小,调试要搜的范围就越小。 - 模块化与封装是把 bug 关进笼子的结构性手段;DRY 是防止「bug 被复制多份」。 默认
private、保持公开接口小而连贯;重复的值抽成常量、重复的控制流抽成循环或辅助方法,但绝不为复用而破坏契约(不要修改调用方的数据)。
常见陷阱与注意事项
- 以为写了
assert就一定在检查 → Java 断言默认关闭,不加-ea时所有断言被完全跳过;结果是「本地不报错的假设」在生产环境悄悄失效。对策:在 IDE 与测试配置中默认开启-ea,并写testAssertionsEnabled之类的自检测试。 - 把带副作用的表达式塞进断言 → 如
assert list.remove(x);,断言关闭时删除操作根本不会执行,程序行为随运行配置而变。对策:先执行副作用(final boolean found = list.remove(x);),再断言结果。 - 用断言检查外部条件 → 如
assert f.exists(),把「环境不配合」误判为「程序有 bug」,且断言关闭时毫无保护。对策:外部故障一律用异常(IOException、FileNotFoundException),并在规格中显式声明。 - 在「不可能到达」的分支里用
assert false→ 断言关闭后非法输入被静默接受,产生错误结果。对策:抛AssertionError或IllegalArgumentException,让检查永远生效。 - 把无用注释式的平凡断言当作文档 → 如
x = y + 1; assert x == y + 1;,只会增加噪声并暗示存在并不存在的风险。对策:只在「本地上下文看不出来」的地方写断言,并优先断言参数要求、返回值要求与表示不变量。 - 在函数开头集中声明所有变量、循环变量声明在循环外、使用全局变量 → 作用域不必要地扩大,任何一行代码都可能成为嫌疑犯,调试时必须搜索整段甚至整个程序;还会造成测试之间的状态污染。对策:就近、就内层声明,循环变量写进
for初始化器。 - 为了复用(DRY)而修改传入的可变对象 → 复用本身值得鼓励,但修改调用方数据会造成隐藏副作用(Reading 8),属于「用正确性原则换取代码行数」的坏交易;对策:复用计算逻辑而不是复用可变状态。
checkRep()只在构造器里调用一次 → 后续修改器破坏了不变量却无人检查,问题延迟到很远的计算中才爆发。对策:每个会创建或修改 rep 的操作末尾都调用checkRep(),并在测试时确保断言开启。
思考题(带答案)
问题 1:下面这段代码有两处与断言相关的错误,请指出并给出修改后的写法。
public static int quadratic(int a, int b, int c, int x) {
assert a != 0;
int value = a*x*x + b*x + c;
assert value == a*x*x + b*x + c;
return value;
}
答案:第一处错误是平凡断言——assert value == a*x*x + b*x + c; 只是在复述上一行的赋值,能发现的只有编译器或 JVM 的错误,而这两者应当被信任;应当删除。第二处问题在于断言的类型选择:a != 0 是调用方的参数要求(前置条件),用断言表达意味着「断言关闭时非法调用被静默接受」,而参数检查属于「正确性所必需」的约束,应当抛出始终生效的未检查异常,并在规格中把它写成后置条件。修改后的写法:
/**
* @param a 二次项系数
* @param b 一次项系数
* @param c 常数项
* @param x 自变量
* @return a*x*x + b*x + c
* @throws IllegalArgumentException 当 a == 0 时(此时不是二次式)
*/
public static int quadratic(int a, int b, int c, int x) {
if (a == 0) {
throw new IllegalArgumentException("a must be nonzero, but was: " + a);
}
return a*x*x + b*x + c;
}
若团队约定「断言始终开启」,保留 assert a != 0 : "a == 0"; 作为额外自查也无妨,但它不能取代抛异常。(附带一提:题目给出的 quadraticRoots 类方法中,位置 A 合理的断言是 assert a != 0;,位置 B 合理的是 for (double root : roots) { assert Math.abs(a*root*root + b*root + c) < 0.0001; } 与 assert roots.size() <= 2;——前者是返回值自查,后者是后置条件检查;assert roots.size() >= 0; 与 assert b != 0;、assert c != 0; 都是无意义的平凡断言或与契约无关的检查。)
问题 2:某同学写道:「我给所有方法都加上了断言,所以我的程序已经很安全了。」请从本讲四个防御层次的角度评价这个说法,并说明还缺什么。
答案:这个说法夸大了断言的作用。断言只覆盖第三层防御的一部分——它能在运行时检查内部假设(参数要求、返回值自查、表示不变量),但存在三个明显局限:(1)Java 断言默认关闭,若没有 -ea,全部检查都不执行,因此断言不能替代始终生效的检查(正确性所必需的检查必须用异常);(2)断言检查不了外部故障(文件、网络、用户输入),这些必须用异常处理;(3)断言只能在「程序跑到那一步」时发现问题,它不能阻止 bug 进入代码。完整的四层防御还需要:第一层——用静态检查(类型、final)与不可变性让某类 bug 不可能发生(例如用 String 而不是 char[] 返回学号,从根本上消除别名修改);第二层——让假设对读者显而易见(类型、final、有意义的断言、清晰的命名);第四层——增量开发、单元测试与回归测试、模块化与封装、作用域最小化、避免重复代码,从而把 bug 限制在小范围内并持续验证。此外还应检查断言本身的质量:是否出现了平凡断言、是否有副作用的断言表达式、是否把本该抛异常的分支用 assert 兜底——这些都会让「加了很多断言」变成虚假的安全感。
问题 3:为什么本讲把「避免在迭代过程中修改集合」「把循环变量声明在循环里」「避免全局变量」这三件事都归入「避免调试」,而不是仅仅当作编程风格问题?
答案:因为这三件事共同决定了调试时你需要搜索多大的代码范围,也就是 bug 的「局部性」。缺陷本身不可避免,但缺陷被发现时离原因越近、可能的嫌疑代码越少,修复成本就越低:迭代中修改集合并不会立刻报错(自写迭代器会静默跳过元素),错误结果与真正原因之间隔着整段循环逻辑;把 i 声明为全局变量后,能改它的地方是整个程序(包括被调用的其他方法、以及并发线程),而声明在 for 初始化器中后,嫌疑范围被压缩到循环体内部,doSomeThings() 可以直接被排除;全局变量不仅扩大作用域,还会让「谁能看见这份状态」失控,从而使测试之间相互污染、产生「单独跑通过、整套跑失败」的现象。三者都与 Reading 9 的第二道防线(把 bug 局部化)完全对应:增量开发与单元测试把 bug 限制在「你刚写的那部分」,作用域最小化与封装把 bug 限制在「你能一眼看完的那部分代码」。因此它们不是审美偏好,而是直接决定调试成本(也即 Safe from bugs 与 Ready for change)的工程决策。
