Lec 13 调试
今天的话题是调试。首先讨论避免调试——要么完全避免调试,要么让调试变得容易。有些时候必须得调试——特别需要重复尝试或者说整个系统一起工作出现的bug,很难定位某个模块。针对这些情况,我们提出一个通用加快调试的系统策略。
有几个阅读资料:
- 《Why Programs Fail》是关于这方面的。这节内容也是主要参考这本书。
- 《How to Debug》by John Regehr,是关于嵌入式系统课程,更底层但是原理通用。
- 《Bebugging: The Nine Indispensable Rules for Finding Even the Most Elusive Software and Hardware Problems》是一个很好的实践指引。
一堆臭名昭著的bug
开始之前回顾下试图解决的问题,列举一些常见的bug类型。
别名bug
Aliasing bug,当两个或多个引用指向一个可变对象,而其中一个修改了,另外一个却希望她保持不变,就会出现这种bug。
下表偏移
off-by-on bug,这类bug包括:
- 0 起始和 1起始的索引混淆
- 循环终止条件出错:当你反向遍历数组时,终止条件应该是
i > 0还是i >= 0?又比如你在处理相邻元素对a[i]和a[i+1]时,终止条件该是i < a.length还是i < a.length - 1? - 栅栏柱 Bug(Fencepost bug):如果你有
n根横杆的围栏,需要多少根立柱?
等等等等
第一道防线: 让bug变得不可能
最好的方法就是通过设计让bug不能发生。一个方法就是我们提到过的静态检查。他能在编译期消除bug
我们看到过一些动态检查的例子。Python 通过动态检查避免了数组越界错误:当你尝试使用超出列表范围的索引时,Python 会自动抛出错误。相比之下, JS表现就差一点,它在读取越界元素时不会报错,而是悄悄返回undefined。 而在2024年,National Cyber Director发布一个report,阐述了改进网络安全实践的多种方式,其中包括逐步淘汰不具备内存安全的编程语言,如C和C++
不可变性(immutability)是另一种可以防止BUG的设计原则,它有另种常见形式。
- 不可变类型(immutable types)
- 不可重新赋值的引用(unreassgnable references),也就是常量。
TS提供了不可重新赋值的引用,通过关键字 const声明变量,const声明的变量只能被复制一次,之后就不能重新赋值。在声明局部变量时,尽可能使用const是一种好习惯。就像变量的类型的一样,也是重要的文档信息。
看一个例子
const letters: Array<string> = ['a', 'e', 'i', 'o', 'u'];这种变量letters 使用const声明的,下面那一条是合法的。
letters = ['x', 'y', 'z']; //?
letters[0] = 'z'; //?要特别小心对const的理解,它只限制变量引用本身不能被重新赋值,但引用所指向的对象本身仍然可能是可变的。
第二道防线:让Bug容易发现
静态检查、动态检查和不可变性在预防错误方面作用有限,因此我们的下一项设计策略是:当错误不可避免地出现时,让它们更容易被发现。我们可以将错误局部化,使其影响范围限定在程序的很小一部分,从而无需大范围排查就能找到根源。当错误被限定在单个方法或小模块内时,仅通过研究代码就可能发现它们。
我们已讨论过快速失败原则:问题被发现得越早(离其根源越近),修复就越容易。
从一个简单示例开始:
/**
* @param x 要求 x >= 0
* @returns x 的平方根近似值
*/
function sqrt(x: number): number { ... }假设有人用负数参数调用 sqrt,此时 sqrt 的最佳行为是什么?由于调用方未满足 x 应为非负数的先决条件。最有用的行为是尽早指出问题。我们通过插入运行时前置条件检查来实现:
function sqrt(x: number): number {
if ( ! (x >= 0)) throw new Error("要求 x >= 0,但实际值为: " + x);
...
}当先决条件不满足时,此代码通过抛出包含具体错误值的异常来终止程序,既阻止了调用方错误的效应扩散,又为其提供了有用的调试信息。
断言
实践中通常定义专门函数进行此类防御性检查,称为 assert。这种方法抽象了断言失败时的具体处理逻辑(退出程序、记录日志或发送报告)。
JavaScript/TypeScript 没有内置 assert,但 6.102 课程使用 Node.js 的断言库。最简单的断言形式接受布尔表达式,并在表达式为 false 时抛出 AssertionError:
assert(x>=0);断言可包含字符串信息,在失败时提供额外上下文,同时附带的调用栈信息能精确定位问题源头。
assert(x >= 0, "x的值为" + x);需注意,许多语言(如 Java)中断言默认关闭,主要出于性能考量。例如二分查找要求数组有序,但验证此条件需扫描整个数组,将对数时间复杂度降为线性。在测试阶段应积极承担此成本以方便调试,但发布版本可关闭断言。Node.js 断言库始终执行断言,无需特别启用。
断言内容
应断言以下内容:
- 参数先决条件
- 返回值后置条件
- 表示不变性
断言应在编写代码时同步创建,此时不变式思路最清晰。事后补写容易遗漏重要约束。
避免断言的情况
运行时断言并非零成本。需避免以下情况:
琐碎断言(如
x = y + 1; assert(x === y+1);)。此类断言不检测程序错误,而是检验编译器/解释器正确性外部条件检测(如文件存在性、网络可用性、用户输入正确性)。断言应用于检验程序内部状态是否符合规范。外部故障应通过异常机制处理
带副作用的断言表达式。由于断言可能被禁用,应确保程序正确性不依赖断言执行。例如
对于未覆盖所有分支的条件语句,使用断言拦截非法路径:
tsif (level == UNDERGRAD) { return 'U'; } else if (level == GRAD) { return 'G'; } else { assert.fail('不应执行至此'); }
增量开发
通过增量开发可将错误局限在微小范围内:逐步构建程序,每完成一个单元即充分测试。这样发现的错误大概率存在于最新编写的代码中。相关技术支持包括:
- 单元测试:隔离测试模块,将错误范围限定在单元内
- 回归测试:添加新功能时频繁运行回归测试套件,若测试失败则错误很可能在新修改的代码中
- 版本控制:频繁提交推送可将错误定位范围缩小到两次提交间的差异
模块化与封装
优良设计能通过以下方式帮助错误定位:
模块化:将系统分解为可独立设计、测试、理解的组件。相反, monolithic 系统(如超长函数)难以理解和调试
封装:通过访问控制(
public/private)限制变量和方法的可见性。尽量保持私有性,特别是变量,可减少意外错误传播变量作用域最小化:将变量定义在尽可能小的作用域内,显著简化错误推理过程。例如:
ts// 作用域过大(整个模块): let i: number; for (i = 0; i < 100; ++i) { doSomeThings(); // i 可能在此处被意外修改 } // 作用域受限(仅循环内): for (let i = 0; i < 100; ++i) { doSomeThings(); // i 不会在此被修改 }- 循环变量应在
for初始化器中声明(使用let) - 始终使用
const或let,避免var(var作用域为整个函数) - 在首次需要时于最内层代码块中声明变量
- 避免全局变量。通过参数传递或创建 ADT 封装共享状态,而非使用全局空间
- 循环变量应在
复现Bug
假设有人向您报告了您编写软件中的错误,首先,找到一个能稳定复现故障的最小测试用例。若错误通过回归测试发现,则您已拥有现成的失败用例;若由用户报告,则可能需要付出努力来复现。对于图形界面和多线程程序,若错误依赖于事件或线程执行时序,可能难以稳定复现。
但投入精力构建最小可复现用例是值得的,因为在定位和修复过程中需反复运行它。此外,成功修复后应将该用例纳入回归测试集,防止错误再次出现。一旦拥有测试用例,让该测试通过就成为明确目标。
假设编写了一下函数:
/**
* 查找字符串中最常见的单词。
* @param text 包含零个或多个单词的字符串,单词由非字母数字字符分隔的字母数字字符串构成
* @returns 在 text 中出现次数最多的单词(忽略字母大小写)
*/
function mostCommonWord(text: string): string {
...
}用户将莎士比亚全部剧作的文本传入该方法,期望返回常见英文单词(如 "the" 或 "a"),但实际返回了意外结果(如 "e")。莎翁剧作有 10 万行超过 80 万个单词,直接调试如此巨大的输入非常困难。应首先缩减输入规模至可管理且能暴露相同(或类似)错误的大小:
- 前半部分剧作是否出现相同错误?(可继续二分切割)
- 单个剧作是否重现错误?
- 单个独白是否重现错误?
找到小测试用例后,基于其进行调试和修复,最后回到原始输入确认修复效果。
运用科学方法定位Bug
修复Bug
发现并理解错误成因后,第三步是设计修复方案。切忌草率打补丁了事。需判断错误性质:是拼写错误、参数传反等编码失误,还是接口设计缺陷或规范不完整等设计问题。若是设计错误,应考虑回溯并重新审视设计,至少检查使用该故障接口的其他客户端是否也存在相同问题。
排查关联错误与新错误。思考代码其他位置是否存在类似错误。若发现除零错误,需排查代码中是否存在同类问题。同时评估修复方案的影响范围,避免引发新问题。
撤销调试探针。定位错误过程中可能存在的注释代码、添加打印语句等临时修改,在提交修复前务必全部清除。通过 git diff 检查可确保彻底清除调试痕迹。
创建回归测试。修复完成后,将错误测试用例加入回归测试集,全面运行测试以验证:(a)错误已修复;(b)未引入新错误。
提交与反思。通过 add/commit/push 保存代码后,应进行复盘:最高效的调试策略是什么?如何预防同类错误?此时应对代码产生更强的掌控感。若未达此状态,可能意味着仅暂时消除了表面症状。
其他技巧
获取新视角(阿甘斯调试九法则之一)。向他人阐述问题常能带来突破,即"橡皮鸭调试法"。大声解释代码预期行为与实际表现的差异,往往能自主发现问题根源。
书面描述问题。当橡皮鸭法无效时,尝试撰写(模拟)求助邮件。以调试记录为纲,用1-2段文字精炼描述:
- 症状:基于最小化复现案例,说明预期与实际行为的差异,结合运行时错误信息分析
- 实验:列举已验证的假设与参考的解决方案,解释其不足
- 复核:以接收者视角审视内容,确保描述精准度。书面化过程本身常能带来新洞察。
寻求帮助。编程本就艰难,6.102课程师生与StackOverflow都是优质资源。
休眠决策。疲劳会降低调试效率,必要时用时间换成效。
总结
本阅读材料系统探讨了降低调试成本的方法:
- 避免调试:通过静态类型、动态检查、不可变性防患未然
- 错误隔离:快速失败机制限制错误扩散;增量开发与单元测试缩小排查范围;频繁提交代码;模块化与作用域最小化
- 系统调试:科学方法定位问题(二分法/增量调试生成假设,无害探针验证假设)
- 彻底修复:深度思考解决方案
这些实践全面提升代码质量三大维度:
- 安全可靠:预防与消除错误
- 易于理解:类型声明、断言等自动校验机制成为活文档;作用域最小化降低认知负荷
- 适应变化:自动化校验机制为后续修改提供安全保障