JavaScript 编程精解 中文第三版 8、Bug 和错误

来源: ApacheCN『JavaScript 编程精解 中文第三版』翻译项目

原文:Bugs and Errorsjavascript

译者:飞龙html

协议:CC BY-NC-SA 4.0java

自豪地采用谷歌翻译git

部分参考了《JavaScript 编程精解(第 2 版)》程序员

调试的难度是开始编写代码的两倍。 所以,若是你尽量巧妙地编写代码,那么根据定义,你的智慧不足以进行调试。github

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

计算机程序中的缺陷一般称为 bug。 它让程序员以为很好,将它们想象成小事,只是碰巧进入咱们的做品。 实际上,固然,咱们本身把它们放在了那里。apache

若是一个程序是思想的结晶,你能够粗略地将错误分为由于思想混乱引发的错误,以及思想转换为代码时引入的错误。 前者一般比后者更难诊断和修复。编程

语言

计算机可以自动地向咱们指出许多错误,若是它足够了解咱们正在尝试作什么。 可是这里 JavaScript 的宽松是一个障碍。 它的绑定和属性概念很模糊,在实际运行程序以前不多会发现拼写错误。 即便这样,它也容许你作一些不会报错的无心义的事情,好比计算true *'monkey'数组

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

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

咱们将查找程序中的错误或者 bug 的过程称为调试(debug)。

严格模式

当启用了严格模式(strict mode)后,JavaScript 就会在执行代码时变得更为严格。咱们只需在文件或函数体顶部放置字符串"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.floor(n/base),使数字“右移”,这才是咱们实际想要的结果。

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

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

错误传播

不幸的是,程序员不可能避免全部问题。 若是你的程序以任何方式与外部世界进行通讯,则可能会致使输入格式错误,工做负荷太重或网络故障。

若是你只为本身编程,那么你就能够忽略这些问题直到它们发生。 可是若是你建立了一些将被其余人使用的东西,你一般但愿程序比只是崩溃作得更好。 有时候,正确的作法是不择手段地继续运行。 在其余状况下,最好向用户报告出了什么问题而后放弃。 但不管在哪一种状况下,该程序都必须积极采起措施来回应问题。

假设你有一个函数promptInteger,要求用户输入一个整数并返回它。 若是用户输入"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的任何代码都必须检查是否实际读取了数字,不然必须以某种方式恢复 - 也许再次询问或填充默认值。 或者它可能会再次向它的调用者返回一个特殊值,表示它未能完成所要求的操做。

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

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关键字用于引起异常。 异常的捕获经过将一段代码包装在一个try块中,后跟关键字catch来完成。 当try块中的代码引起异常时,将求值catch块,并将括号中的名称绑定到异常值。 在catch块结束以后,或者try块结束而且没有问题时,程序在整个try / catch语句的下面继续执行。

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

须要注意的是如今look函数能够彻底忽略promptDirection出错的可能性。这就是使用异常的优点:只有在错误触发且必须处理的位置才须要错误处理代码。其间的函数能够忽略异常处理。

嗯,咱们要讲解的理论知识差很少就这些了。

异常后清理

异常的效果是另外一种控制流。 每一个可能致使异常的操做(几乎每一个函数调用和属性访问)均可能致使控制流忽然离开你的代码。

这意味着当代码有几个反作用时,即便它的“常规”控制流看起来像它们老是会发生,但异常可能会阻止其中一些发生。

这是一些很是糟糕的银行代码。

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

function getAccount() {
  let accountName = prompt("Enter an account name");
  if (!accounts.hasOwnProperty(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语句具备另外一个特性。 他们可能会跟着一个finally块,而不是catch块,也不是在它后面。 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 环境来处理。其处理方式会根据环境的不一样而不一样。在浏览器中,错误描述一般会写入 JavaScript 控制台中(可使用浏览器工具或开发者菜单来访问控制台)。咱们将在第 20 章中讨论的,无浏览器的 JavaScript 环境 Node.js 对数据损坏更加谨慎。 当发生未处理的异常时,它会停止整个过程。

对于程序员的错误,让错误通行一般是最好的。 未处理的异常是表示糟糕的程序的合理方式,而在现代浏览器上,JavaScript 控制台为你提供了一些信息,有关在发生问题时堆栈上调用了哪些函数的。

对于在平常使用中发生的预期问题,因未处理的异常而崩溃是一种糟糕的策略。

语言的非法使用方式,好比引用一个不存在的绑定,在null中查询属性,或调用的对象不是函数最终都会引起异常。你能够像本身的异常同样捕获这些异常。

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

JavaScript(很明显的疏漏)并未对选择性捕获异常提供良好的支持,要不捕获全部异常,要不什么都不捕获。这让你很容易假设,你获得的异常就是你在写catch时所考虑的异常。

但它也可能不是。 可能会违反其余假设,或者你可能引入了致使异常的 bug。 这是一个例子,它尝试持续调用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块彻底忽略了异常值,假定其知道问题所在,错将绑定错误信息当成错误输入。这样不只会引起无限循环,并且会掩盖掉真正的错误消息——绑定名拼写错误。

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

所以,咱们转而会去捕获那些特殊类型的异常。咱们能够在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;
    }
  }
}

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

断言

断言(assertions)是程序内部的检查,用于验证某个东西是它应该是的方式。 它们并非用于处理正常操做中可能出现的状况,而是发现程序员的错误。

例如,若是firstElement被描述为一个函数,永远不会在空数组上调用,咱们能够这样写:

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

如今,它不会默默地返回未定义值(当你读取一个不存在的数组属性的时候),而是在你滥用它时当即干掉你的程序。 这使得这种错误不太可能被忽视,而且当它们发生时更容易找到它们的缘由。

我不建议尝试为每种可能的不良输入编写断言。 这将是不少工做,并会产生很是杂乱的代码。 你会但愿为很容易犯(或者你发现本身作过)的错误保留他们。

本章小结

错误和无效的输入十分常见。编程的一个重要部分是发现,诊断和修复错误。 若是你拥有自动化测试套件或向程序添加断言,则问题会变得更容易被注意。

咱们经常须要使用优雅的方式来处理程序可控范围外的问题。若是问题能够就地解决,那么返回一个特殊的值来跟踪错误就是一个不错的解决方案。或者,异常也多是可行的。

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

习题

重试

假设有一个函数primitiveMultiply,在 20% 的状况下将两个数相乘,在另外 80% 的状况下会触发MultiplicatorUnitFailure类型的异常。编写一个函数,调用这个容易出错的函数,不断尝试直到调用成功并返回结果为止。

确保只处理你指望的异常。

class MultiplicatorUnitFailure extends Error {}

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

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

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

上锁的箱子

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

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

这是一个带锁的箱子。其中有一个数组,但只有在箱子被解锁时,才能够访问数组。不容许直接访问_content属性。

编写一个名为withBoxUnlocked的函数,接受一个函数类型的参数,其做用是解锁箱子,执行该函数,不管是正常返回仍是抛出异常,在withBoxUnlocked函数返回前都必须锁上箱子。

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

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
相关文章
相关标签/搜索