第 5 章: 错误处理

编写在一切按预期进行时工作的程序是一个良好的开端。让你的程序在遇到意外情况时能正常运行才是真正的挑战。

程序可能遇到的问题情况分为两类:程序员错误和真正的问题。如果有人忘记向函数传递一个必需的参数,这就是第一类问题的例子。另一方面,如果一个程序要求用户输入一个姓名,而它得到一个空字符串,那是程序员无法阻止的。

一般来说,程序员错误可以通过查找和修复来处理,而真正错误可以通过代码检查它们并执行一些合适的操作来弥补(例如,再次询问姓名),或者至少以一种定义良好且干净的方式失败。


重要的是要确定某个问题属于哪一类。例如,考虑我们之前写的 power 函数

function power(base, exponent) {
  var result = 1;
  for (var count = 0; count < exponent; count++)
    result *= base;
  return result;
}

当某个极客试图调用 power("Rabbit", 4) 时,这显然是一个程序员错误,但是 power(9, 0.5) 呢?该函数无法处理分数指数,但从数学角度来说,将一个数字提升到二分之一次方是完全合理的 (Math.pow 可以处理它)。在不完全清楚函数接受哪种输入的情况下,通常最好在注释中明确说明可接受的参数类型。


如果一个函数遇到了它自身无法解决的问题,它应该怎么做?在 第 4 章 中,我们编写了 between 函数

function between(string, start, end) {
  var startAt = string.indexOf(start) + start.length;
  var endAt = string.indexOf(end, startAt);
  return string.slice(startAt, endAt);
}

如果给定的 startend 不出现在字符串中,indexOf 将返回 -1,并且这个版本的 between 将返回很多无意义的东西:between("Your mother!", "{-", "-}") 返回 "our mother"

当程序正在运行,并且函数被这样调用时,调用它的代码将得到一个字符串值,正如它所期望的那样,并且会开心地继续做一些事情。但这个值是错误的,所以无论它最终用它做什么都会是错误的。如果你不幸,这个错误只会在你经过二十个其他函数之后才出现问题。在这种情况下,很难找出问题的根源。

在某些情况下,你会对这些问题漠不关心,以至于不介意函数在收到错误输入时出现故障。例如,如果你确定函数只会被少数几个地方调用,并且你可以证明这些地方都给它提供了合理的输入,那么通常不值得费事让函数变得更大更丑,以便它能够处理有问题的案例。

但大多数时候,'静默'失败的函数很难使用,甚至很危险。如果调用 between 的代码想要知道是否一切都顺利呢?目前,它无法判断,除非重新进行 between 完成的所有工作,并用自己的结果检查 between 的结果。那很糟糕。一个解决方案是让 between 返回一个特殊的值,例如 falseundefined,当它失败时。

function between(string, start, end) {
  var startAt = string.indexOf(start);
  if (startAt == -1)
    return undefined;
  startAt += start.length;
  var endAt = string.indexOf(end, startAt);
  if (endAt == -1)
    return undefined;

  return string.slice(startAt, endAt);
}

你可以看到错误检查通常不会让函数更漂亮。但是现在,调用 between 的代码可以做一些事情,例如

var input = prompt("Tell me something", "");
var parenthesized = between(input, "(", ")");
if (parenthesized != undefined)
  print("You parenthesized '", parenthesized, "'.");

在许多情况下,返回一个特殊的值是指示错误的一种完全可行的方式。然而,它也有一些缺点。首先,如果函数已经可以返回所有可能的价值类型呢?例如,考虑这个从数组中获取最后一个元素的函数

function lastElement(array) {
  if (array.length > 0)
    return array[array.length - 1];
  else
    return undefined;
}

show(lastElement([1, 2, undefined]));

那么数组是否有最后一个元素呢?通过查看 lastElement 返回的值,无法判断。

返回特殊值的第二个问题是,它有时会导致很多混乱。如果一段代码调用了 between 十次,它必须检查十次是否返回了 undefined。此外,如果一个函数调用了 between,但没有策略来从失败中恢复,它必须检查 between 的返回值,如果它是 undefined,那么这个函数就可以返回 undefined 或其他一些特殊值给它的调用者,而调用者也会检查这个值。

有时,当发生奇怪的事情时,最好是停止我们正在做的事情,立即跳回到知道如何处理问题的地方。

好吧,我们很幸运,许多编程语言都提供了这样的机制。通常,它被称为 异常处理。


异常处理背后的理论是这样的:代码可以 引发(或 抛出)一个 异常,它是一个值。引发异常有点类似于函数的超级强化返回——它不仅跳出当前函数,而且还跳出它的调用者,一直跳到启动当前执行的最顶层调用。这被称为 栈展开。你可能还记得在 第 3 章 中提到的 函数调用栈。异常沿着这个栈快速下降,抛弃它遇到的所有调用上下文。

