Menu

C++ try-catch 异常处理:正确捕获异常

把有风险的代码放进 try,在 catch 里做出响应。学习如何按 const 引用捕获异常、排列多个处理器、使用 catch (...) 以及重新抛出,同时不泄漏资源。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

从抛出到处理

上一页你学会了在出问题时用 throw 抛出异常。抛出只是故事的一半:一个始终没被捕获的异常会调用 std::terminate,让程序崩溃。try/catch 语句就是你用来处理抛出的东西、让程序继续运行的方式。

它的结构很简单:把有风险的代码放进 try 块,后面跟上一个或多个 catch 块,分别响应特定的错误类型。如果 try 块顺利执行完,所有 catch 都会被跳过。一旦有东西被抛出,控制流就直接跳到第一个匹配的 catch。

注意 "after" 从来没有被打印。throw 一触发,try 块剩下的部分就被放弃,执行转到匹配的 catch 里继续。catch 执行完后,程序在它下面照常往下运行。

按 const 引用捕获

C++ 错误处理中最重要的一个习惯:按 const 引用捕获异常,而不是按值。

按值捕获会复制异常,更糟的是会把它切片。标准异常构成一个层次结构(runtime_error 和 logic_error 都派生自 std::exception),所以把派生异常当作基类的值来捕获,会把派生部分切掉。按引用捕获则让对象保持完整,并保留多态:

这里我们抛出的是 out_of_range,却以 const exception& 捕获它。因为 out_of_range 派生自 exception,基类的处理器能够匹配;又因为是引用,e.what() 返回的仍然是真实的消息。如果你写成 catch (exception e)(按值),对象会被切成一个普通的 exception,具体的消息就可能丢失。

多个 catch 块

一个 try 后面可以跟多个 catch 块,每个处理一种不同的异常类型。C++ 会从上到下依次尝试,并执行第一个匹配的块,所以要按从最具体到最通用的顺序排列。

因为 invalid_argument 比 exception 更具体,它必须放在前面。如果你把顺序反过来,把 catch (const exception&) 放在最上面,它就会吞掉所有异常,下面那个 invalid_argument 处理器就成了永远不会执行的死代码。很多编译器会对此发出警告,但语言本身不会阻止你。

catch (...) 与重新抛出

有时你需要一张安全网,兜住所有没预料到的情况。万能处理器 catch (...) 能匹配任何异常类型,包括不是从 std::exception 派生的那些(有人可能会写 throw 42; 或 throw "oops";)。

问题在于你拿不到任何对象,没有 e 可以检查。所以 catch (...) 最好作为最后的手段:记录下某个地方出了错,或者做完清理后重新抛出。

要重新抛出当前异常(在做完局部清理或记录日志后,把它交给外层的处理器),使用不带操作数的 throw;。这样会保留原始异常(它真实的类型和消息),而 throw e; 抛出的是一个被切片的副本:

内层处理器记录日志后重新抛出,再由 main 里的外层处理器处理。做这件事请用单独的 throw;,永远不要用 throw e;。

栈展开与 RAII

当异常从 try 块中传播出去时,C++ 会执行栈展开(stack unwinding):throw 和匹配的 catch 之间的每个局部对象都会调用析构函数,顺序与构造顺序相反。正是这一点让异常变得安全:栈上对象持有的资源会被自动释放。

这正是你应该用 RAII 类型(例如 std::vector、std::string 和智能指针)来持有资源、而不是直接使用 new/delete 的原因。看看当异常穿过一次手动分配时会发生什么:

void leaky() {
    int* buffer = new int[1000];
    mightThrow();        // if this throws, the next line never runs...
    delete[] buffer;     // ...and the buffer leaks
}

因为 throw 跳过了 delete[],这块内存就丢了。智能指针可以轻松解决这个问题,它的析构函数会在栈展开时执行:

void safe() {
    auto buffer = std::make_unique<int[]>(1000);
    mightThrow();   // if this throws, buffer's destructor still frees the memory
}                   // no manual delete, no leak, even on the exception path

结论是:不要为了 delete 某个东西而去 catch 异常。让析构函数负责清理,把 catch 留给“如何恢复”这类决策。

常见错误与陷阱

有几个陷阱总是反复出现:

不要用异常来做正常的控制流。 抛出和栈展开比一个简单的 if 慢得多。异常只用于真正异常的错误情况,而不是“用户输入了一个空字符串”。

空的 catch 块会掩盖 bug。 写 catch (...) {} 来让错误静默,意味着失败会消失得无影无踪。至少要把问题记录下来;通常你应该重新抛出,或者妥善处理它。

会抛出异常的析构函数很危险。 如果析构函数在栈展开期间(另一个异常已经在传播时)抛出异常,程序会调用 std::terminate。在现代 C++ 中,析构函数隐式地是 noexcept,永远不要让异常从析构函数里逃出来。

catch 只能看到 try 覆盖的范围。 在进入 try 之前抛出的异常,或者在不处于其内部调用路径上的其他函数里抛出的异常,都不会在这里被捕获。catch 只保护在它自己的 try 块里运行的代码(直接运行的,或在它调用的函数中运行的)。

下一步:未定义行为

异常是 C++ 告诉你出错的一种有定义的方式:你抛出,你捕获,行为可以预测。但 C++ 还有一个更阴暗的角落,在那里语言不做任何承诺:解引用悬空指针、越过数组末尾读取、有符号整数溢出。下一页讲解未定义行为:它由什么触发,为什么它可能一直看起来“正常”,直到某天彻底崩溃,以及如何让它远离你的代码。

常见问题

C++ 中 try-catch 是怎么工作的?

把可能抛出异常的代码放进 try { } 块。一旦有异常抛出,程序就不再执行 try 块剩下的部分,而是跳到第一个匹配的 catch 块,由你在那里处理错误。如果什么都没抛出,所有 catch 块都会被直接跳过。

C++ 中为什么要按 const 引用捕获异常?

按引用捕获(catch (const std::exception& e))可以避免复制异常对象,更关键的是能保留多态:以基类类型捕获的派生异常,调用的仍然是正确的 what()。按值捕获(catch (std::exception e))会把派生部分切掉,可能丢失信息。

C++ 中如何捕获任意异常?

使用 catch (...):省略号能捕获任何类型的异常。它适合作为最后的兜底处理器,但因为你拿不到可以检查的对象,应当把它放在具体的 catch 块之后,主要用来记录日志或重新抛出。

Coddy 编程语言插图

和 Coddy 一起学编程

开始