1
0
mirror of https://github.com/apachecn/eloquent-js-3e-zh.git synced 2025-05-28 07:02:20 +00:00
wizardforcel acbef990e5 8.
2018-05-06 12:06:02 +08:00

25 KiB
Raw Blame History

八、Bug 和错误

调试的难度是开始编写代码的两倍。 因此,如果您尽可能巧妙地编写代码,那么根据定义,您的智慧不足以进行调试。

Brian Kernighan 和 P.J. Plauger《The Elements of Programming Style》

计算机程序中的缺陷通常称为 bug。 它让程序员觉得很好,将它们想象成小事,只是碰巧进入我们的作品。 实际上,我们当然会把它们放在那里。

如果一个程序是思想的结晶,你可以粗略地将错误分为因为思想混乱引起的错误,以及思想转换为代码时引入的错误。 前者通常比后者更难诊断和修复。

语言

计算机能够自动地向我们指出许多错误,如果它足够了解我们正在尝试做什么。 但是这里 JavaScript 的宽松是一个障碍。 它的绑定和属性概念很模糊,在实际运行程序之前很少会发现拼写错误。 即使这样,它也允许你做一些不会报错的无意义的事情,比如计算true *'monkey'

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

不过JavaScript 在处理无意义的计算时会仅仅返回NaN表示不是数字或undefined这样的结果。程序会认为其执行的代码毫无问题并顺利运行下去要等到随后的运行过程中才会出现问题而此时已经有许多函数使用了这个无意义的值。程序执行中也可能不会遇到任何错误只会产生错误的程序输出。找出这类错误的源头是非常困难的。

我们将查找程序中的错误或者bug的过程称为调试debug

8.2 严格模式

当启用了严格模式strict modeJavaScript就会在执行代码时变得更为严格。我们只需在文件或函数体顶部放置字符串“use strict”就可以启用严格模式了。下面是示例代码

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

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

通常,当您忘记在绑定前面放置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");
// → TypeError: Cannot set property 'name' of undefined

JavaScript会立即告知我们代码中包含错误。这种特性十分有用。

幸运的是,使用class符号创建的构造函数,如果在不使用new来调用,则始终会抱怨,即使在非严格模式下也不会产生问题。

严格模式做了更多的事情。 它不允许使用同一名称给函数赋多个参数,并且完全删除某些有问题的语言特性(例如with语句,这是错误的,本书不会进一步讨论)。

简而言之,在程序顶部放置"use strict"很少会有问题,并且可能会帮助您发现问题。

类型

有些语言甚至在运行程序之前想要知道,所有绑定和表达式的类型。 当类型以不一致的方式使用时,他们会马上告诉你。 JavaScript 只在实际运行程序时考虑类型,即使经常尝试将值隐式转换为它预期的类型,所以它没有多大帮助。

尽管如此,类型为讨论程序提供了一个有用的框架。 许多错误来自于值的类型的困惑,它们进入或来自一个函数。 如果你把这些信息写下来,你不太可能会感到困惑。

你可以在上一章的goalOrientedRobot函数上面,添加一个像这样的注释来描述它的类型。

// (WorldState, Array) → {direction: string, memory: Array}
function goalOrientedRobot(state, memory) {
  // ...
}

有许多不同的约定,用于标注 JavaScript 程序的类型。

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

当程序的类型已知时,计算机可以为你检查它们,在程序运行之前指出错误。 有几种 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() == "مرحبا";
});

像这样写测试往往会产生很多重复,笨拙的代码。 幸运的是有些软件通过提供适合于表达测试的语言以函数和方法的形式并在测试失败时输出丰富的信息来帮助您构建和运行测试集合测试套件test suite。 这些通常被称为测试运行器test runner

一些代码比其他代码更容易测试。 通常,代码与外部交互的对象越多,建立用于测试它的上下文就越困难。 上一章中显示的编程风格,使用自包含的持久值而不是更改对象,通常很容易测试。

调试

当程序的运行结果不符合预期或在运行过程中产生错误时,你就会注意到程序出现问题了,下一步就是要推断问题出在什么地方。

有时错误很明显。错误消息会指出错误出现在程序的哪一行,只要稍加阅读错误描述及出错的那行代码,你一般就知道如何修正错误了。

