8. Bug 与 Error

计算机程序中的缺陷通常被称为 bug。把它们想象成偶然爬进我们工作中的小东西,会让程序员感觉良好。当然,实际上是我们自己把它们放进去的。

如果程序是思想的结晶,我们可以将错误大致分为思想混乱造成的错误和将思想转化为代码时引入错误造成的错误。前者通常比后者更难诊断和修复。

语言

如果计算机对我们要做的事情有足够的了解,那么很多错误都可以由计算机自动指出来。但在这方面,JavaScript 的松散性是一个障碍。它对绑定和属性的概念非常模糊,以至于在实际运行程序之前很少能发现错误。即便如此,它还是允许你做一些明显毫无意义的事情,而不会抱怨,比如计算 true * "monkey"。

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

不过,通常情况下,你的无意义计算只会产生 NaN(不是数字)或未定义的值,而程序会继续愉快地运行,坚信自己在做有意义的事情。只有在假值经过多个函数后,错误才会显现出来。它可能根本不会引发错误,但会悄无声息地导致程序输出错误。查找此类问题的根源可能很困难。

在程序中查找错误的过程称为调试。

严格模式

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

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

通常情况下,当你忘记在绑定前面加上 let 时(如示例中的 counter),JavaScript 会悄悄创建一个全局绑定并使用它。而在严格模式下,则会报错。这非常有用。不过需要注意的是,如果绑定已经存在于作用域中的某个地方,那么这个方法就不起作用了。在这种情况下,循环仍会悄悄覆盖绑定的值。

严格模式下的另一个变化是,在未作为方法调用的函数中,该绑定值为未定义。在严格模式之外进行此类调用时,this 指的是全局作用域对象,而全局作用域对象的属性就是全局绑定。因此,如果在严格模式下不小心错误地调用了方法或构造函数,JavaScript 在试图从中读取内容时就会产生错误,而不是愉快地写入全局作用域。

例如,下面的代码在调用构造函数时没有使用 new 关键字,这样它的 this 就不会指向一个新构造的对象:

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

我们立即被告知出了问题。这很有帮助。

幸运的是,使用类符号创建的构造函数如果在没有 new 的情况下被调用,总是会被抱怨,因此即使在非严格模式下,问题也不大。

严格模式还做了一些事情。它不允许给一个函数提供多个同名参数,并完全删除了某些有问题的语言特性(如 with 语句,这种语句是错误的,本书不再讨论)。

总之,在程序顶端加上 "use strict "很少有坏处,而且可能会帮助你发现问题。

类型

有些语言希望在运行程序之前就知道所有绑定和表达式的类型。当某种类型的使用方式不一致时,它们会立即告诉你。JavaScript 只有在实际运行程序时才会考虑类型,而且即使在运行程序时也会经常尝试将值隐式地转换为它所期望的类型,因此帮不上什么忙。

不过,类型还是为讨论程序提供了一个有用的框架。很多错误都是由于对进入函数或从函数中出来的值的类型感到困惑而造成的。如果把这些信息写下来,就不容易混淆了。

你可以在上一章的 findRoute 函数前添加类似下面的注释来描述它的类型:

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

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

当程序的类型已知时,计算机就有可能为你检查这些类型,在程序运行前指出错误。有几种 JavaScript 方言可以在语言中添加类型并对其进行检查。最流行的一种叫做 TypeScript。如果你有兴趣为你的程序增加更多的严谨性,我建议你试一试。

在本书中,我们将继续使用原始、危险、未键入的 JavaScript 代码。

测试

如果程序语言不能帮助我们发现错误,我们就只能通过运行程序,看看它是否做对了。

一次又一次地手工操作是个非常糟糕的主意。这不仅令人讨厌,而且往往效果不佳,因为每次更改都要花费大量时间对所有内容进行详尽测试。

计算机擅长重复性工作,而测试正是理想的重复性工作。自动测试是编写一个程序来测试另一个程序的过程。编写测试程序比手动测试要繁琐一些,但一旦完成,你就会获得一种超能力:只需几秒钟就能验证你的程序在你编写测试程序的所有情况下是否仍能正常运行。当你破坏了某些东西时,你会立即注意到,而不是在以后的某个时间随机遇到。

