第 3 版现已上市。点击此处阅读

第 8 章
错误和错误处理

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

Brian Kernighan 和 P.J. Plauger,编程风格要素

元马写了一个小程序,它使用了许多全局变量和简陋的捷径。一个学生读了之后问道:“你警告我们不要使用这些技巧,但我却在你的程序中发现了它们。这是怎么回事?” 这位大师说:“房子没有着火,就不用去拿消防水管。”

元马大师,编程之道

程序是凝固的思想。有时这些思想会变得混乱。其他时候,在将思想转化为代码的过程中会引入错误。无论哪种情况,结果都是一个有缺陷的程序。

程序中的缺陷通常被称为 bug。Bug 可能源于程序员的错误,也可能是程序与之交互的其他系统的故障。有些 bug 立即显而易见,而另一些则比较隐蔽,可能在系统中隐藏多年。

通常,问题只会在程序遇到程序员最初没有考虑到的情况时才会出现。有时,这种情况是不可避免的。当要求用户输入他们的年龄,而用户却输入了“orange”,就会使我们的程序陷入困境。必须以某种方式预测和处理这种情况。

程序员的错误

对于程序员的错误,我们的目标很简单。我们希望找到它们并修复它们。这些错误的范围可以从简单的拼写错误(导致计算机在看到我们的程序时立即报错)到我们对程序工作方式的理解上的细微错误(导致仅在特定情况下出现错误结果)。后一种类型的 bug 可能需要数周才能诊断出来。

语言帮助你发现这些错误的程度各不相同。不出所料,JavaScript 处于“几乎没有帮助”的一端。有些语言希望在运行程序之前知道所有变量和表达式的类型,并且会立即告诉你当以不一致的方式使用类型时。JavaScript 只有在实际运行程序时才会考虑类型,即使如此,它也允许你做一些明显毫无意义的事情,例如 x = true * "monkey",而不会报错。

不过,JavaScript 确实会抱怨一些事情。编写语法无效的程序会立即触发错误。其他事情,例如调用非函数或在未定义的值上查找属性,会在程序运行时遇到这种毫无意义的操作时报错。

但通常情况下,你的无意义计算只会产生一个 NaN(非数字)或 undefined 值。然后程序愉快地继续运行,确信它正在做一些有意义的事情。这个错误只会稍后显现出来,在错误的值经过多个函数处理之后。它可能根本不会触发错误,而是会默默地导致程序的输出结果错误。找到这种问题的根源可能很困难。

在程序中查找错误(即 bug)的过程称为调试

严格模式

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

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

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

通常,当你忘记在变量前面加上 var(例如示例中的 counter)时,JavaScript 会默默地创建一个全局变量并使用它。但在严格模式下,会报告错误。这非常有用。需要注意的是,当该变量已经存在作为全局变量时,这不会起作用,但只有在对它赋值会创建它时才会起作用。

严格模式的另一个变化是,this 绑定在非方法调用函数中持有值 undefined。在严格模式之外进行此类调用时,this 指向全局作用域对象。因此,如果你在严格模式下意外地错误地调用了方法或构造函数,JavaScript 会在尝试从 this 中读取内容时立即产生错误,而不是愉快地使用全局对象,并创建和读取全局变量。

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

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

因此,对 Person 的错误调用成功了,但返回了一个 undefined 值,并创建了全局变量 name。在严格模式下,结果不同。

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

我们立即被告知出现错误。这很有帮助。

严格模式还会执行一些其他操作。它不允许为函数指定多个同名参数,并且会完全删除某些有问题的语言特性(例如 with 语句,它非常误导,因此本书不再对此进行进一步讨论)。

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

测试

如果语言不会为我们提供太多帮助来发现错误,我们就必须通过更困难的方式来找到它们:运行程序,看看它是否按预期工作。

通过人工手动执行此操作,一遍又一遍,肯定会让你发疯。幸运的是,通常可以编写第二个程序来自动测试你的实际程序。

作为一个例子,我们再次使用 Vector 类型。

function Vector(x, y) {
  this.x = x;
  this.y = y;
}
Vector.prototype.plus = function(other) {
  return new Vector(this.x + other.x, this.y + other.y);
};