但不总是这样。 有时触发问题的行,只是第一个地方,它以无效方式使用其他地方产生的奇怪的值。 如果您在前几章中已经解决了练习,您可能已经遇到过这种情况。

下面的示例代码尝试将一个整数转换成给定进制表示的字符串十进制、二进制等其原理是不断循环取出最后一位数字并将其除以基数将最后一位数从数字中除去。但该程序目前的输出表明程序中是存在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的值依次变为13、1然后是0。让我们先在循环起始处输出n的值。

13
1.3
0.13
0.013
…
1.5e-323

没错。13除以10并不会产生整数。我们不应该使用n/=base而应该使用n=Math.floorn/base使数字“右移”这才是我们实际想要的结果。

使用console.log来查看程序行为的替代方法是使用浏览器的调试器debugger功能。 浏览器可以在代码的特定行上设置断点breakpoint。 当程序执行到带有断点的行时,它会暂停,并且您可以检查该点的绑定值。 我不会详细讨论,因为调试器在不同浏览器上有所不同,但请查看浏览器的开发人员工具或在 Web 上搜索来获取更多信息。

设置断点的另一种方法,是在程序中包含一个debugger语句(仅由该关键字组成)。 如果您的浏览器的开发人员工具是激活的,则只要程序达到这个语句,程序就会暂停。

错误传播

不幸的是,程序员不可能避免所有问题。 如果您的程序以任何方式与外部世界进行通信,则可能会导致输入格式错误,工作负荷过重或网络故障。

如果你只为自己编程,那么你就可以忽略这些问题直到它们发生。 但是如果你创建了一些将被其他人使用的东西,你通常希望程序比只是崩溃做得更好。 有时候,正确的做法是不择手段地继续运行。 在其他情况下,最好向用户报告出了什么问题然后放弃。 但无论在哪种情况下,该程序都必须积极采取措施来回应问题。

假设你有一个函数promptInteger,要求用户输入一个整数并返回它。 如果用户输入“橙色”,它应该返回什么?

一种办法是返回一个特殊值,通常会使用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的任何代码都必须检查是否实际读取了数字,否则必须以某种方式恢复 - 也许再次询问或填充默认值。 或者它可能会再次向它的调用者返回一个特殊值,表示它未能完成所要求的操作。

在很多情况下,当错误很常见并且调用者应该明确地考虑它们时,返回特殊值是表示错误的好方法。 但它确实有其不利之处。 首先,如果函数已经可能返回每一种可能的值呢? 在这样的函数中,你必须做一些事情,比如将结果包装在一个对象中,以便能够区分成功与失败。

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

返回特殊值的第二个问题是它可能产生非常笨拙的代码。 如果一段代码调用promptNumber 10 次,则必须检查是否返回null 10 次。 如果它对null的回应是简单地返回null本身,函数的调用者将不得不去检查它,以此类推。

异常

当函数无法正常工作时,我们只希望停止当前任务,并立即跳转到负责处理问题的位置。这就是异常处理的功能。

异常是一种当代码执行中遇到问题时可以触发或抛出异常的机制异常只是一个普通的值。触发异常类似于从函数中强制返回异常不只跳出到当前函数中还会跳出函数调用方直到当前执行流初次调用函数的位置。这种方式被称为“堆栈展开Unwinding the Stack”。你可能还记得我们在第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关键字用于触发异常。将throw语句填写在try块中紧跟catch关键字就可以捕获代码片段中的异常。当try块中的代码引发异常程序就会执行catch块。catch关键字后的绑定名在括号中会绑定到异常值。如果在catch块结束或try块正常结束后未引发任何问题那么控制流将会跳转到try/catch语句下方。

在本例中我们使用Error构造函数来创建异常值。这是一个标准的JavaScript构造函数用于创建一个对象包含message属性。在现代JavaScript环境中构造函数实例也会收集异常创建时的调用栈信息即堆栈跟踪信息Stack Trace。该信息存储在stack属性中对于调用问题有很大的帮助我们可以从堆栈跟踪信息中得知问题发生的精确位置即问题具体出现在哪个函数中以及执行失败为止调用的其他函数链。

需要注意的是现在look函数可以完全忽略promptDirection出错的可能性。这就是使用异常的优势只有在错误触发且必须处理的位置才需要错误处理代码。其间的函数可以忽略异常处理。