如果它们总是直接降到底部,异常就没什么用处,它们只会提供一种新颖的方式来让你的程序崩溃。幸运的是,可以沿着栈设置障碍来阻挡异常。这些'捕获'异常,并在它向下快速下降时做一些处理,之后程序会继续运行在异常被捕获的地方。

一个例子

function lastElement(array) {
  if (array.length > 0)
    return array[array.length - 1];
  else
    throw "Can not take the last element of an empty array.";
}

function lastElementPlusTen(array) {
  return lastElement(array) + 10;
}

try {
  print(lastElementPlusTen([]));
}
catch (error) {
  print("Something went wrong: ", error);
}

throw 是用来引发异常的关键字。关键字 try 为异常设置了一个障碍:当它后面的代码块引发异常时,catch 代码块将被执行。在 catch 关键字后面的括号中命名的变量是在这个代码块中给异常值起的名字。

请注意,函数 lastElementPlusTen 完全忽略了 lastElement 可能出错的可能性。这是异常的一大优势——错误处理代码只需要出现在错误发生的地方和错误被处理的地方。中间的函数可以忘记所有这些。

好吧,几乎是。


考虑以下情况:一个函数 processThing 想要在它的代码块执行期间将一个顶层变量 currentThing 设置为指向一个特定的东西,这样其他函数也可以访问这个东西。通常情况下,你当然只需要将这个东西作为参数传递,但假设一下,这种情况不可行。当函数结束时,currentThing 应该被重置为 null

var currentThing = null;

function processThing(thing) {
  if (currentThing != null)
    throw "Oh no! We are already processing a thing!";

  currentThing = thing;
  /* do complicated processing... */
  currentThing = null;
}

但如果复杂的处理过程引发了异常呢?在这种情况下,调用 processThing 将被异常从栈中抛出,而 currentThing 将永远不会被重置为 null

try 语句也可以后面跟着 finally 关键字,这意味着 '无论发生什么,都在尝试运行 try 代码块中的代码之后运行这段代码'。如果一个函数必须清理一些东西,清理代码通常应该放在 finally 代码块中

function processThing(thing) {
  if (currentThing != null)
    throw "Oh no! We are already processing a thing!";

  currentThing = thing;
  try {
    /* do complicated processing... */
  }
  finally {
    currentThing = null;
  }
}

程序中的许多错误会导致 JavaScript 环境引发异常。例如

try {
  print(Sasquatch);
}
catch (error) {
  print("Caught: " + error.message);
}

在这样的情况下,会引发特殊的错误对象。这些对象总是有一个 message 属性,包含问题的描述。你可以使用 new 关键字和 Error 构造函数引发类似的对象

throw new Error("Fire!");

当一个异常一直到达栈的底部而没有被捕获时,它会被环境处理。这意味着什么在不同的浏览器之间会有所不同,有时错误的描述会被写入某种日志,有时会弹出一个窗口来描述错误。

在这个页面上,通过在控制台中输入代码产生的错误总是会被控制台捕获,并在其他输出中显示。


大多数程序员认为异常纯粹是一种错误处理机制。然而,本质上,它们只是影响程序控制流的另一种方式。例如,它们可以用作递归函数中的一种 break 语句。这里有一个有点奇怪的函数,它确定一个对象及其内部存储的对象是否包含至少七个 true

var FoundSeven = {};

function hasSevenTruths(object) {
  var counted = 0;

  function count(object) {
    for (var name in object) {
      if (object[name] === true) {
        counted++;
        if (counted == 7)
          throw FoundSeven;
      }
      else if (typeof object[name] == "object") {
        count(object[name]);
      }
    }
  }

  try {
    count(object);
    return false;
  }
  catch (exception) {
    if (exception != FoundSeven)
      throw exception;
    return true;
  }
}

内部函数 count 会对作为参数的每个对象递归调用。当变量 counted 达到七时,就没有必要继续计数了,但仅仅从当前的 count 调用中返回并不能保证停止计数,因为下面可能还有更多的调用。所以我们所做的是抛出一个值,这将导致控制权直接跳出对 count 的任何调用,并到达 catch 代码块。

但是仅仅在遇到异常时返回 true 是不正确的。其他问题也可能发生,所以我们首先检查异常是否为 FoundSeven 对象,它是专门为此目的创建的。如果不是,这个 catch 代码块不知道如何处理它,所以它会再次引发它。

这是一个在处理错误条件时也很常见的模式——你必须确保你的 catch 代码块只处理它知道如何处理的异常。抛出字符串值,就像本章中的一些例子那样,很少是一个好主意,因为它难以识别异常的类型。更好的方法是使用唯一的值,例如 FoundSeven 对象,或者引入一个新的对象类型,正如 第 8 章 中所描述的那样。