我们将编写一个程序来检查我们对 Vector 的实现是否按预期工作。然后,每次修改实现时,我们都会运行测试程序,这样我们就可以有把握地确定没有破坏任何东西。当我们在 Vector 类型中添加额外功能(例如,一个新方法)时,我们也会为新功能添加测试。

function testVector() {
  var p1 = new Vector(10, 20);
  var p2 = new Vector(-10, 5);
  var p3 = p1.plus(p2);

  if (p1.x !== 10) return "fail: x property";
  if (p1.y !== 20) return "fail: y property";
  if (p2.x !== -10) return "fail: negative x property";
  if (p3.x !== 0) return "fail: x from plus";
  if (p3.y !== 25) return "fail: y from plus";
  return "everything ok";
}
console.log(testVector());
// → everything ok

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

调试

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

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

但并非总是如此。有时触发问题的行只是第一个使用其他地方产生的错误值的地方,而这种使用方式无效。有时根本没有错误消息,只是结果无效。如果你一直在解决前面章节中的练习,你可能已经遇到过这种情况。

以下示例程序试图通过反复取出最后一位数字,然后将数字除以该位数字来将其舍去,从而将一个整数转换为任意进制(十进制、二进制等)的字符串。但它目前产生的不正常的输出表明它存在 bug。

