错误和 Bug

调试代码的难度是编写代码的两倍。因此,如果你尽可能巧妙地编写代码,那么根据定义,你就不够聪明来调试它。

Brian Kernighan 和 P.J. Plauger,《编程风格要素》
Illustration showing various insects and a centipede

计算机程序中的缺陷通常被称为bug(错误)。程序员想象这些错误就像爬进我们工作的小东西,这让他们感觉很好。当然,实际上,是我们自己把它们放进去的。

如果程序是凝固的思想,那么我们可以大致将 bug 分为两种:由思想混乱引起的 bug 和在将思想转换为代码时引入的错误引起的 bug。前者通常比后者更难诊断和修复。

语言

如果计算机能足够了解我们想做什么,那么它就可以自动地指出我们很多错误。但在这里,JavaScript 的宽松性却成了障碍。它对绑定和属性的定义过于模糊,以至于很少能在实际运行程序之前捕捉到拼写错误。即使如此,它也允许你毫无怨言地做一些明显荒谬的事情,比如计算true * "monkey"

JavaScript 确实会抱怨一些事情。编写一个不符合语言语法的程序将立即让计算机抱怨。其他事情,比如调用非函数的东西或在未定义的值上查找属性,将在程序尝试执行操作时导致报告错误。

然而,通常情况下,你的无意义计算只会产生NaN(非数字)或未定义的值,而程序会愉快地继续执行,相信它正在做一些有意义的事情。错误只会稍后出现,在伪造的值经过几个函数后。它可能根本不会触发错误,而是会静默地导致程序的输出错误。找到此类问题的根源可能很困难。

寻找程序中错误(bug)的过程称为调试

严格模式

通过启用严格模式,可以使 JavaScript 变得稍微严格一些。这可以通过在文件或函数体顶部添加字符串"use strict"来实现。下面是一个例子

function canYouSpotTheProblem() {
  "use strict";
  for (counter = 0; counter < 10; counter++) {
    console.log("Happy happy");
  }
}

canYouSpotTheProblem();
// → ReferenceError: counter is not defined

类和模块(我们将在第 10 章中讨论)内部的代码自动处于严格模式。旧的非严格行为仍然存在只是因为一些旧代码可能依赖于它,而语言设计人员努力避免破坏任何现有的程序。

通常,当你忘记在绑定前面添加let,例如在示例中的counter,JavaScript 会静默地创建一个全局绑定并使用它。在严格模式下,会报告错误。这非常有用。不过需要注意的是,当要绑定的名称已经在作用域中存在时,这种情况不起作用。在这种情况下,循环仍然会静默地覆盖绑定的值。

严格模式的另一个变化是,this绑定在未作为方法调用的函数中保存undefined的值。当在非严格模式下进行此类调用时,this引用全局作用域对象,该对象的属性是全局绑定。因此,如果你在严格模式下意外地错误地调用了一个方法或构造函数,JavaScript 会在尝试从this读取内容时立即产生错误,而不是愉快地写入全局作用域。

例如,考虑以下代码,它在没有new关键字的情况下调用一个构造函数,因此它的this不会引用一个新构造的对象

function Person(name) { this.name = name; }
let ferdinand = Person("Ferdinand"); // oops
console.log(name);
// → Ferdinand

Person的伪造调用成功了,但返回了一个未定义的值并创建了全局绑定name。在严格模式下,结果不同。

"use strict";
function Person(name) { this.name = name; }
let ferdinand = Person("Ferdinand"); // forgot new
// → TypeError: Cannot set property 'name' of undefined

我们立即被告知有些地方出错了。这很有帮助。

幸运的是,使用class表示法创建的构造函数在没有new的情况下调用时总是会抱怨,即使在非严格模式下,这也使得这个问题不那么严重。

严格模式还做了一些其他事情。它不允许给函数赋予多个具有相同名称的参数,并完全删除某些有问题的语言特性(例如with语句,它太糟糕了,这本书不再讨论它)。

简而言之,在程序顶部添加"use strict"很少会造成伤害,并且可能有助于你发现问题。

类型

一些语言希望在运行程序之前就知道所有绑定和表达式的类型。当类型以不一致的方式使用时,它们会立即告诉你。JavaScript 只在实际运行程序时才考虑类型,即使在那里,它也经常试图隐式地将值转换为它期望的类型,因此它没有多大帮助。

