Reading 4: 代码审查(Code Review)
Reading 4: 代码审查(Code Review)
说明:本讲 sp22 原版使用 TypeScript,本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版)原文保持一致。凡属笔记额外补充的 Java 生态知识,均以「补充说明」标注。
概述
代码审查(code review)是由非作者本人对源代码进行的仔细而系统的研究,它和校对论文是同一种活动:目的不是证明作者愚蠢,而是让缺陷在最早、最便宜的时刻暴露出来。它有两个并列的目的——改进代码(找 bug、预判 bug、检查清晰度、检查是否符合项目风格标准)和改进程序员(彼此学习新的语言特性、设计变更与编码标准)。6.031 把代码审查当作贯穿全学期的工程实践,因为这套方法有扎实的实证支持:研究表明代码审查可以发现 70%–90% 的软件缺陷,Google 的流程甚至规定没有第二位工程师签字就不能把代码推入主仓库。
本讲的重点不是”审查的社交礼仪”,而是一份可操作的好代码清单:不要重复自己(DRY)、在需要的地方写注释、快速失败(fail fast)、避免魔法数字、每个变量只有一个用途、使用好名字、用空白帮助读者、不要用全局变量、函数应当返回结果而不是打印它们、避免特例代码。这十条规则全都直接服务于 6.031 的三大目标:Safe from bugs(免于 bug)——DRY 让一个 bug 只需修一处,快速失败让缺陷靠近源头被发现,避免全局变量让 bug 的影响范围被限制;Easy to understand(易于理解)——注释、命名、避免魔法数字、空白排版让读者不必反向工程作者的意图;Ready for change(为变更而设计)——DRY 与”返回结果而非打印”让代码能被用于作者当初没想到的新场景。一句话总结本讲的立场:代码审查是唯一能发现”晦涩难懂代码”的手段,因为只有另一个人真的去读它、试图理解它,晦涩才会被暴露。
核心概念与设计原则详解
代码审查(Code Review)
- 定义与目的:由代码作者之外的人对源代码做仔细、系统的检查。它同时服务于安全性(发现与预判 bug)、易理解性(发现晦涩代码)与可修改性(由有经验的开发者预判未来变化并建议防护措施)。
- 直观解释(”它是什么?”):把代码当成一篇要投稿的论文,审查者就是审稿人。审稿人不会替你重写,而是指出”这里读者会误解”、”这个假设没有写下来”、”这两段逻辑重复了,将来改一处忘另一处”。开源项目(Apache、Mozilla)与工业界(Google)都广泛采用它,Google 的规则是:没有另一位工程师在审查中签字,任何代码都不能进入主仓库。
- 关键规则与最佳实践:
- 不要替作者重排格式:审查是针对语义与可维护性,不是把你的个人风格强加给别人;擅自把每个模块都重新格式化,队友”会恨你,而且恨得有理”。
- 保持自洽并遵守项目约定——风格是个人选择,但项目一致性是团队义务。
- 区分”两类问题”:一类是风格(大括号放哪里,属于圣战级的口味问题),一类是能强化三大目标的实质规则(DRY、fail fast、命名等),后者才是本讲的重点。
- 审查是双通道学习:作者学到新技术,审查者也从别人的代码里学到新写法,因此不要把它当作单向的挑错。
- 审查清单不是穷尽的;随着课程推进,规格说明、表示不变量、并发与线程安全都会成为新的审查素材。
快速失败(Fail Fast)
- 定义与目的:代码应尽可能早地暴露自己的缺陷。问题被观察到的时间越早、离成因越近,定位和修复就越容易。它主要服务 Safe from bugs。
- 直观解释(”它是什么?”):静态检查比动态检查失败得更快,动态检查又比”算出一个错误答案并污染后续计算”失败得更快。
dayOfYear就是个反面典型:如果按”月/日/年”以外的顺序传参,它不会报错,只会安静地返回一个错误答案。 - 关键规则与最佳实践:
- 优先用静态检查(类型、枚举、
final)而不是运行时检查;能用类型表达的前置条件就不要留给注释。 - 不能静态表达时,用动态检查,并且尽早检查——进入业务逻辑之前就检查。
- 抛异常属于”动态失败”,返回
-1、null或false属于”静默失败”,后者最慢,因为它把错误推迟到很远的地方才爆发。 - 用更强的类型承载语义:把
int month换成enum Month { JANUARY, ..., DECEMBER },把参数的顺序错误变成静态错误;把int month换成String month也能让”顺序搞错 + 类型不符”变成静态错误。 - 在分支结构的
else兜底处抛异常(而不是漏掉),可以把”漏掉一种情况”变成运行时的即时失败。
- 优先用静态检查(类型、枚举、
防御性编程 vs 快速失败(Defensive Programming vs Fail Fast)
- 定义与目的:防御性编程指代码主动处理”理论上不该发生”的输入或状态,不假设调用者守规矩;快速失败指代码在假设被破坏时立刻、响亮地失败。两者不是对立的口号,而是边界之分。
- 直观解释(”它是什么?”):对来自程序外部(用户输入、文件、网络、第三方库)的数据,你必须防御,因为那里没有契约可依赖;对程序内部的调用,契约由规格说明(Reading 6)保证,此时”防御”反而有害——它会把你本该知道的 bug 悄悄吞掉,让错误在下游以更难理解的形式出现。
- 关键规则与最佳实践:
- 内部代码:相信前置条件,违反前置条件时抛异常或断言失败(fail fast)。
- 外部边界:解析、校验、拒绝非法输入,并把失败转译成上层可以理解的异常(异常转译)。
- 绝不”静默修正”内部错误:不要
if (month < 1) month = 1;这类”容错”,那会把 bug 变成错误答案。 - 断言(assertions)属于”开发期快速失败”:课程在 Reading 9(Avoiding Debugging)与 Reading 11(Abstraction Functions & Rep Invariants)中会把它作为检查前置条件与表示不变量的工具深入展开。
- 记住判据:这个检查是在保护”我不信任的人”,还是在掩盖”我自己的 bug”? 前者是防御,后者是自欺。
不要重复自己(Don’t Repeat Yourself, DRY)
- 定义与目的:重复代码是安全风险——如果两份相同或相似的代码里藏着一个 bug,维护者很可能只修了其中一份。它同时服务三大目标。
- 直观解释(”它是什么?”):把复制粘贴想象成不看来车就横穿马路。复制的代码块越长,风险越高。
dayOfYear里”4 月有多少天”这个问题被写了多次,一旦历法变了,你得改很多处,而且总会漏掉一处。 - 关键规则与最佳实践:
- 数值重复、逻辑重复、结构重复都算重复;三种都要消除。
- 消除数值重复的办法是命名常量或数据表(如
monthLength[month])。 - 消除逻辑重复的办法是循环或辅助函数(如把
dayOfMonth += ...收敛为一次累加)。 - 不要硬编码你手工算出来的数(例如 59、90 这种”几个月天数之和”);用可见地由其他命名常量计算出来的表达式。
- 重复出现三次以上的字面量就该被提取,”三振出局”是常见经验法则。
在需要的地方写注释(Comments Where Needed)
- 定义与目的:好注释让代码更易理解、更安全(重要假设被记录下来)、更可修改。注释分两类:规格说明(specification)与出处说明(provenance)。
- 直观解释(”它是什么?”):注释是对”代码看不出来的信息”的补充。代码本身能表达”做了什么”,注释要表达”为什么这样做、假设了什么、来源是哪里”。把代码逐行翻译成英文的注释毫无价值,因为你应当假设读者至少懂 Java。
- 关键规则与最佳实践:
- 每个方法/类上方写 Javadoc 规格说明:
/** ... @param ... @return ... */,把前置条件写进@param,后置条件写进@return(详见 Reading 6)。 - 抄来或改编的代码必须注明出处(这也是 6.031 协作政策的要求),既避免版权问题,也提醒后来者”这段代码可能过时或有已知缺陷”。
- 不要写”把代码翻译成英文”的注释:
++i; // increment i是噪声。 - 晦涩但正确的代码需要注释解释其数学/物理依据,例如
// Gauss's formula for the sum of 1...n。 - 更好的做法常常是改名字/改结构来消除对注释的需求:
const tmp = 86400; // number of seconds in a day应改成secondsPerDay。
- 每个方法/类上方写 Javadoc 规格说明:
避免魔法数字(Avoid Magic Numbers)
- 定义与目的:除 0、1(有时 2)之外凭空出现的常量都叫魔法数字,因为它们”像从稀薄空气里冒出来”,没有任何解释。消除它们同时改善可读性与可修改性。
- 直观解释(”它是什么?”):读到
if (month == 2)时,你无法确定 2 指一月、二月、三月还是公元 2 年。魔法数字让读者必须去猜,而猜错是 bug 的温床。turtle.rotate(3)里的 3 到底是 3 度、3 弧度还是 3 整圈?光看调用点无法判断。 - 关键规则与最佳实践:
- 用命名常量替代字面量:
FEBRUARY显然比2可读。 - 常量之间常有依赖关系:
59与90是手算出来的和,应当写成由其他命名常量计算出的表达式。 - 即使是 π、G 这类”永恒常量”也值得命名:一是避免抄错数字(
3.14159265358979323846与3.1415926538979323846谁对?),二是精度、单位这类设计决策未来会变,命名常量更易改。 - 当魔法数字大量出现时,考虑把它们当成数据而不是常量,放进数组/映射等数据结构,让代码退化成一次查表(
monthLength[month])。 - 判断”0 是否是魔法数字”的准则:它是否承载了领域含义?
for (int i = 0; ...)与if (list.size() == 0)里的 0 是普适常识;if (date.getMonth() == 0)里的 0 是”一月”,是魔法数字。
- 用命名常量替代字面量:
每个变量只有一个用途(One Purpose for Each Variable)
- 定义与目的:变量不是稀缺资源——随手引入、起好名字、不再需要就停用。复用参数或变量会让读者困惑,也埋下 bug。
- 直观解释(”它是什么?”):
dayOfYear把参数dayOfMonth复用成了”一年中的第几天”,两个含义完全不同的值共用一个名字。如果几行之后有人还需要”这个月几号”,信息已经丢失了。 - 关键规则与最佳实践:
- 参数默认不应被修改:未来的改动可能还需要知道传入时的原值。
- 用
final修饰参数与尽可能多的局部变量,让编译器做静态检查(Java 支持final参数,这是 Java 相对 TypeScript 的一个便利之处)。 - 需要新含义时引入新变量,而不是覆盖旧变量。
- 循环变量只在循环内使用,不要借它传递跨循环的信息。
- 如果一个变量在方法中途”换了身份”,这是明确的重构信号。
使用好名字(Use Good Names)
- 定义与目的:好的方法名与变量名又长又自解释,往往能完全替代注释。它主要服务易理解性,也间接改善安全性与可修改性。
- 直观解释(”它是什么?”):
tmp、temp、data是糟糕的名字——每个局部变量都是临时的,每个变量都是数据,这类名字因此毫无信息量。名字应让代码”自己会说话”。 - 关键规则与最佳实践:
- 遵循语言的词法约定:Java 中类名首字母大写,变量名与方法名首字母小写,多词用 camelCase(
startsWith、getFirstName),全局常量用ALL_CAPS_WITH_UNDERSCORES。 - 方法名通常是动词短语(
getDate、isUpperCase),变量名与类名通常是名词短语。 - 避免缩写:
message比msg清楚,word远胜wd;很多队友的母语不是英语,缩写对他们更难。 - 避免单字符变量名,除非是公认惯例:笛卡尔坐标的
x、y,循环里的i、j。 - 名字可以暗示类型或单位:
widthInPixels、secondsPerDay、bookTitle让读者不必去查声明。
- 遵循语言的词法约定:Java 中类名首字母大写,变量名与方法名首字母小写,多词用 camelCase(
用空白帮助读者(Use Whitespace to Help the Reader)
- 定义与目的:一致的缩进、行内空格、多行对齐能让代码结构对眼睛”显形”,主要服务易理解性。
- 直观解释(”它是什么?”):
dayOfYear把相加的数排成整齐的列,读者一眼就能比较相邻两行差在哪;leap把整行条件挤在一起,读者需要逐字符解析。同一份逻辑,排版决定了它的可读性。 - 关键规则与最佳实践:
- 缩进保持一致;不要用制表符,只用空格字符(不同工具把 tab 展开成 2/4/8 个空格,
git diff或换编辑器看代码时缩进会全乱)。 - 二元运算符两侧加空格(
===、\|\|),让运算符”跳出来”。 - 多行条件对齐,使相似与差异一目了然。
- 用空行分隔逻辑段落,让方法结构(准备—计算—返回)自解释。
- 编辑器应配置为按 Tab 键时插入空格(新版 6.031 环境已默认如此)。
- 缩进保持一致;不要用制表符,只用空格字符(不同工具把 tab 展开成 2/4/8 个空格,
不要使用全局变量(Don’t Use Global Variables)
- 定义与目的:全局变量既能从程序任何位置读取、也能从任何位置修改,因此 bug 的影响范围无法收敛。它主要损害 Safe from bugs 与 Ready for change。
- 直观解释(”它是什么?”):Java 中全局变量由
public static声明:public让它处处可见,static让它只有一份实例。任何一段代码都能改它,任何一次调用都可能被上一次调用污染。 - 关键规则与最佳实践:
- 判据是两条同时成立:它是变量(值可变),且它是全局的(处处可读可写)。
- 加上
public static final且类型不可变,它就变成全局常量——处处可读、永不重赋值或变异,风险消失。全局常量常见且有用。 - 把全局变量改写为参数与返回值,或放进对象里、通过对象的方法访问。
- 单例式”配置对象”也应尽量以参数形式显式传递,让依赖关系可见。
- 快照图(snapshot diagram)中要区分局部变量、实例变量、全局变量,因为它们的生命周期完全不同。
函数应当返回结果,而不是打印它们(Functions Should Return Results, Not Print Them)
- 定义与目的:把结果打印到控制台会锁死函数的使用场景——当结果要用于计算而非给人看时,函数必须重写。它主要损害 Ready for change。
- 直观解释(”它是什么?”):只有程序的最高层才应该与用户/控制台交互;低层组件应把输入作为参数接收、把输出作为返回值交付。调试输出是唯一例外,但它是调试手段而不是设计的一部分。
- 关键规则与最佳实践:
- 方法签名要能表达全部结果:
List<Integer>不够表达”计数 + 最长单词”两个结果,应返回一个不可变的小对象。 - 不要让方法既修改全局状态又打印——两个副作用都让测试困难。
- 单个结果就返回它,不要返回”是否成功”的布尔值而把结果留在输出参数里。
- 需要多个结果时,返回一个小类(Java 16+ 可用
record,见补充说明)。 - 打印只发生在程序的边界层(
main、命令行前端、UI 层)。
- 方法签名要能表达全部结果:
避免特例代码(Avoid Special-Case Code)
- 定义与目的:为”看起来特殊”的输入(0、空数组、空字符串)单独写分支,会让代码更长、更难理解、更容易出现不一致。它损害全部三大目标。
- 直观解释(”它是什么?”):
countLongWords开头的if (words.length == 0)分支是多余的——省略它,空数组会让for循环什么都不做,最终照样打印 0。更糟的是:这个特例分支漏掉了通用代码里对longestWord的初始化,于是”空数组”与”非空但没有长单词”这两种都应得到相同结果的输入,行为却不一致。 - 关键规则与最佳实践:
- 写下特例
if之前先停下来:通用代码是不是本来就能处理它?往往是。 - 先写通用情形,再考虑特例;不要”先易后难”。
- 性能理由不成立时不成立:只有在有证据表明特例真的影响性能时才优化,否则只是增加复杂度和 bug 藏身处。
- 通用代码更短、假设更少、修改点更少,因此同时更安全、更好懂、更好改。
- 特例分支一旦存在,就必须与通用路径的结果保持一致——这正是 bug 最常出现的地方。
- 写下特例
代码示例与对比分析
场景 1:计算一年中的第几天——魔法数字、重复、参数复用三宗罪
❌ 错误代码
// 错误:魔法数字 + 复制粘贴 + 复用参数 dayOfMonth
public static int dayOfYear(int month, int dayOfMonth, int year) {
if (month == 2) {
dayOfMonth += 31;
} else if (month == 3) {
dayOfMonth += 59; // 59 是手工算出来的 31+28
} else if (month == 4) {
dayOfMonth += 90; // 90 是手工算出来的 31+28+31
} 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;
}
【错误代码的问题】
- 复用参数:
dayOfMonth先表示”这个月几号”,随后被改写为”一年中第几天”,两个含义不同的值共用一个名字,读者极易误解,任何后续需要原值的改动都做不到。 - 大量魔法数字与手工求和:
59、90是作者心算的结果,读者无法验证;一旦历法变化(例如二月改成 30 天),需要改动的数字分散在多处,极易漏改。 - 严重重复:
dayOfMonth += ...出现 11 次,”4 月有多少天”这个知识在多行里重复表达。 - 不快速失败:参数顺序传错(例如非美式日期习惯的
dayOfYear(9, 2, 2019))不会报任何错,只是安静地返回错误答案;没有任何注释说明month是 1–12 还是 0–11。
✅ 正确代码
// 正确:命名常量 + 数据表 + 单一用途变量 + final 参数 + fail fast + Javadoc
private static final int[] MONTH_LENGTH =
{ 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; // 索引 1 = 一月
/**
* Compute the day of the year.
* @param month month of the year, where January=1 and December=12
* @param dayOfMonth day of the month, where 1 <= dayOfMonth <= days in that month
* @param year the year, in the Gregorian calendar
* @return the day of the year, counting from 1; for example
* dayOfYear(2, 9, 2019) = 40
* @throws IllegalArgumentException if month or dayOfMonth is out of range
*/
public static int dayOfYear(final int month, final int dayOfMonth, final int year) {
if (month < 1 || month > 12) {
throw new IllegalArgumentException("month out of range: " + month);
}
if (dayOfMonth < 1 || dayOfMonth > monthLength(month, year)) {
throw new IllegalArgumentException("day out of range: " + dayOfMonth);
}
int dayOfYear = dayOfMonth; // 新变量承担新用途,参数不被污染
for (int m = 1; m < month; ++m) {
dayOfYear += monthLength(m, year);
}
return dayOfYear;
}
/** @return the number of days in month of year, where January=1 */
private static int monthLength(final int month, final int year) {
if (month == 2 && isLeapYear(year)) {
return 29;
}
return MONTH_LENGTH[month];
}
【为什么这样更好】 参数被声明为 final,编译器会阻止复用;dayOfYear 是一个全新的局部变量,用途单一;月份长度变成一张数据表加一次查表,重复的逻辑被 for 循环收敛为一次累加,重复的数字被 MONTH_LENGTH 收敛为一处定义;越界检查把”参数顺序传错”从”安静的错误答案”变成”立刻抛出的异常”;Javadoc 明确写了月份从 1 开始,消除了读者最大的猜测成本。
【代码对比解说】 两版代码在”正确输入下”的输出完全一致,差别全在可维护性曲线上:错误版本每增加一个特殊规则(闰年、历法改革)都要在多行里同步修改,且修改点之间没有结构约束;正确版本把”每个月几天”这个领域知识集中到一个地方,把”累加”这个算法收敛成一个循环。注意这里同时用到了两条本讲的原则:用数据表消除魔法数字,用循环消除逻辑重复。还要注意 MONTH_LENGTH[0] = 0 这个占位元素——它让 MONTH_LENGTH[month] 直接以 1 为下标,是”用一点空间换掉一次减法”的常见手法,但它本身必须靠注释或命名说清楚,否则下标从 0 还是 1 开始又变成了新的猜测点。
【设计原则透视】 这一组把 DRY、避免魔法数字、每个变量一个用途、快速失败四条规则叠在一起。用规格说明的语言说:错误版本隐式假设了”month 是 1–12”这一前置条件却从未写下,因此客户违反它时实现返回垃圾值;正确版本把该前置条件写进 @param,并在实现中主动检查,把违反前置条件变成显式的动态错误——这正是 Reading 6(Specifications)中”前置条件是客户的责任”的具体落地。
场景 2:闰年判断——把数字当字符串、糟糕命名、隐晦逻辑
❌ 错误代码
// 错误:靠字符串下标取十进制位、名字无意义、隐式魔法数字、逻辑难以核对
public static boolean leap(int y) {
String tmp = String.valueOf(y);
if (tmp.charAt(2) == '1' || tmp.charAt(2) == '3' || tmp.charAt(2) == 5
|| tmp.charAt(2) == '7' || tmp.charAt(2) == '9') {
if (tmp.charAt(3) == '2' || tmp.charAt(3) == '6') return true; /*R1*/
else return false; /*R2*/
} else {
if (tmp.charAt(2) == '0' && tmp.charAt(3) == '0') return false; /*R3*/
if (tmp.charAt(3) == '0' || tmp.charAt(3) == '4' || tmp.charAt(3) == '8') return true; /*R4*/
}
return false; /*R5*/
}
【错误代码的问题】
- 隐式假设年份恰好是 4 位数字:
charAt(2)、charAt(3)在leap(916)(3 位)或leap(10016)(5 位)时会抛StringIndexOutOfBoundsException,或读到完全错误的位置。 tmp.charAt(2) == 5是真实的静默 bug:charAt返回char,与int字面量比较是合法的数值比较,'5'的码点是 53,永远不等于 5。于是”十位是 5”这一支永远不会命中,代码不会报编译错误,只会算错。- 名字
leap、tmp毫无信息量;R1–R5这样的行号注释只告诉你”这是第几条返回”,不告诉你”为什么”。 - 只支持公元 4 位数年份、无法表达闰年规则本身,读者必须做心算才能确认它对不对。
✅ 正确代码
// 正确:命名好、规则直接可见、逻辑用算术表达、使用辅助方法消除重复
/**
* Test whether a year is a leap year in the Gregorian calendar.
* @param year a year in the Gregorian calendar; requires year >= 1
* @return true if and only if year is a leap year, i.e. divisible by 4
* but not by 100, unless it is also divisible by 400
*/
public static boolean isLeapYear(final int year) {
return isDivisibleBy(year, 4)
&& (!isDivisibleBy(year, 100) || isDivisibleBy(year, 400));
}
/** @return true if and only if number is divisible by factor */
private static boolean isDivisibleBy(final int number, final int factor) {
return number % factor == 0;
}
【为什么这样更好】 方法名 isLeapYear 是动词短语且自解释;规则直接写成算术表达式,与公历闰年定义一一对应,读者可以逐字核对;isDivisibleBy 消除了四处重复的取模逻辑;不再依赖字符串下标,因此对任意位数的年份都正确;剩下的数字 4、100、400 是领域定义的一部分——它们就是闰年定义本身,而不是”算出来的中间量”,因此可以接受为具名方法参数中的字面量(若想更进一步,也可声明为 private static final int YEARS_PER_LEAP_CYCLE = 4; 之类的常量)。
【代码对比解说】 错误版本的”聪明之处”(用十进制字符判断整除性)恰恰是它最大的问题:它把程序员的心理过程固化成代码,而把问题定义丢掉了。正确版本反过来——先写下定义,再让代码逐字对应定义。注意 isDivisibleBy 并没有让代码变短很多,但它把”取模等于 0”这一惯用法命名了,使 isLeapYear 的三行读起来像一句英文。这就是”好名字可以替代注释”的典型场景:正确版本几乎不需要行内注释,因为名字已经说明了一切。补充说明:Java 8 的 java.time.Year.isLeap(int) 已经实现了这个规格,实际项目中应优先复用标准库,而不是自己重写——这也是一条 DRY。
【设计原则透视】 这组对比同时体现易理解性(命名与结构)与安全性(消除静态无法发现、运行时静默出错的 char == int 比较)。它也与 Reading 1(Static Checking)呼应:错误版本把类型系统本来可以帮忙的地方(用 int 直接做算术)换成了字符串操作,从而主动放弃了静态检查的保护。此外 isDivisibleBy 是一个纯粹的函数——没有副作用、结果只依赖参数——这种”纯函数”是最容易测试、最容易推理的代码形态,与 Reading 6(Specifications)中”后置条件只谈参数与返回值”的理想规格一致。
场景 3:统计长单词——全局变量、打印结果、特例代码
❌ 错误代码
// 错误:可变全局变量 + 打印结果 + 空列表特例分支(且特例与通用路径不一致)
public static int LONG_WORD_LENGTH = 5; // 缺少 final:任何人都能改这个"常量"
public static String longestWord; // 全局可变状态,处处可读写
public static void countLongWords(String text) {
String[] words = text.split(" ");
if (words.length == 0) { // 特例分支:多余,且漏了 longestWord 的初始化
System.out.println("0");
return;
}
int n = 0;
longestWord = "";
for (String word : words) {
if (word.length() > LONG_WORD_LENGTH) ++n;
if (word.length() > longestWord.length()) longestWord = word;
}
System.out.println(n); // 结果被打印,无法被程序复用
}
【错误代码的问题】
- 全局可变状态:
longestWord在任何地方都能被读取和修改,调用一次之后结果会残留,两个线程或两次嵌套调用会互相污染,bug 的影响范围无法界定。 - 结果被打印而不是返回:想把这个计数用于排序、写报告或聚合时就只能重写方法;单元测试也不得不捕获标准输出,测试变得脆弱(与 Reading 3 Testing 直接冲突)。
- 特例分支与通用分支行为不一致:空数组时
longestWord不会被重置为"",于是”空数组”与”非空但没有长单词”这两种都该得到 0 的输入,留下了longestWord的残留值——这正是课程原文指出的隐藏 bug。 LONG_WORD_LENGTH声明为public static(而非public static final),它仍然是一个全局变量。
✅ 正确代码
// 正确:全局常量 + 返回结果对象 + 无特例分支 + 参数 final
public static final int LONG_WORD_LENGTH = 5; // 全局常量:只读,风险消失
/** The two results of countLongWords, bundled into one immutable value. */
public static final class WordStats {
private final int count;
private final String longestWord;
public WordStats(final int count, final String longestWord) {
this.count = count;
this.longestWord = longestWord;
}
/** @return the number of words longer than LONG_WORD_LENGTH */
public int count() { return count; }
/** @return the longest word in the text, or "" if there are no words */
public String longestWord() { return longestWord; }
@Override public String toString() {
return count + " long words, longest = \"" + longestWord + "\"";
}
}
/**
* Count the words in text that are longer than LONG_WORD_LENGTH,
* and find the longest word.
* @param text words separated by single spaces
* @return the count and the longest word; for empty text, count is 0 and
* the longest word is "" (the general case handles this correctly)
*/
public static WordStats countLongWords(final String text) {
int count = 0;
String longest = "";
for (String word : text.split(" ")) { // 空数组时循环体不执行,自然得到 (0, "")
if (word.length() > LONG_WORD_LENGTH) ++count;
if (word.length() > longest.length()) longest = word;
}
return new WordStats(count, longest);
}
【为什么这样更好】 没有任何全局可变状态,调用者拿到的是一个全新的不可变结果对象,两次调用互不影响;结果被返回而不是打印,因此可以被测试、聚合、格式化或在 GUI 中展示;特例分支被彻底删除,空输入自然走通用路径并得到与”非空但无长单词”完全一致的 (0, ""),那个隐藏的不一致 bug 随之消失;LONG_WORD_LENGTH 加上了 final,从全局变量升级为全局常量。
【代码对比解说】 关键判断是”两个结果怎么一起返回”。错误版本用”打印一个 + 全局变量存一个”的混合手法,代价是两种副作用都让复用和测试变难。正确版本用一个小类打包结果——这比返回 Object[] 或 Pair<Integer, String> 更好,因为 WordStats 有名字、有类型、有文档,字段含义不会在调用点丢失。补充说明:Java 16+ 可以用 public record WordStats(int count, String longestWord) { } 一行得到同样的不可变载体。还要注意正确版本里循环外只初始化一次计数器,而没有任何为 words.length == 0 写的分支——通用代码覆盖特例,正是”避免特例代码”的字面示范。
【设计原则透视】 这一组把”不要全局变量”“返回结果而非打印”“避免特例代码”三条规则串在一起,并且直接展示了它们共享的底层理由:局部性。状态越局部,能改变它的代码越少,bug 的影响范围就越小;结果越显式(通过返回值流动),调用者与实现者之间的契约就越清楚。这与 Reading 6(Specifications)中”后置条件要说明返回值与副作用”的要求一致:错误版本的方法签名 void 完全无法表达它的两个结果,规格说明根本写不出来;正确版本签名本身就承载了大部分后置条件。WordStats 的不可变性也预告了 Reading 8(Immutability)的核心论点:不可变对象可以被安全共享,无需防御性拷贝。
场景 4:注释与命名——冗余注释 vs 有信息量的规格说明
❌ 错误代码
// 错误:把代码翻译成英文的注释、无信息量的名字、与代码重复的说明
public static int h(int n) {
List<Integer> l = new ArrayList<>();
int i = 0;
while (n != 1) { // test whether n is 1 <-- 无价值注释
++i; // increment i <-- 无价值注释
l.add(n); // add n to l <-- 无价值注释
if (n % 2 == 0) n = n / 2; else n = 3 * n + 1;
}
l.add(1);
return l.size(); // 返回值含义完全不明
}
int tmp = 86400; // tmp is the number of seconds in a day(应当直接改名!)
【错误代码的问题】
- 注释只是把代码念了一遍,读者得不到任何新信息,反而增加阅读负担;真正需要解释的地方(
n必须大于 0、序列定义来自 Collatz 猜想)却完全没写。 - 名字
h、l、i、n、tmp让人无法在不读完整个方法的情况下知道任何东西;tmp那条注释实际上在说”这个变量应该改名叫secondsPerDay“。 - 没有规格说明,调用者不知道
h(0)或h(-5)会发生什么(无限循环),也不知道返回值到底代表什么。 - 参数
n在循环中被反复修改,调用者无法从方法体内恢复原值。
✅ 正确代码
// 正确:让名字承担解释职责,只在代码无法自述之处写注释,并把规格写成 Javadoc
private static final int SECONDS_PER_DAY = 86400;
/**
* Compute the hailstone sequence.
* See https://en.wikipedia.org/wiki/Collatz_conjecture
* @param n starting number of the sequence; requires n > 0
* @return the hailstone sequence starting at n and ending with 1;
* for example, hailstoneSequence(3) = [3, 10, 5, 16, 8, 4, 2, 1]
*/
public static List<Integer> hailstoneSequence(final int n) {
List<Integer> sequence = new ArrayList<>();
int current = n; // 参数 n 保持原值不变
while (current != 1) {
sequence.add(current);
current = (current % 2 == 0) ? current / 2 : 3 * current + 1;
}
sequence.add(1);
return sequence;
}
// 只有在代码本身无法表达"为什么"时才写注释:
int sum = n * (n + 1) / 2; // Gauss's formula for the sum of 1...n
// here we are using the sin x ~= x approximation, which works for very small x
double moonDiameterInMeters = moonDistanceInMeters * apparentAngleInRadians;
【为什么这样更好】 名字本身说明了一切:hailstoneSequence、sequence、current、SECONDS_PER_DAY,读者无需注释即可读懂;注释被用在真正需要的地方——数学公式的来源与近似条件的适用前提,这两处是代码无法自述的”为什么”;Javadoc 明确写出前置条件 n > 0(这正是 Reading 6 的规格说明),把”h(0) 会怎样”变成客户的责任而不是读者的猜测;参数被 final 保护,循环使用新变量 current。
【代码对比解说】 判断注释好坏的标准不是”有没有注释”,而是”这条注释是否提供了代码本身没有的信息”。翻译型注释的信息量为零;出处型注释(这段代码抄自哪里)、假设型注释(这里用了什么近似、在什么条件下成立)、规格型注释(前置/后置条件)的信息量很高。同样地,把 tmp 改名成 secondsPerDay 之后,那条注释就可以整条删除——这是”用更好的名字消除注释”的典型收益。注意正确版本里的 ? : 条件表达式把 Collatz 规则放在一行,配合好名字反而比 if/else 更紧凑;这属于”排版服务读者”的取舍,团队一致即可。
【设计原则透视】 这一组把”注释”与”命名”两条规则捆在一起,落到 Reading 6(Specifications)的核心:注释中最重要的那一类就是规格说明,它把接口的假设变成可传递的契约。同时它体现了 Reading 4 与 Reading 3(Testing)的分工:有了 @param n requires n > 0,测试就知道不该去测 h(0);反过来,如果没有规格,测试作者只能靠猜——这正是课程中”gcd 的规格在哪里”那组练习想说明的问题。
场景 5:防御性编程 vs 快速失败——静默容错还是立刻报错?
❌ 错误代码
// 错误:对内部调用者的错误"温柔容错",把 bug 变成错误答案
public static int dayOfYear(int month, int dayOfMonth, int year) {
if (month < 1) month = 1; // 静默修正:调用者的 bug 被吞掉
if (month > 12) month = 12; // 静默截断
if (dayOfMonth < 1) dayOfMonth = 1;
if (dayOfMonth > 31) dayOfMonth = 31;
int total = dayOfMonth;
for (int m = 1; m < month; ++m) total += 30; // 又用 30 这个魔法数字掩盖了错误
return total;
}
【错误代码的问题】
- 静默容错把”调用者传错参数”这个 bug 转成了一个看起来合理的错误答案,错误会一路传播到很远的地方才爆发,定位成本极高(违反 fail fast)。
- 参数被就地修改,违反”每个变量一个用途”和”参数不应被修改”的规则。
- 用
30这个统一的魔法数字代替真实月份长度,即使输入合法也算错,属于”为了容错而放弃正确性”。 - 没有任何文档说明这些”修正”的存在,读者会以为方法是”宽容”的,从而更依赖这种未定义行为。
✅ 正确代码
// 正确:内部代码相信前置条件,违反时立刻失败;外部边界处才做校验与转译
/**
* @param month month of the year, where January=1 and December=12
* @param dayOfMonth day of the month, 1 <= dayOfMonth <= days in that month
* @param year the year; requires year >= 1
* @return the day of the year, counting from 1
* @throws IllegalArgumentException if any argument is out of range
*/
public static int dayOfYear(final int month, final int dayOfMonth, final int year) {
if (month < 1 || month > 12) {
throw new IllegalArgumentException("month out of range: " + month); // fail fast
}
if (dayOfMonth < 1 || dayOfMonth > monthLength(month, year)) {
throw new IllegalArgumentException("day out of range: " + dayOfMonth);
}
int total = dayOfMonth;
for (int m = 1; m < month; ++m) {
total += monthLength(m, year);
}
return total;
}
// 只有真正的外部边界(这里是一个命令行前端)才做校验,并把失败转译成用户能懂的提示
public static void main(String[] args) {
if (args.length != 3) {
System.out.println("usage: DayOfYear <month> <day> <year>");
return;
}
try {
int month = Integer.parseInt(args[0]);
int day = Integer.parseInt(args[1]);
int year = Integer.parseInt(args[2]);
System.out.println(dayOfYear(month, day, year));
} catch (NumberFormatException e) {
System.out.println("please enter numbers, not text");
} catch (IllegalArgumentException e) {
System.out.println("invalid date: " + e.getMessage());
}
}
【为什么这样更好】 核心逻辑对”内部错误”零容忍:参数不合法立刻抛异常,错误在离成因最近的地方被报告;final 保证参数不被就地篡改;校验通过后,循环里使用的每一个月份长度都是真实值,正确性不再被”容错”牺牲。而外部边界(main 里的命令行解析)承担了防御与转译的职责:Integer.parseInt 的 NumberFormatException 与内部契约的 IllegalArgumentException 被转译成用户看得懂的提示,程序不会以难懂的堆栈崩溃。
【代码对比解说】 两段的差别不是”谁更健壮”,而是”谁把失败放在了正确的位置”。错误版本把容错放在最核心的计算逻辑里,代价是错误被掩盖;正确版本把防御集中在系统边界,核心逻辑保持纯粹和快速失败。这就是 6.031 的立场:契约内部相信契约,契约边界验证契约。异常转译(把低层异常转成高层异常)还额外带来可修改性——将来核心逻辑换用别的日期库,边界层的提示语不用变。补充说明:若参数来自不可信来源,除抛异常外还应考虑日志与限流等措施,但那属于系统边界的设计范畴,不应侵入 dayOfYear 本身。
【设计原则透视】 这组对比把”快速失败”与 Reading 6(Specifications)的异常章节连起来:什么时候抛异常、抛什么异常、要不要写进规格说明,都是规格设计决策。它也预告了 Reading 9(Avoiding Debugging)中的断言:断言用于检查”本该永远为真”的假设(如表示不变量),而异常用于向客户报告可预期的失败——两者都是快速失败,但受众不同。进一步说,”谁会读这个错误”决定了手段:内部 bug 用断言/异常,用户输入用提示信息,二者不该混在一个方法里。
与其他设计原则的关联
- 与 Reading 1(Static Checking):快速失败有三个层级——静态检查快于动态检查,动态检查快于静默的错误答案。本讲中”用
enum Month代替int month"”用final修饰参数”都是把检查提前到编译期的具体手段。tmp.charAt(2) == 5这类静默 bug 之所以危险,正是因为它绕过了静态检查(char与int的数值比较在 Java 中合法)。 - 与 Reading 2(Basic Java/TypeScript):本讲关于可变性、
static、final、空值与非空的讨论,为后续 Reading 8(Immutability)的不可变对象与 Reading 6(Specifications)的 null 约定打基础。 - 与 Reading 3(Testing):”函数应该返回结果而不是打印”直接决定了可测性:打印结果的方法无法用断言检查返回值,只能捕获标准输出,测试因此脆弱。代码审查与测试是互补的两道防线——测试发现行为错误,审查发现理解成本与修改成本。
- 与 Reading 5(Version Control):本讲反复强调”不要用注释保存旧代码”,其可行性完全依赖版本控制——历史版本已经在仓库里,删掉死代码比注释掉它更安全。同时,提交信息与差异审查是代码审查在时间维度上的延伸。
- 与 Reading 6(Specifications):本讲中最重要的注释类型就是规格说明。前置条件写进
@param、后置条件写进@return的做法在本讲只是点到,Reading 6 会给出契约的完整理论与异常、null、副作用的规定。 - 与 Reading 8(Immutability):”不要全局变量”的正解之一是把可变状态收敛进对象;不可变对象可以被安全共享,因此”全局常量”是安全的,而”全局变量”不是。
- 与 Reading 9(Avoiding Debugging):防御性编程与快速失败的边界划分在本讲确立,Reading 9 会用断言(assertions)与表示不变量把它变成可执行的检查。
- 与 Reading 10–11(Abstract Data Types / Abstraction Functions & Rep Invariants):本讲要求规格说明不得谈论局部变量与私有字段;Reading 11 的表示不变量正是”私有字段上的约束”,两者共同构成”抽象边界”的两侧——对外是规格,对内是 RI。
关键要点
- 审查的两个目的同等重要:改代码与改人。发现问题时优先解释”这条规则服务于哪个质量目标”,而不是宣布”这样写不好”。
- 快速失败优先于静默容错:把检查尽量左移到编译期,其次放到方法入口,绝不用”修正参数”掩盖内部 bug;防御只发生在不可信边界。
- DRY 是安全属性而非审美偏好:任何一处重复都意味着未来有一个”只改了一半”的机会。用命名常量、数据表和辅助方法三件工具消除重复。
- 名字、空白、注释是给下一位读者的界面:好名字消除注释,好排版暴露结构,注释只写代码无法自述的”为什么”,规格说明写前置/后置条件。
- 把状态和结果都局部化:不用全局可变变量,不用打印代替返回,不为特例写分支——三条规则的共同目的是缩小 bug 的影响范围、扩大代码的复用范围。
常见陷阱与注意事项
- 把”防御性编程”当成万能美德 → 在内部代码里做静默容错,调用者的 bug 被吞掉,错误在下游以完全不同的形式爆发,定位成本成倍增加。判据是:这个检查保护的是”我不信任的输入源”还是”我自己写的调用点”。
- 用返回值
-1/null/false表示失败 → 失败变成了一个看起来合法的值,客户端很容易忘记检查,于是错误数据继续向下流动。应采用异常或在签名中显式表达的”特殊结果”(见 Reading 6)。 - 为”性能”提前写特例分支 → 代码变长、分支间行为可能不一致、bug 藏身处增多,而收益往往根本不存在(特例输入很少出现)。先写干净的通用算法,只有在有证据时才优化。
- 把注释写成代码的英文翻译 → 注释与代码同步腐烂,读者多读一遍却没获得新信息;真正重要的假设(单位、范围、来源)反而没人记录。
- 改写参数或复用变量 → 读者被”同名不同义”误导,未来需要原值的改动无法完成。用
final参数与”一变量一用途”从语法上阻断这种写法。 - 认为代码审查只是”找 bug” → 忽略易理解性与可修改性的意见,团队会持续积累阅读成本;审查意见里”这段我看不懂”和”这里有个 NPE”同样重要。
思考题(带答案)
问题 1:下面的方法”看起来”很防御,请指出它的问题,并给出改进方案。
public static int countLongWords(String text) {
if (text == null) return 0;
if (text.length() == 0) return 0;
String[] words = text.split(" ");
int n = 0;
for (String word : words) if (word.length() > 5) ++n;
return n;
}
答案:三个问题。第一,text == null 的检查是”静默容错”:如果 text 是 null,那一定是调用者的 bug,返回 0 会让这个 bug 变成”这段文本没有长单词”的错误结论,违反快速失败;按照 Reading 6 的约定,参数非空是隐式前置条件,实现可以(也应该)让 NullPointerException 自然抛出,或显式 Objects.requireNonNull(text) 抛出更清楚的 NullPointerException。第二,text.length() == 0 是多余的特例分支——"".split(" ") 返回长度为 1、元素为 "" 的数组,循环体同样不会计数,结果自然为 0;即使按其他语言的语义返回空数组,通用路径也照样得到 0。第三,5 是魔法数字,应提为 public static final int LONG_WORD_LENGTH = 5;(或作为参数传入)。改进后的版本:保留 final 参数、删除两个 if、用命名常量、Javadoc 写明前置条件”text 非空”和返回值的定义。
问题 2:为什么 leap() 里的 tmp.charAt(2) == 5 不会产生编译错误,却会产生错误结果?这与”fail fast”有什么关系?
答案:在 Java 中 char 可以参与数值比较,charAt(2) 返回的 char 会被提升为 int 再与字面量 5 比较。字符 '5' 的 Unicode 码点是 53,因此 '5' == 5 恒为 false——这一支条件永远不会命中,编译器不会报错,程序也不会抛异常。它属于最慢的一类失败:静默错误答案。从 fail fast 的角度看,这段代码同时违反了”静态检查优先”(本该用 int 做算术,却退化成字符比较,把类型系统能帮的忙全部放弃)和”尽早暴露问题”(错误只在特定年份的结果里体现,可能需要很久才被发现)。修正方式是回到算术表达:year % 4 == 0 && (year % 100 != 0 \|\| year % 400 == 0)。
问题 3:countLongWords 的”空数组特例分支”为什么不是”更健壮”,反而引入了 bug?请用”通用代码 vs 特例代码”的框架解释,并说明如何系统性地避免这类问题。
答案:特例分支与通用分支是两条独立演化的代码路径,只要它们对同一类结果负责,就必须保持行为一致——而人总会漏掉同步。原例中通用路径在循环前执行了 longestWord = "",特例路径提前 return 跳过了它,于是”空数组”和”非空但没有长单词”这两种都应返回 0 的输入,在 longestWord 上产生了不同结果,这是典型的”分支不一致 bug”。系统性避免的办法有三条:一是先写通用路径并确认它能覆盖特例(本例中 for 循环对空数组天然不做任何事);二是把特例视为”通用算法的退化情形”而不是”新的算法”,只在通用路径无法处理时才增加分支;三是如果确实必须保留快路径(例如排序对长度 0/1 的提前返回),也要让它与通用路径产生完全相同的外部可观察行为,并用测试覆盖边界输入。
