第八章错误和漏洞
计算机程序中的缺陷通常被称为bug(漏洞)。程序员们喜欢将它们想象成一些小东西,它们碰巧爬进了我们的作品中。当然,在现实中,是我们自己把它们放进去的。
如果一个程序是凝固的思想,那么你可以将漏洞大致分为两类:由思想混乱造成的漏洞和在将思想转化为代码时引入的错误造成的漏洞。前者通常比后者更难诊断和修复。
语言
如果计算机能足够了解我们的意图,它就能自动地指出我们许多错误。但是,JavaScript 的松散性在这里是一个障碍。它对绑定和属性的概念足够模糊,以至于它很少能在程序实际运行之前发现拼写错误。即使在程序运行之后,它也允许你执行一些明显无意义的操作,例如计算 true * "monkey"
,而不会有任何抱怨。
JavaScript 确实会抱怨一些事情。编写一个不符合语言语法规则的程序会立即让计算机抱怨。其他一些事情,例如调用非函数的东西或在未定义的值上查找属性,会在程序尝试执行该操作时导致错误报告。
但是,通常情况下,你的无意义计算只会产生 NaN
(非数字)或未定义的值,而程序会愉快地继续运行,确信它正在做一些有意义的事情。这个错误只会稍后表现出来,当这个错误的值经过多个函数后被使用时。它可能根本不会触发错误,但会默默地导致程序输出错误。找到此类问题的根源可能很困难。
严格模式
通过启用严格模式,可以使 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"); // forgot new // → TypeError: Cannot set property 'name' of undefined
幸运的是,使用 class
符号创建的构造函数在没有 new
的情况下被调用时总会抱怨,即使在非严格模式下,这也使得这个问题不那么严重。
严格模式还会做一些其他事情。它不允许给一个函数提供多个同名参数,并完全删除了一些有问题的语言特性(例如 with
语句,它是如此错误,以至于本书不再进一步讨论它)。
简而言之,在程序顶部添加 "use strict"
通常不会造成伤害,甚至可能帮助你发现问题。
类型
有些语言希望在程序运行之前就了解所有绑定和表达式的类型。它们会立即告诉你何时以不一致的方式使用类型。JavaScript 只在程序实际运行时才会考虑类型,而且即使在那时,它也经常尝试将值隐式转换为它期望的类型,因此它没有太大帮助。
尽管如此,类型还是为讨论程序提供了一个有用的框架。许多错误都是由于对进入或离开函数的值类型感到困惑造成的。如果你将这些信息记录下来,你就不太可能感到困惑。
你可以在上一章的 goalOrientedRobot
函数之前添加以下注释来描述它的类型
// (VillageState, Array) → {direction: string, memory: Array} function goalOrientedRobot(state, memory) { // ... }
有很多不同的约定用于用类型注释 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() == "مرحبا"; });
编写这样的测试往往会产生相当重复、笨拙的代码。幸运的是,存在一些软件可以帮助你构建和运行测试集合(测试套件),它们通过提供适合表达测试的语言(以函数和方法的形式)以及在测试失败时输出信息丰富的消息来做到这一点。这些通常被称为测试运行器。
有些代码比其他代码更容易测试。一般来说,代码交互的外部对象越多,为其设置测试环境就越难。在上一章中展示的编程风格,它使用自包含的持久值而不是改变对象,往往很容易测试。
调试
一旦你注意到程序存在问题,因为它行为异常或产生错误,下一步就是找出是什么问题。
有时很明显。错误消息会指向你程序的特定行,如果你查看错误描述和那行代码,你通常可以看到问题所在。
但并非总是如此。有时触发问题的行只是某个不稳定的值在其他地方产生并在无效的情况下使用时首次出现的地方。如果你一直在解决前面章节中的练习,你可能已经经历过这种情况。
以下示例程序试图通过反复选出最后一位数字,然后除以该数字来去除该数字,从而将一个整数转换为给定基数(十进制、二进制等)的字符串。但是,它目前产生的奇怪输出表明它有一个漏洞。
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
。让我们在循环开始时写出它的值。
13 1.3 0.13 0.013 … 1.5e-323
没错。用 10 除以 13 并不会得到一个整数。实际上我们想要的是 n = Math.
,而不是 n /= base
,这样才能正确地将数字“右移”。
除了使用 console.log
来窥视程序的行为之外,还可以使用浏览器的调试器功能。浏览器自带在代码特定行设置断点的功能。当程序执行到达带有断点的行时,它会暂停,你可以检查该点绑定的值。我不会详细介绍,因为调试器在不同的浏览器中有所不同,但是你可以查看浏览器的开发者工具或在网上搜索更多信息。
另一种设置断点的方法是在程序中包含一个 debugger
语句(仅包含该关键字)。如果浏览器的开发者工具处于活动状态,程序将在到达该语句时暂停。
错误传播
不幸的是,并非所有问题都能被程序员预防。如果你的程序以任何方式与外部世界进行通信,就有可能获取到格式错误的输入、工作量过载或网络故障。
如果你只是为自己编程,你可以承受这些问题直到它们出现。但如果你要构建一个要供其他人使用的程序,你通常希望程序比仅仅崩溃做得更好。有时正确的方法是泰然处之,继续运行。在其他情况下,最好向用户报告错误,然后放弃。但在任何情况下,程序都必须积极地对问题做出反应。
假设你有一个函数 promptNumber
,它会询问用户一个数字并返回它。如果用户输入“orange”,它应该返回什么?
一种选择是让它返回一个特殊的值。这种值的常见选择是 null
、undefined
或 -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 次,它必须检查 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
属性的对象。在大多数 JavaScript 环境中,此构造函数的实例还会收集有关创建异常时存在的调用栈的信息,即所谓的堆栈跟踪。此信息存储在 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
块,也可以与 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; } } }
这个版本的函数跟踪它的进度,如果在退出时发现它在创建不一致的程序状态时被中止,它会修复它造成的损坏。
请注意,即使在 try
块中抛出异常时也会运行 finally
代码,但它不会干扰异常。在 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
),并假设它知道问题所在,所以它错误地将绑定错误视为表示输入错误。这不仅会导致无限循环,还会“掩盖”有关拼写错误绑定的有用错误消息。
一般来说,除非是为了“路由”异常到某个地方(例如,通过网络告诉另一个系统我们的程序崩溃了),否则不要全面捕获异常。即使这样,也要仔细考虑你可能隐藏了哪些信息。
所以我们希望捕获特定类型的异常。我们可以在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]; }
现在,这个函数不会默默地返回未定义(当读取数组中不存在的属性时会得到未定义),而是在你误用它时立即大声地使程序崩溃。这使得这类错误不太可能被忽视,并且更容易在发生错误时找到错误原因。
我不建议尝试为所有可能的错误输入编写断言。这将是一项非常繁重的任务,并且会导致代码过于嘈杂。你应该将断言保留用于容易犯的错误(或者是你发现自己经常犯的错误)。
总结
错误和错误输入是生活中的现实。编程的重要部分是查找、诊断和修复错误。如果你有自动化测试套件或在程序中添加断言,问题会更容易被注意到。
由程序控制之外的因素引起的问题通常应该被优雅地处理。有时,如果问题可以在本地处理,特殊的返回值是跟踪问题的良好方法。否则,异常可能更可取。
抛出异常会导致调用堆栈被展开,直到下一个包含的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 = { 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
的函数,该函数将一个函数值作为参数,解锁盒子,运行函数,然后确保在返回之前盒子再次被锁定,无论参数函数是正常返回还是抛出异常。
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