尽管如此,类型仍然为讨论程序提供了一个有用的框架。很多错误都是因为混淆了进入或退出函数的值的类型。如果你把这些信息写下来,你就不太可能感到困惑。

你可以像下面这样在上一章的findRoute函数之前添加一个注释来描述它的类型

// (graph: Object, from: string, to: string) => string[]
function findRoute(graph, from, to) {
  // ...
}

有很多不同的约定来用类型注释 JavaScript 程序。

关于类型的一点是,它们需要引入自己的复杂性才能描述足够多的代码以变得有用。你觉得randomPick函数的类型是什么?它从数组中返回一个随机元素。你需要引入一个类型变量T,它可以代表任何类型,这样你就可以给randomPick一个像(T[]) → T的类型(从T数组到T的函数)。

当程序的类型已知时,计算机可以为你检查它们,在程序运行之前指出错误。有几种 JavaScript 方言将类型添加到语言中并进行检查。最流行的一种被称为TypeScript。如果你有兴趣为你的程序添加更多严谨性,我建议你试一试。

在本书中,我们将继续使用原始的、危险的、无类型的 JavaScript 代码。

测试

如果语言没有做太多事情来帮助我们找到错误,我们只能用困难的方式找到它们:运行程序并查看它是否做了正确的事情。

手动地一遍又一遍地做这件事,这是一个非常糟糕的主意。它不仅很烦人,而且往往效率低下,因为每次你修改代码时,都需要花费大量时间来彻底测试所有内容。

计算机擅长重复性任务,而测试是理想的重复性任务。自动化测试是指编写一个程序来测试另一个程序。编写测试比手动测试工作量更大,但一旦你完成了它,你就会获得一种超级能力:它只需要几秒钟就可以验证你的程序在所有你编写测试的情况中仍然正常工作。当你破坏一些东西时,你将立即注意到,而不是在以后的某个时间随机地遇到它。

测试通常采用一些小的标记程序的形式,这些程序验证你的代码的某些方面。例如,针对(标准的,可能已经被其他人测试过)toUpperCase方法的一组测试可能如下所示

function test(label, body) {
  if (!body()) console.log(`Failed: ${label}`);
}

test("convert Latin text to uppercase", () => {
  return "hello".toUpperCase() == "HELLO";
});
test("convert Greek text to uppercase", () => {
  return "Χαίρετε".toUpperCase() == "ΧΑΊΡΕΤΕ";
});
test("don't convert case-less characters", () => {
  return "مرحبا".toUpperCase() == "مرحبا";
});

编写这样的测试往往会产生重复、笨拙的代码。幸运的是,有一些软件可以帮助你构建和运行测试集合(测试套件),它们提供了一种语言(以函数和方法的形式)来表达测试,并在测试失败时输出信息。这些通常被称为测试运行器

一些代码比其他代码更容易测试。一般来说,代码与之交互的外部对象越多,设置测试它的上下文就越困难。在上一章中展示的编程风格,它使用自包含的持久值而不是改变对象,往往很容易测试。

调试

一旦你注意到你的程序出现了问题,因为它行为异常或产生了错误,下一步就是弄清楚什么是问题。

有时,这是显而易见的。错误消息会指向你的程序的某一行,如果你查看错误描述和那一行代码,你通常可以看出问题所在。

但并非总是如此。有时触发问题的行只是第一个以无效方式使用其他地方产生的不稳定值的地方。如果你一直在解决前面章节的练习,你可能已经体验过这种情况。

下面的示例程序试图通过反复取出最后一位数字,然后除以该数字来删除该数字,将一个整数转换为给定基数(十进制、二进制等)的字符串。但它目前产生的奇怪输出表明它存在 bug。

function numberToString(n, base = 10) {
  let result = "", sign = "";
  if (n < 0) {
    sign = "-";
    n = -n;
  }
  do {
    result = String(n % base) + result;
    n /= base;
  } while (n > 0);
  return sign + result;
}
console.log(numberToString(13, 10));
// → 1.5e-3231.3e-3221.3e-3211.3e-3201.3e-3191.3e-3181.3…