测试通常采用小标签程序的形式,用于验证代码的某些方面。例如,toUpperCase 方法(标准方法,可能已经有人测试过了)的测试集可能是这样的:

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

有些代码比其他代码更容易测试。一般来说,与代码交互的外部对象越多,设置测试上下文就越难。上一章中展示的编程风格使用自足的持久值而不是不断变化的对象,这种风格往往容易测试。

调试

一旦你发现程序出了问题,因为它行为不端或产生错误,下一步就是找出问题所在。有时问题很明显。错误信息会指向程序中的某一行,如果查看错误描述和该行代码,通常就能发现问题所在。但并非总是如此。有时,引发问题的那行代码只是在其他地方产生的错误值以无效方式被使用的第一个地方。如果你已经解决了前面几章的练习,你可能已经经历过这种情况。

下面的示例程序试图将一个整数转换成一个给定基数(十进制、二进制等)的字符串,方法是反复挑出最后一位数字,然后除以该数字,去掉该数字。但它目前产生的奇怪输出结果表明它存在一个错误。

即使你已经看到了问题所在,也请暂时假装看不到。我们知道我们的程序出现了故障,我们想找出原因。这时,你必须克制住自己的冲动,不要随意修改代码,看看这样做是否能让程序变得更好。取而代之的是思考。分析正在发生的情况,并就可能发生的原因提出理论。然后进行更多的观察来验证这一理论--或者,如果你还没有理论,也可以进行更多的观察来帮助你提出一个理论。

在程序中调用一些策略性的 console.log 调用,是获取关于程序正在做什么的额外信息的好方法。在本例中,我们希望 n 取值为 13、1 和 0。

对。13 除以 10 不能得到整数。我们实际上需要的是 n = Math.floor(n/base),而不是 n /= base,这样数字就会正确地向右 "移动"。

除了使用 console.log 来窥探程序的行为外,另一种方法是使用浏览器的调试器功能。浏览器具有在特定代码行上设置断点的功能。当程序执行到带有断点的行时,程序会暂停,你可以检查该点的绑定值。由于不同浏览器的调试器不尽相同,我就不详细介绍了,但请查看浏览器的开发工具或在网上搜索相关说明。

另一种设置断点的方法是在程序中加入调试器语句(仅由该关键字组成)。如果浏览器的开发工具处于激活状态,程序在运行到该语句时就会暂停。

错误传播

遗憾的是,并非所有问题程序员都能避免。如果你的程序以任何方式与外界通信,就有可能获得畸形输入、工作负荷过重或网络故障。

如果你只是为自己编程,你可以忽略这些问题,直到它们发生。但如果你编写的程序将被其他人使用,你通常希望程序能做得更好,而不仅仅是崩溃。有时,正确的做法是接受不良输入并继续运行。在其他情况下,最好是向用户报告出错的原因,然后放弃。无论在哪种情况下,程序都必须积极采取措施来应对问题。

假设有一个 promptNumber 函数,向用户询问一个数字并返回该数字。如果用户输入 "orange",它应该返回什么?

一种方法是让它返回一个特殊值。这种值的常见选择是 null、undefined 或-1。

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

在许多情况下,主要是当错误很常见并且调用者应该明确考虑到这些错误时,返回一个特殊值是一种很好的提示错误的方法。不过,它也有缺点。首先,如果函数已经可以返回所有可能的值,那该怎么办?在这样的函数中,你就必须像迭代器接口的下一个方法那样,将结果封装在一个对象中,以便区分成功与失败。

返回特殊值的第二个问题是,它可能导致代码笨拙。如果一段代码调用 promptNumber 10 次,它就必须检查 10 次是否返回了空值。如果发现空值后的反应是简单地返回空值,那么函数的调用者就必须依次检查空值,以此类推。

异常

当函数无法正常运行时,我们通常希望停止正在进行的工作,并立即跳转到知道如何处理问题的地方。这就是异常处理的作用。