嗯,我们要讲解的理论知识差不多就这些了。

8.7 异常后清理

考虑下面一种情况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语句还有另一项功能它不仅可以附加catch语句块还可以追加finally语句块。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中的函数调用引发异常witchContext自身依然可以正确清理context绑定。

8.8 选择性捕获

当程序出现异常且异常未被捕获时异常就会直接回退到栈顶并由JavaScript环境来处理。其处理方式会根据环境的不同而不同。在浏览器中错误描述通常会写入JavaScript控制台中可以使用浏览器工具或开发者菜单来访问控制台

如果程序无法处理开发人员编写的错误或问题我们可以让错误直接传递到环境中去。未处理的异常是一种很好的传递程序奔溃信息的方式现代浏览器中的JavaScript控制台会在遇到问题时向开发者提供一些函数调用栈信息。

但对于已知的程序运行错误来说,如果我们还去使用未处理异常的方式进行处理,这种方式就不大友好了。

语言的非法使用方式比如引用一个不存在的绑定在null中查询属性或调用的对象不是函数最终都会引发异常。你可以像自己的异常一样捕获这些异常。

进入catch语句块时我们只知道try体中引发了异常但不知道引发了哪一类或哪一个异常。

JavaScript很明显的疏漏并未对选择性捕获异常提供良好的支持要不捕获所有异常要不什么都不捕获。因此当你编写catch块时你可以直接假定获得的异常就是你需要处理的那个异常。

但实际情况可能并非如此。这么做可能会影响其他判断逻辑或者会在新代码中引入一个bug。这里给出一个示例尝试不断调用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循环体来创建一个无限循环其自身永远不会停止运行。我们在用户给出有效的方向之后会跳出循环。但我们拼写错了promptDirection因此会引发一个“未定义值”错误。由于catch块完全忽略了异常值假定其知道问题所在错将绑定错误信息当成错误输入。这样不仅会引发无限循环而且会掩盖掉真正的错误消息——绑定名拼写错误。

一般而言,只有将抛出的异常重定位到其他地方进行处理时,我们才会捕获所有异常。比如说通过网络传输通知其他系统当前应用程序的崩溃信息。即便如此,我们也要注意编写的代码是否会将错误信息掩盖起来。

因此我们转而会去捕获那些特殊类型的异常。我们可以在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。我们也赋予InputError对象一个name属性因为标准错误类型如Eroor、SytaxError、ReferenceError等也有这么一个属性。

我们对stack属性进行赋值是希望通过该对象提供一些有用的堆栈轨迹信息。在支持堆栈轨迹的平台中我们只需要创建一个普通的Error对象并使用该对象的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;
  }
}

这里的catch代码只会捕获InputError类型的异常而其他类型的异常则不会在这里进行处理。如果又输入了不正确的值那么系统会向用户准确报告错误——“绑定未定义”。

8.9 断言

断言是检查开发人员逻辑错误的基本工具。来看一下这个工具函数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。但是从一个空数组中获取最后一个元素的操作是无用功所以这很有可能就是开发人员编写代码时造成的错误。

断言能够确保错误发生时触发程序中止运行的操作,而不是让程序悄悄产生一个无意义的值,然后继续执行下去,直到系统出现其他莫名其妙的错误为止。

8.10 本章小结

错误和无效的输入十分常见。开发人员需要发现并修复程序中的bug。通过自动测试并在程序中添加断言可以更轻松地发现程序中存在的隐患。

我们常常需要使用优雅的方式来处理程序可控范围外的问题。如果问题可以就地解决,那么返回一个特殊的值来跟踪错误就是一个不错的解决方案。如果你不想用这种方式来跟踪错误,使用异常也行。

抛出异常会引发堆栈展开直到遇到下一个封闭的try/catch块或堆栈底部为止。catch块捕获异常后会将异常值赋予catch块catch块中应该验证异常是否是实际希望处理的异常然后进行处理。为了处理由于异常引起的不可预测的执行流可以使用finally块来确保执行try块之后的代码。

8.11 习题

8.11.1 重试

假设有一个函数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

8.11.2 上锁的箱子

考虑以下这个编写好的对象:

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的函数接受一个函数类型的参数其作用是解锁箱子执行该函数无论是正常返回还是抛出异常在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