即使你已经看到了问题,也假装一下你没有看到。我们知道我们的程序出现了故障,我们想找出原因。

不要因为想要改进代码就随意修改代码。相反,要 **思考**。分析正在发生的事情,并想出一个可能导致这种情况的理论。然后进行额外的观察来测试这个理论——或者,如果你还没有理论,就进行额外的观察来帮助你形成一个理论。

在程序中添加一些有策略的 console.log 调用是获取有关程序正在执行的操作的额外信息的好方法。在本例中,我们希望 n 分别取值 1310。让我们在循环开始时写出它的值。

13
1.3
0.13
0.013
…
1.5e-323

**正确**。13 除以 10 不能得到一个整数。我们实际上想要的是 n = Math.floor(n / base),而不是 n /= base,这样才能正确地将数字“右移”。

除了使用 console.log 来窥视程序的行为之外,还可以使用浏览器的 **调试器** 功能。浏览器具有在代码的特定行设置 **断点** 的功能。当程序的执行到达带有断点的行时,程序会暂停,您可以检查该点绑定值。我不会详细说明,因为调试器在不同浏览器之间有所不同,但请在浏览器的开发者工具中查找或在网上搜索说明。

另一种设置断点的方法是在程序中包含一个 debugger 语句(仅包含该关键字)。如果浏览器的开发者工具处于活动状态,程序将在到达该语句时暂停。

错误传播

不幸的是,并非所有问题都可以由程序员预防。如果您的程序以任何方式与外部世界通信,则有可能获得格式错误的输入、被工作量压垮或网络故障。

如果您只为自己编程,您可以承受只在问题出现时才忽略这些问题的代价。但是,如果您构建的是要供其他人使用的产品,您通常希望程序比仅仅崩溃做得更好。有时,正确的方法是泰然处之处理错误输入并继续运行。在其他情况下,最好向用户报告发生了什么错误,然后放弃。无论哪种情况,程序都必须积极地对问题做出反应。

假设您有一个名为 promptNumber 的函数,该函数会询问用户输入一个数字并返回该数字。如果用户输入“orange”,它应该返回什么?

一种选择是让它返回一个特殊值。常见的这种特殊值包括 nullundefined-1

function promptNumber(question) {
  let result = Number(prompt(question));
  if (Number.isNaN(result)) return null;
  else return result;
}

console.log(promptNumber("How many trees do you see?"));

现在,任何调用 promptNumber 的代码都必须检查是否读取了实际的数字,如果未读取,则必须以某种方式恢复——也许是再次询问或填充一个默认值。或者,它可以再次向**其**调用者返回一个特殊值,以指示它未能执行其请求的操作。

在许多情况下,尤其是在错误很常见并且调用者应该明确地考虑错误的情况下,返回一个特殊值是指示错误的好方法。但是,它也有一些缺点。首先,如果函数已经可以返回所有可能的类型的值呢?在这种情况下,您必须执行一些操作,例如将结果包装在对象中,以便能够区分成功和失败,就像迭代器接口上的 next 方法那样。

function lastElement(array) {
  if (array.length == 0) {
    return {failed: true};
  } else {
    return {value: array[array.length - 1]};
  }
}

返回特殊值的第二个问题是,它会导致代码变得笨拙。如果一段代码调用 promptNumber 10 次,它必须检查 10 次是否返回了 null。如果它对发现 null 的响应只是简单地返回 null 本身,则该函数的调用者将反过来不得不检查它,依此类推。

异常

当函数无法正常执行时,我们通常希望做的是停止当前操作并立即跳转到一个知道如何处理该问题的点。这就是**异常处理**的作用。

异常是一种机制,使代码能够在遇到问题时**引发**(或**抛出**)异常。异常可以是任何值。引发异常有点类似于函数的超强返回:它不仅跳出当前函数,还跳出其调用者,一直跳到启动当前执行的第一个调用。这被称为**展开堆栈**。您可能还记得**第 3 章**中提到的函数调用堆栈。异常会沿着这个堆栈向下移动,丢弃它遇到的所有调用上下文。

如果异常总是直接向下移动到堆栈的底部,那么它们就没有多大用处。它们只会提供一种新颖的方法来使程序崩溃。它们的力量在于您可以沿着堆栈设置“障碍”来**捕获**向下移动的异常。一旦您捕获了异常,您就可以对其进行处理以解决问题,然后继续运行程序。