function numberToString(n, base) {
  var 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 取值 131,然后是 0。让我们在循环开始时输出它的值。

13
1.3
0.13
0.013
…
1.5e-323

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

使用 console.log 的另一种方法是使用浏览器的调试器功能。现代浏览器具有在程序特定行上设置断点的功能。这将使程序的执行在每次遇到带有断点的行时暂停,并允许你在该点检查变量的值。我在这里不会详细介绍,因为调试器在不同的浏览器中有所不同,但请查看浏览器的开发者工具,并在网上搜索更多信息。设置断点的另一种方法是在程序中包含一个 debugger 语句(仅包含该关键字)。如果浏览器的开发者工具处于活动状态,则程序在遇到该语句时会暂停,并且你将能够检查其状态。

错误传播

不幸的是,并非所有问题都可以由程序员预防。如果你的程序以任何方式与外部世界通信,则它收到的输入可能无效,或者它试图与之通信的其他系统可能出现故障或无法访问。

简单的程序,或者只在您监督下运行的程序,当遇到此类问题时,可以放弃。您可以调查问题并重试。“真正的”应用程序则被期望不会简单地崩溃。有时,正确的做法是淡化错误输入并继续运行。在其他情况下,最好向用户报告错误并放弃。但在任何情况下,程序都必须对问题做出积极的响应。

假设您有一个函数 promptInteger,它要求用户输入一个整数并将其返回。如果用户输入 orange,它应该返回什么?

一种选择是让它返回一个特殊的值。此类值的常见选择是 nullundefined

function promptNumber(question) {
  var result = Number(prompt(question, ""));
  if (isNaN(result)) return null;
  else return result;
}

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

这是一个合理策略。现在任何调用 promptNumber 的代码都必须检查是否读取了实际的数字,如果没有,则必须以某种方式恢复 - 可能是再次询问或填写默认值。或者它可以再次返回一个特殊的值给 its 调用者,以表明它无法完成要求的操作。

在许多情况下,尤其是当错误很常见并且调用者应该明确地考虑错误时,返回一个特殊值是指示错误的一种完美方式。但是,它确实有其缺点。首先,如果函数已经可以返回所有可能的类型的值?对于这样的函数,很难找到一个可以与有效结果区分开的特殊值。

返回特殊值的第二个问题是它会导致一些非常混乱的代码。如果一段代码调用 promptNumber 10 次,它必须检查 10 次是否返回了 null。如果它对找到 null 的响应只是简单地返回 null 本身,则调用者反过来必须检查它,依此类推。

异常

当一个函数无法正常进行时,我们 want 做的是停止我们正在做的事情,并立即跳回到一个知道如何处理问题的地方。这就是 exception handling 做的事情。

异常是一种机制,它使遇到问题的代码能够 raise(或 throw)一个异常,它只是一个值。引发异常有点类似于从函数中进行超强返回:它不仅跳出当前函数,还跳出其调用者,一直跳到启动当前执行的第一个调用。这称为 unwinding the stack。您可能还记得在 第三章 中提到的函数调用堆栈。异常沿着此堆栈向下快速移动,丢弃它遇到的所有调用上下文。

如果异常总是直接向下移动到堆栈的底部,那么它们就没有多大用处。它们只会提供一种新颖的方式来破坏您的程序。它们的强大之处在于您可以在堆栈上设置“障碍”来 catch 正在向下移动的异常。然后您就可以对它做些事情,之后程序将在捕获异常的地方继续运行。

这是一个例子

function promptDirection(question) {
  var 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 后面的变量名(在括号中)将绑定到异常值。在 catch 块完成后 - 或者如果 try 块在没有问题的情况下完成 - 控制权将继续传递到整个 try/catch 语句之下。

在这种情况下,我们使用了 Error 构造函数来创建我们的异常值。这是一个标准的 JavaScript 构造函数,它创建了一个具有 message 属性的对象。在现代的 JavaScript 环境中,此构造函数的实例还会收集有关创建异常时存在的调用堆栈的信息,即所谓的 stack trace。此信息存储在 stack 属性中,在尝试调试问题时可能会有所帮助:它告诉我们问题发生的精确函数以及导致失败调用的其他函数。

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

好吧,几乎...

清理异常后的操作

考虑以下情况:一个函数 withContext 想要确保在执行期间,顶级变量 context 持有一个特定的上下文值。在它完成之后,它会将此变量恢复为其旧值。

var context = null;

function withContext(newContext, body) {
  var oldContext = context;
  context = newContext;
  var result = body();
  context = oldContext;
  return result;
}

如果 body 抛出了异常怎么办?在这种情况下,对 withContext 的调用将被异常从堆栈中抛出,并且 context 永远不会被设置回其旧值。

try 语句还有一个功能。它们可以后跟一个 finally 块,要么代替 catch 块,要么与 catch 块一起使用。finally 块意味着“无论发生什么,在尝试运行 try 块中的代码后都运行此代码”。如果一个函数必须清理某些东西,则清理代码通常应该放在 finally 块中。

function withContext(newContext, body) {
  var oldContext = context;
  context = newContext;
  try {
    return body();
  } finally {
    context = oldContext;
  }
}

请注意,我们不再需要将 body 的结果(我们想要返回的结果)存储在一个变量中。即使我们直接从 try 块返回,finally 块也将被运行。现在我们可以这样做,并且可以保证安全

try {
  withContext(5, function() {
    if (context < 10)
      throw new Error("Not enough context!");
  });
} catch (e) {
  console.log("Ignoring: " + e);
}
// → Ignoring: Error: Not enough context!

console.log(context);
// → null

即使从 withContext 调用的函数爆炸了,withContext 本身仍然正确地清理了 context 变量。

选择性捕获

当异常一直移动到堆栈的底部而没有被捕获时,它将由环境处理。这意味着什么在不同的环境中有所不同。在浏览器中,错误的描述通常会写入 JavaScript 控制台(可以通过浏览器的工具或开发者菜单访问)。

对于程序员错误或程序无法处理的问题,只需让错误通过通常是可以的。未处理的异常是表示程序已损坏的合理方法,而现代浏览器中的 JavaScript 控制台会向您提供有关在发生问题时堆栈中有哪些函数调用的一些信息。

对于 expected 在常规使用过程中发生的问题,崩溃并产生未处理的异常并不是一个友好的响应。

语言的无效使用,例如引用不存在的变量、在 null 上查找属性或调用不是函数的东西,也会导致引发异常。此类异常可以像您自己的异常一样被捕获。

当进入 catch 体时,我们只知道 try 体中的 something 导致了异常。但我们不知道 what 或者 which 异常导致了它。

JavaScript(在一个相当明显的遗漏中)没有直接支持选择性捕获异常:要么您全部捕获它们,要么您不捕获任何一个。这使得 assume 您得到的异常是您在编写 catch 块时想到的异常变得非常容易。

但它可能不是。其他一些假设可能被违反了,或者您可能在某个地方引入了一个导致异常的错误。这是一个例子,它 attempts 继续调用 promptDirection,直到它得到一个有效的答案

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

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

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

因此,我们想要捕获 specific 类型的异常。我们可以通过检查 catch 块中我们得到的异常是否是我们感兴趣的异常,以及在其他情况下重新抛出它来做到这一点。但是我们如何识别异常呢?

当然,我们可以将其 message 属性与我们期望的错误消息进行匹配。但这是一种不稳定的编写代码的方式 - 我们将使用旨在供人类消费的信息(消息)来做出程序决策。一旦有人更改(或翻译)消息,代码将停止工作。

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

function InputError(message) {
  this.message = message;
  this.stack = (new Error()).stack;
}
InputError.prototype = Object.create(Error.prototype);
InputError.prototype.name = "InputError";

原型被设置为从 Error.prototype 派生,因此 instanceof Error 也会针对 InputError 对象返回 true。它还具有 name 属性,因为标准错误类型 (ErrorSyntaxErrorReferenceError 等) 也具有此属性。

stack 属性的赋值尝试通过创建常规错误对象,然后使用该对象的 stack 属性作为其自身来为此对象提供一个有点有用的堆栈跟踪,在支持它的平台上。

现在 promptDirection 可以抛出这样的错误。

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

循环可以更小心地捕获它。

for (;;) {
  try {
    var 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 的实例,并让不相关的异常通过。如果您重新引入错误,则未定义变量错误将被正确报告。

断言

Assertions 是一种用于对程序员错误进行基本健全性检查的工具。考虑这个辅助函数 assert

function AssertionFailed(message) {
  this.message = message;
}
AssertionFailed.prototype = Object.create(Error.prototype);

function assert(test, message) {
  if (!test)
    throw new AssertionFailed(message);
}

function lastElement(array) {
  assert(array.length > 0, "empty array in lastElement");
  return array[array.length - 1];
}

这提供了一种紧凑的方式来强制执行期望,如果声明的条件不成立,它会很有帮助地让程序崩溃。例如,lastElement 函数从数组中获取最后一个元素,如果省略断言,则在空数组上会返回 undefined。从空数组中获取最后一个元素没有多大意义,因此这样做几乎肯定是一个程序员错误。

断言是一种确保错误在错误发生时导致故障的方法,而不是默默地生成可能在系统无关部分造成麻烦的无意义值。

总结

错误和不良输入是生活中不可避免的事实。程序中的错误需要找到并修复。通过拥有自动测试套件并在程序中添加断言,它们可以更容易被发现。

由程序控制之外的因素导致的问题通常应该得到优雅地处理。有时,当问题可以在本地处理时,特殊的返回值是跟踪它们的明智方法。否则,异常是更好的选择。

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

练习

重试

假设你有一个函数 primitiveMultiply,它在 50% 的情况下会将两个数字相乘,而在另外 50% 的情况下会抛出类型为 MultiplicatorUnitFailure 的异常。编写一个函数来包装这个笨拙的函数,并且只要调用成功就一直尝试,然后返回结果。

确保你只处理你试图处理的异常。

function MultiplicatorUnitFailure() {}

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

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

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

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

为了进行重试,你可以使用一个循环,它只在调用成功时才中断,就像本章前面look 示例中一样,或者使用递归,并希望你不会遇到一连串的失败,以至于它会溢出堆栈(这是一个相当安全的赌注)。

锁定的盒子

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

var box = {
  locked: true,
  unlock: function() { this.locked = false; },
  lock: function() { this.locked = true;  },
  _content: [],
  get content() {
    if (this.locked) throw new Error("Locked!");
    return this._content;
  }
};

它是一个带锁的盒子。里面是一个数组,但只有在盒子解锁时才能访问它。直接访问 _content 属性是不允许的。

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

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

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

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

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

这个练习需要一个 finally 块,正如你可能猜到的那样。你的函数应该首先解锁盒子,然后从 try 块中调用参数函数。随后的 finally 块应该再次锁定盒子。

为了确保我们不会在盒子没有被锁定的情况下锁定它,请在函数开始时检查它的锁,并且只有在它最初处于锁定状态时才解锁和锁定它。