异常是一种机制,它可以让遇到问题的代码引发(或抛出)异常。异常可以是任何值。引发异常有点类似于函数的超级返回:异常不仅会跳出当前函数,还会跳出其调用者,一直跳到开始当前执行的第一个调用。这就是所谓的释放堆栈。你可能还记得第 3 章中提到的函数调用栈。异常会沿着堆栈向下缩放,丢弃它遇到的所有调用上下文。

如果异常总是直接放大到堆栈底部,那么它们就没有什么用处了。它们只是提供了一种炸毁程序的新方法。异常的强大之处在于,你可以沿着堆栈设置 "障碍",以便在异常向下放大时捕获它。一旦捕捉到异常,你就可以对它进行处理,解决问题,然后继续运行程序。

这里有一个例子:

throw 关键字用于引发异常。捕获异常的方法是将一段代码封装在 try 代码块中,然后使用关键字 catch。当 try 代码块中的代码导致异常发生时,catch 代码块将被评估,括号中的名称将与异常值绑定。在 catch 代码块结束后,或者如果 try 代码块结束时没有问题,程序将在整个 try/catch 语句下继续执行。

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

请注意,look 函数完全忽略了 promptDirection 可能出错的可能性。这就是异常的最大优势:只有在错误发生时和处理错误时才需要错误处理代码。中间的函数可以完全不用考虑。

嗯,几乎是...

清理例外情况

异常的影响是另一种控制流。每一个可能导致异常的操作(几乎是每一次函数调用和属性访问)都可能导致控制权突然离开代码。

这就意味着,当代码有多个副作用时,即使其 "常规 "控制流看起来都会发生,异常也可能会阻止其中一些副作用的发生。

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

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

但是转账函数会先将钱从账户中取出,然后调用 getAccount,再将钱添加到另一个账户中。如果在这个过程中出现异常,钱就会消失。

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

解决这一问题的方法之一是减少副作用。同样,计算新值而非更改现有数据的编程风格也有帮助。如果一段代码在创建新值的过程中停止运行,现有的数据结构不会受到破坏,这样就更容易恢复。

由于这并不总是切实可行,所以 try 语句还有另一个特点:它们后面可能会跟一个 finally 代码块,以代替 catch 代码块或作为 catch 代码块的补充。finally 代码块表示 "无论发生什么,在尝试运行 try 代码块中的代码后,再运行此代码"。

这个版本的函数会跟踪其运行进度,如果在离开时发现它在创建了不一致的程序状态时被终止,它就会修复所造成的损害。

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

编写即使异常在意想不到的地方出现也能可靠运行的程序是很困难的。很多人根本懒得去做,而且由于异常通常只在特殊情况下出现,因此问题可能很少发生,甚至从未被注意到。这究竟是好事还是坏事,取决于软件出现故障时会造成多大的损失。

选择性捕捉

当一个异常没有被捕获而一直到达堆栈底部时,它就会被环境处理。这在不同环境中的含义各不相同。在浏览器中,错误描述通常会写入 JavaScript 控制台(可通过浏览器的 "工具 "或 "开发人员 "菜单访问)。我们将在第 20 章讨论的无浏览器 JavaScript 环境 Node.js 对数据损坏更为谨慎。当出现无法处理的异常时,它会中止整个进程。

对于程序员的错误,让错误通过往往是最好的办法。未处理异常是程序崩溃的合理信号,在现代浏览器上,JavaScript 控制台会提供一些信息,告诉你问题发生时堆栈上有哪些函数调用。

对于在日常使用中预计会发生的问题,使用未处理异常导致程序崩溃是一种糟糕的策略。

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

当输入 catch body 时,我们只知道 try body 中的某些内容导致了异常。但我们不知道是什么引起了异常,也不知道是哪个异常。

JavaScript(一个相当明显的疏忽)并不直接支持选择性捕获异常:要么全部捕获,要么一个都不捕获。这就很容易让人认为你捕获到的异常就是你在编写捕获块时考虑到的异常。

但事实可能并非如此。其他一些假设可能被违反,或者你可能引入了一个导致异常的错误。下面是一个试图继续调用 promptDirection 直到得到有效答案的示例:

for (;;) 结构是一种有意创建不会自行终止的循环的方法。只有在给出有效方向时,我们才会跳出循环。不幸的是,我们拼错了 promptDirection,这将导致 "未定义变量 "错误。由于 catch 代码块完全忽略了异常值 (e),以为自己知道问题所在,因此错误地将绑定错误视为输入错误。这不仅会导致无限循环,还会 "掩盖 "有关拼写错误绑定的有用错误信息。

一般来说,除非是为了将异常 "路由 "到其他地方--例如,通过网络告诉其他系统我们的程序崩溃了,否则不要一揽子捕获异常。即便如此,也要仔细考虑如何隐藏信息。

我们要捕获一种特定的异常。我们可以通过在 catch 块中检查我们得到的异常是否是我们感兴趣的异常,如果不是,就重新抛出。但如何识别异常呢?

我们可以将其信息属性与我们预期的错误信息进行比较。但这种编写代码的方式并不可靠--我们将使用供人类使用的信息(信息)来做出程序决策。一旦有人更改(或翻译)了信息,代码就会停止工作。

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

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

现在,循环可以更仔细地捕捉这些对象了。

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

断言

断言是程序内部的检查,用于验证某些事情是否符合其应有的状态。它们不是用来处理正常运行中可能出现的情况,而是用来发现程序员的错误。

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

现在,它不再默默地返回未定义(读取不存在的数组属性时会返回未定义),而是一旦你误用它,程序就会立即爆炸。这样,此类错误就不容易被忽视,也更容易在错误发生时找到原因。

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

总结

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

对于由程序控制之外的因素导致的问题,通常应积极加以规划。有时,当问题可以在本地处理时,特殊返回值是跟踪问题的好方法。否则,异常可能是更好的选择。

抛出异常会导致调用堆栈被释放,直到下一个外层 try/catch 块或堆栈底部。异常值将提供给捕获异常的 catch 块,该块应验证异常确实是预期的异常类型,然后对异常进行处理。为了帮助解决异常导致的控制流不可预测性问题,可以使用 finally 块来确保代码在块结束时始终运行。

练习
重试

假设你有一个函数 primitiveMultiply,它在 20% 的情况下可以实现两个数字相乘,而在另外 80% 的情况下会引发一个类型为 MultiplicatorUnitFailure 的异常。编写一个函数来封装这个笨重的函数,并不断尝试直到调用成功,然后返回结果。

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

代码:

function reliableMultiply(a, b) {
  try {
    return primitiveMultiply(a, b);
  } catch (e) {
    if (e instanceof MultiplicatorUnitFailure) {
      console.log(e.message);
    }
  }
}
console.log(reliableMultiply(8,2));
上锁的箱子

请看下面这个(相当臆造的)对象:

这是一个带锁的盒子。盒子里有一个数组,但只有当盒子被解锁时才能访问它。

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

代码:

function withBoxUnlocked(body) {
  box.unlock();
  try {
    body();
    console.log(box.content);
  } finally {
    box.lock();
  }
}

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

相关推荐
程序员爱技术1 小时前
Vue 2 + JavaScript + vue-count-to 集成案例
前端·javascript·vue.js
悦涵仙子2 小时前
CSS中的变量应用——:root,Sass变量,JavaScript中使用Sass变量
javascript·css·sass
兔老大的胡萝卜2 小时前
ppk谈JavaScript,悟透JavaScript,精通CSS高级Web,JavaScript DOM编程艺术,高性能JavaScript pdf
前端·javascript
cs_dn_Jie6 小时前
钉钉 H5 微应用 手机端调试
前端·javascript·vue.js·vue·钉钉
开心工作室_kaic6 小时前
ssm068海鲜自助餐厅系统+vue(论文+源码)_kaic
前端·javascript·vue.js
有梦想的刺儿7 小时前
webWorker基本用法
前端·javascript·vue.js
清灵xmf8 小时前
TypeScript 类型进阶指南
javascript·typescript·泛型·t·infer
小白学大数据8 小时前
JavaScript重定向对网络爬虫的影响及处理
开发语言·javascript·数据库·爬虫
qq_390161778 小时前
防抖函数--应用场景及示例
前端·javascript
334554328 小时前
element动态表头合并表格
开发语言·javascript·ecmascript