为什么需要异常
上一页你用 enum class 给错误状态起了有意义的名字。对于函数预料之中、调用者应该检查的结果,这样做很好。但有些失败不一样:调用栈深处的某个函数发现文件打不开,或者某个参数毫无意义,而它根本不知道程序该怎么应对。返回错误码只有在调用链上的每个调用者都记得检查并往上传递时才行得通。漏掉一次检查,程序就会带着垃圾数据继续运行下去。
异常解决了这个问题。出错时,你 throw 一个对象。执行立即停止,栈开始展开(从 throw 到处理代码之间的每个局部对象都会运行它的析构函数),控制权跳到最近的匹配 catch。未处理的异常不会被悄悄忽略:如果没有任何代码捕获它,程序就会调用 std::terminate 并中止。
这一页重点讲抛出这一侧,也就是错误对象本身。下一页会详细讲解 try/catch 的机制。
抛出异常与 what() 消息
严格来说你可以 throw 任何值,throw 42; 或 throw "oops"; 都是合法的,但不要这样做。大家都遵循的惯例是抛出派生自 std::exception 的对象。这个基类声明了一个虚方法 what(),它返回一个描述问题的 const char*。坚持这个惯例,意味着一个 catch (const std::exception& e) 就能处理所有情况。
<stdexcept> 头文件提供了现成的类型,它们的构造函数接受错误消息:
注意,what() 返回的正是你构造异常时传入的那个字符串。还要注意,我们抛出的是 runtime_error,捕获时用的却是 const exception&。这之所以可行,是因为 runtime_error 就是一种 std::exception(这种关系你在继承那一页会认出来)。
标准异常体系
在编写自己的异常类型之前,先看看标准库里是否已经有合适的。它们都继承自 std::exception,在 <stdexcept> 中分为两大类:
logic_error:程序逻辑中的 bug,原则上在运行之前就能发现。子类型包括invalid_argument、out_of_range、domain_error和length_error。runtime_error:只有在运行时才会出现的失败,本身并不算编程错误。子类型包括range_error、overflow_error和underflow_error。
很多库函数会替你抛出这些异常。例如,vector 容器的 at() 会做边界检查,在你越界读取时抛出 out_of_range,而不是任由你读到末尾之外:
at() 是 v[9] 的安全版本。普通的 operator[] 不做边界检查,在这里读取 v[9] 是未定义行为,而不是异常。选用 at(),就能把一次悄无声息的内存破坏变成一个可以捕获的错误。
选择能描述这个错误的类型:调用者传入了毫无意义的东西,用 invalid_argument;下标或键有问题,用 out_of_range;“外部世界出了故障”,用 runtime_error。
编写自己的异常类型
当没有合适的标准类型时,比如你想附带额外的数据,或者只想专门 catch 你自己的错误而不管其他错误,就定义一个继承自 std::exception(或它的某个子类型)的类,并重写 what()。继承 std::runtime_error 是最简单的做法,因为它已经保存了消息,也替你实现了 what():
因为 NetworkError 带有状态码,处理代码就可以据此做出反应:遇到 5xx 就重试,遇到 4xx 就放弃。光是一个错误字符串做不到这一点。自定义类型还能让 catch (const NetworkError&) 只捕获网络问题,把其他一切留给它下面那个更通用的处理代码。
如果你直接继承 std::exception(而不是 runtime_error),记得自己重写 what(),并把它标记为 noexcept,以匹配基类的签名:
class ParseError : public std::exception {
public:
const char* what() const noexcept override {
return "failed to parse input";
}
};
按值抛出,按引用捕获
这是 C++ 异常最重要的一条规则,也是新手最常弄错的一条。按值抛出对象,按 const 引用捕获。
throw runtime_error("oops"); // by value - correct
catch (const runtime_error& e) { ... } // by const reference - correct
如果改成按值捕获,即 catch (std::exception e),异常会被复制到一个基类对象中,派生类的部分被切掉了。发生对象切片之后,e.what() 调用的是基类的实现,而不是你重写的版本,你精心编写的消息就不见了:
try {
throw NetworkError(503, "service unavailable");
} catch (std::exception e) { // by value - object slicing!
std::cout << e.what(); // generic message, status() is gone
}
引用(&)保留了真实的动态类型,所以虚函数 what() 能正确分派,你也仍然可以访问派生类的成员。加上 const,是因为你只读取异常,不修改它。永远不要抛出指针(throw new runtime_error(...)):捕获的一方就得负责 delete 它,可是在哪条代码路径上 delete 呢?这恰恰是异常本该帮你避免的那种泄漏。
下一步:try-catch
现在你已经能创建并 throw 规范的异常,也能为每种失败选择合适的标准类型了。故事的另一半是捕获这一侧。下一页完整讲解 try/catch:多个 catch 块如何从最具体到最通用排列、万能的 catch (...)、用单独的 throw; 重新抛出,以及 RAII(回想一下智能指针)如何保证栈展开时你的资源一定会被释放。
常见问题
C++ 中的异常是什么?
异常是一个对象,用来表示当前函数自己处理不了的错误。你 throw 它,栈随之展开(沿途销毁局部对象),然后由上层一个匹配的 catch 块接手。它把发现问题的代码和决定如何处理问题的代码分开了。
用 throw 和用 return 报告错误有什么区别?
return 的值必须由调用者检查,而这很容易被忘掉,程序就带着错误的数据继续运行。抛出的异常无法被忽略:如果没有人捕获它,程序就会终止。异常用于真正的失败(文件打不开、输入无效);对于普通的结果,包括预料之中的“未找到”情况,返回值仍然是正确的选择。
C++ 异常中的 what() 方法是做什么的?
每个派生自 std::exception 的类都提供一个虚方法 what(),它返回一个描述错误的 const char*。捕获异常后,调用 e.what() 就能得到一条人能读懂的消息,可以记录到日志或打印出来。标准异常类型会用你传给构造函数的字符串来设置这条消息。