以下是一个示例

function promptDirection(question) {
  let result = prompt(question);
  if (result.toLowerCase() == "left") return "L";
  if (result.toLowerCase() == "right") return "R";
  throw new Error("Invalid direction: " + result);
}

function look() {
  if (promptDirection("Which way?") == "L") {
    return "a house";
  } else {
    return "two angry bears";
  }
}

try {
  console.log("You see", look());
} catch (error) {
  console.log("Something went wrong: " + error);
}

throw 关键字用于引发异常。捕获异常的方法是将一段代码包装在一个 try 块中,后跟 catch 关键字。当 try 块中的代码导致引发异常时,会执行 catch 块,括号中的名称绑定到异常值。在 catch 块完成之后(或者如果 try 块在没有问题的情况下完成),程序将在整个 try/catch 语句下方继续执行。

在本例中,我们使用了 Error 构造函数来创建我们的异常值。这是一个标准的 JavaScript 构造函数,用于创建一个具有 message 属性的对象。Error 的实例还会收集创建异常时存在的调用堆栈的信息,即所谓的**堆栈跟踪**。该信息存储在 stack 属性中,在尝试调试问题时可能会有所帮助:它告诉我们问题发生在哪一个函数中以及哪些函数进行了失败的调用。

请注意,look 函数完全忽略了 promptDirection 可能出错的可能性。这是异常的一大优势:错误处理代码只需要在错误发生的地方和处理错误的地方进行。介于两者之间的函数可以忘记所有这些。

好吧,几乎是...

在异常后清理

异常的影响是另一种类型的控制流。每个可能导致异常的操作,即几乎所有函数调用和属性访问,都可能导致控制突然离开您的代码。

这意味着,当代码有多个副作用时,即使其“常规”控制流看起来好像总是会全部发生,异常也可能会阻止其中一些副作用发生。

以下是一些非常糟糕的银行代码

const accounts = {
  a: 100,
  b: 0,
  c: 20
};

function getAccount() {
  let accountName = prompt("Enter an account name");
  if (!Object.hasOwn(accounts, accountName)) {
    throw new Error(`No such account: ${accountName}`);
  }
  return accountName;
}

function transfer(from, amount) {
  if (accounts[from] < amount) return;
  accounts[from] -= amount;
  accounts[getAccount()] += amount;
}

transfer 函数将一笔款项从给定账户转到另一个账户,并在此过程中询问另一个账户的名称。如果给出了无效的账户名称,getAccount 会抛出异常。

但是 transfer 会*先*从账户中取出款项,*然后*在将其添加到另一个账户之前调用 getAccount。如果它在这一点被异常中断,它只会让钱消失。

这段代码本可以写得更聪明一点,例如,在开始移动钱之前调用 getAccount。但是,通常这类问题会以更微妙的方式发生。即使看起来不会抛出异常的函数也可能在特殊情况下或包含程序员错误时抛出异常。

解决此问题的一种方法是使用更少的副作用。同样,计算新值而不是更改现有数据的编程风格会有所帮助。如果一段代码在创建新值的中间停止运行,则不会损坏任何现有的数据结构,从而更容易恢复。

由于这并不总是可行,try 语句还有另一个功能:它们可以在 catch 块之前或之后,或者与其一起,被一个 finally 块跟随。finally 块表示“无论发生什么情况,在尝试运行 try 块中的代码之后都运行此代码”。

function transfer(from, amount) {
  if (accounts[from] < amount) return;
  let progress = 0;
  try {
    accounts[from] -= amount;
    progress = 1;
    accounts[getAccount()] += amount;
    progress = 2;
  } finally {
    if (progress == 1) {
      accounts[from] += amount;
    }
  }
}

此版本的函数跟踪其进度,并且如果在退出时它发现自己在创建不一致的程序状态的点被中止,则会修复它所做的破坏。

请注意,即使 finally 代码在 try 块中抛出异常时运行,它也不会干扰异常。在 finally 块运行之后,堆栈将继续展开。

编写即使在异常出现在意外位置时也能可靠运行的程序很难。许多人根本不费心,而且由于异常通常保留用于特殊情况,所以问题发生的频率可能很低,以至于从未被注意到。这是否是一件好事或一件非常糟糕的事情取决于软件在出现故障时会造成多少损害。

选择性捕获

当异常一直向下移动到堆栈的底部而没有被捕获时,它将由环境处理。这意味着什么在不同环境之间有所不同。在浏览器中,错误的描述通常会写入 JavaScript 控制台(可以通过浏览器的“工具”或“开发者”菜单访问)。Node.js 是一个无浏览器的 JavaScript 环境,我们将在**第 20 章**中讨论它,它对数据损坏更加谨慎。当出现未处理的异常时,它会中止整个进程。

对于程序员错误,仅仅让错误继续传递通常是最好的做法。未处理的异常是指示程序崩溃的合理方法,现代浏览器的 JavaScript 控制台会提供一些有关问题发生时堆栈上的函数调用信息。

对于在正常使用过程中*预期*会发生的错误,使用未捕获异常崩溃是一个糟糕的策略。

语言的无效使用,例如引用不存在的绑定,在null上查找属性,或调用不是函数的函数,也会导致引发异常。这些异常也可以被捕获。

当进入catch代码块时,我们只知道try代码块中的*某些*内容导致了异常。但我们不知道*是什么*导致了异常,也不知道是*哪个*异常。

JavaScript(在一个相当明显的遗漏中)没有提供直接支持选择性地捕获异常:要么全部捕获,要么不捕获任何异常。这使得人们容易*假设*您得到的异常就是您在编写catch代码块时想到的异常。

但它可能不是。其他一些假设可能会被违反,或者您可能引入了一个导致异常的错误。以下是一个*尝试*持续调用promptDirection直到获得有效答案的示例

for (;;) {
  try {
    let dir = promtDirection("Where?"); // ← typo!
    console.log("You chose ", dir);
    break;
  } catch (e) {
    console.log("Not a valid direction. Try again.");
  }
}

for (;;)结构是一种有意创建一个不会自行终止的循环的方式。我们只有在给出有效方向时才会退出循环。不幸的是,我们拼错了promptDirection,这将导致“未定义变量”错误。由于catch代码块完全忽略了它的异常值(e),假设它知道问题所在,它错误地将绑定错误视为指示了不良输入。这不仅会导致无限循环,而且还会“掩盖”关于拼写错误绑定的有用错误消息。

作为一般规则,不要 blanket-catch 异常,除非是为了将它们“路由”到某个地方——例如,通过网络告诉另一个系统我们的程序崩溃了。即使那样,也要仔细考虑您可能隐藏了哪些信息。

我们希望捕获一种特定类型的异常。我们可以通过在catch代码块中检查我们得到的异常是否是我们感兴趣的异常来做到这一点,如果不是,则重新抛出它。但我们如何识别异常呢?

我们可以将它的message属性与我们碰巧期望的错误消息进行比较。但这是一种不稳定的编写代码方式——我们将在使用旨在供人类消费的信息(消息)来做出编程决策。只要有人更改(或翻译)消息,代码就会停止工作。

相反,让我们定义一种新的错误类型并使用instanceof来识别它。

class InputError extends Error {}

function promptDirection(question) {
  let result = prompt(question);
  if (result.toLowerCase() == "left") return "L";
  if (result.toLowerCase() == "right") return "R";
  throw new InputError("Invalid direction: " + result);
}

新的错误类扩展了Error。它没有定义自己的构造函数,这意味着它继承了Error构造函数,后者需要一个字符串消息作为参数。事实上,它什么也没定义——类是空的。InputError对象的行为类似于Error对象,只是它们具有不同的类,我们可以通过该类来识别它们。

现在循环可以更仔细地捕获这些异常。

for (;;) {
  try {
    let dir = promptDirection("Where?");
    console.log("You chose ", dir);
    break;
  } catch (e) {
    if (e instanceof InputError) {
      console.log("Not a valid direction. Try again.");
    } else {
      throw e;
    }
  }
}

这将只捕获InputError的实例,并让不相关的异常通过。如果重新引入拼写错误,未定义绑定错误将被正确报告。

断言

断言是程序中的检查,用于验证某些内容是否按预期进行。它们不用于处理正常操作中可能出现的情况,而是用于查找程序员错误。

例如,如果firstElement被描述为一个永远不应该在空数组上调用的函数,我们可以这样编写它

function firstElement(array) {
  if (array.length == 0) {
    throw new Error("firstElement called with []");
  }
  return array[0];
}

现在,该函数不会默默地返回 undefined(当您读取不存在的数组属性时会得到 undefined),而是在您错误使用它时立即爆炸您的程序。这使得此类错误不太可能被忽视,并且更容易在发生时找到它们的原因。

我不建议尝试为每种可能的错误输入编写断言。这将是很多工作,并且会导致非常嘈杂的代码。您需要为容易犯的错误(或发现自己在犯的错误)保留断言。

总结

编程的重要部分是查找、诊断和修复错误。如果您有自动测试套件或在程序中添加断言,问题会更容易被发现。

由程序控制范围之外的因素引起的问题通常应该积极地计划。有时,当问题可以在本地处理时,特殊的返回值是跟踪它们的好方法。否则,异常可能更可取。

抛出异常会导致调用栈被展开,直到下一个封闭的try/catch代码块或直到堆栈底部。异常值将被提供给捕获它的catch代码块,该代码块应验证它是否是预期的异常类型,然后对它进行处理。为了帮助解决由异常引起的不可预测的控制流,可以使用finally代码块来确保代码块完成后始终运行一段代码。

练习

重试

假设您有一个名为primitiveMultiply的函数,该函数在 20% 的情况下会将两个数字相乘,而在另外 80% 的情况下会引发一个名为MultiplicatorUnitFailure的异常。编写一个包装这个笨拙的函数的函数,并不断尝试直到调用成功,之后它返回结果。

确保您只处理要处理的异常。

class MultiplicatorUnitFailure extends Error {}

function primitiveMultiply(a, b) {
  if (Math.random() < 0.2) {
    return a * b;
  } else {
    throw new MultiplicatorUnitFailure("Klunk");
  }
}

function reliableMultiply(a, b) {
  // Your code here.
}

console.log(reliableMultiply(8, 8));
// → 64
显示提示...

primitiveMultiply的调用肯定应该发生在try代码块中。相应的catch代码块应该在异常不是MultiplicatorUnitFailure的实例时重新抛出异常,并在它是实例时确保调用被重试。

为了进行重试,您可以使用一个仅在调用成功时停止的循环——就像本章前面提到的look示例——或者使用递归,并希望您不会遇到一连串的失败,这些失败会导致堆栈溢出(这是一个相当安全的赌注)。

锁定的箱子

考虑以下(相当人为的)对象

const box = new class {
  locked = true;
  #content = [];

  unlock() { this.locked = false; }
  lock() { this.locked = true;  }
  get content() {
    if (this.locked) throw new Error("Locked!");
    return this.#content;
  }
};

它是一个带锁的箱子。箱子里有一个数组,但只有在箱子解锁时才能访问它。

编写一个名为withBoxUnlocked的函数,该函数以函数值为参数,解锁箱子,运行函数,然后确保在返回之前箱子再次被锁定,无论参数函数是正常返回还是抛出异常。

const box = new class {
  locked = true;
  #content = [];

  unlock() { this.locked = false; }
  lock() { this.locked = true;  }
  get content() {
    if (this.locked) throw new Error("Locked!");
    return this.#content;
  }
};

function withBoxUnlocked(body) {
  // Your code here.
}

withBoxUnlocked(() => {
  box.content.push("gold piece");
});

try {
  withBoxUnlocked(() => {
    throw new Error("Pirates on the horizon! Abort!");
  });
} catch (e) {
  console.log("Error raised: " + e);
}
console.log(box.locked);
// → true

为了获得额外积分,请确保如果在箱子已经解锁的情况下调用withBoxUnlocked,箱子将保持解锁状态。

显示提示...

此练习需要一个finally代码块。您的函数应该首先解锁箱子,然后从try代码块中调用参数函数。它后面的finally代码块应该再次锁定箱子。

为了确保我们不会在箱子没有被锁定的时候锁定它,请在函数开头检查它的锁,并且只有在它最初被锁定的时候才解锁和锁定它。