第20章 异常处理机制
第20章 异常处理机制
- 使用 try-catch 块捕获和处理异常
- 自定义异常类并利用继承体系
- 理解栈展开与资源管理(RAII)的配合
想象一下,你正在操作一台自动咖啡机。正常情况下,你放入咖啡豆,按下按钮,得到咖啡。但如果咖啡豆卡住了,机器不会直接爆炸,而是会亮起红灯并发出“卡豆”的警报,让你有机会清理它,而不是让整个系统宕机。在C++中,异常处理就是这套警报和恢复机制,它允许程序在遇到无法在局部处理的错误(如文件不存在、内存不足、除零错误)时,跳出当前执行流,将错误信息“抛”给上层调用者处理,而不是让程序崩溃。
20.1 异常处理的基本语法:try, throw, catch
异常处理的核心是三个关键字:try、throw 和 catch。它们构成了一个“尝试执行-抛出问题-捕获处理”的流程。
异常处理流程
try 块用于包裹可能抛出异常的代码。throw 用于在检测到错误时抛出一个异常对象。catch 块紧随 try 块之后,用于捕获并处理特定类型的异常。一个基本的例子:
#include <iostream>
#include <stdexcept>
int divide(int a, int b) {
if (b == 0) {
throw std::runtime_error("Division by zero!"); // 抛出异常
}
return a / b;
}
int main() {
try { // 尝试执行
int result = divide(10, 0);
std::cout << "Result: " << result << std::endl;
} catch (const std::runtime_error& e) { // 捕获异常
std::cerr << "Error caught: " << e.what() << std::endl; // 输出 "Error caught: Division by zero!"
}
std::cout << "Program continues..." << std::endl;
return 0;
}
关键点: throw 可以抛出任何类型的对象(基本类型、字符串、类对象),但最佳实践是抛出派生自 std::exception 的类对象,因为它们有统一的 what() 成员函数来获取错误描述。
常见误区
- 滥用异常处理流程控制: 异常应用于处理“异常”情况(错误),而非正常的业务逻辑分支。用异常来实现类似 if-else 的逻辑会严重降低性能,我在一个高频交易系统的原型中踩过这个坑,性能直接下降了30%。
- 捕获所有异常但不处理: 使用 catch (...) 捕获所有异常后,如果不做任何有意义的处理(如记录日志、清理资源)就直接忽略,会掩盖严重的程序缺陷,导致调试极其困难。
- 在析构函数中抛出异常: 如果栈正在展开(即已经有异常被抛出),此时析构函数再抛出异常,程序会直接调用 std::terminate() 终止。这是C++异常机制的一个硬性规定。
20.2 标准异常类与自定义异常
C++标准库在 <stdexcept>、<new>、<typeinfo> 等头文件中定义了一套异常类体系,它们都继承自 std::exception。
| 异常类 | 头文件 | 典型抛出场景 |
|---|---|---|
| std::logic_error | <stdexcept> | 程序逻辑错误,如无效参数 |
| std::runtime_error | <stdexcept> | 运行时才能检测的错误,如文件打开失败 |
| std::bad_alloc | <new> | new 操作符内存分配失败 |
| std::bad_cast | <typeinfo> | dynamic_cast 对引用类型转换失败 |
对于特定领域的错误,你需要定义自己的异常类。一个好的自定义异常类应公开继承自标准异常类(通常是 std::runtime_error 或 std::logic_error),并提供一个能接受错误信息的构造函数。
#include <stdexcept>
#include <string>
class NetworkConnectionError : public std::runtime_error {
public:
NetworkConnectionError(const std::string& ip, int port)
: std::runtime_error("Failed to connect to " + ip + ":" + std::to_string(port)),
ipAddress(ip), portNumber(port) {}
const std::string& getIpAddress() const { return ipAddress; }
int getPortNumber() const { return portNumber; }
private:
std::string ipAddress;
int portNumber;
};
// 使用
void connectToServer() {
// ... 连接逻辑
if (connection_failed) {
throw NetworkConnectionError("192.168.1.100", 8080);
}
}
编者提示: 自定义异常类充分利用了面向对象的继承特性(详见第15章)。通过建立异常类的继承体系,你可以用基类引用捕获一大类异常,也可以用派生类引用捕获特定异常,实现错误处理的精细化。
20.3 栈展开、异常安全与RAII
当 throw 语句执行时,程序控制流会立即离开当前函数,并沿着调用链向上回溯,这个过程称为栈展开。在回溯过程中,局部对象(在栈上分配)会按照构造相反的顺序被析构。
栈展开引出了异常安全的概念。一个函数是异常安全的,意味着即使有异常抛出,它也不会泄露资源(如内存、文件句柄)或破坏数据一致性。实现异常安全的核心是RAII。
RAII类比: 就像你进入一个房间会自动开灯(获取资源),离开时无论你是正常走出去还是被紧急叫走(发生异常),自动感应系统都会确保灯被关上(释放资源)。在C++中,RAII(Resource Acquisition Is Initialization,资源获取即初始化)是指将资源(内存、文件、锁)的生命周期绑定到一个局部对象的生命周期上,由对象的构造函数获取资源,析构函数释放资源。
由于栈展开时会自动调用局部对象的析构函数,因此RAII能保证资源在任何离开函数的方式(正常返回或异常抛出)下都能被正确释放。这是C++管理资源的根本大法。
#include <memory>
void processFile(const char* filename) {
// 传统危险写法:如果中间抛出异常,文件句柄泄露,内存泄露
// FILE* f = fopen(filename, "r");
// int* buffer = new int[1024];
// ... // 可能抛出异常的操作
// fclose(f);
// delete[] buffer;
// RAII安全写法(C++11后)
auto filePtr = std::unique_ptr<FILE, decltype(&fclose)>(fopen(filename, "r"), &fclose);
auto bufferPtr = std::make_unique<int[]>(1024);
if (!filePtr) {
throw std::runtime_error("Failed to open file");
}
// ... 即使这里抛出异常,filePtr和bufferPtr的析构函数也会被调用,自动释放资源
}
关键点: 实际开发中,绝大部分资源管理都应通过RAII对象来完成,例如使用 std::unique_ptr(第25章)、std::shared_ptr、std::lock_guard 等。这能从根本上避免资源泄露,是实现强异常安全保证的基础。
20.4 异常规格与noexcept
在C++11之前,可以使用异常规格声明函数可能抛出的异常类型,如 void func() throw(std::runtime_error);。但这种动态检查机制问题很多,在C++11中已被弃用。
C++11引入了 noexcept 关键字,它表示函数承诺不会抛出任何异常。这有两层重要意义:
- 编译器优化: 编译器知道 noexcept 函数不会抛出,可以生成更高效的代码,并省略为栈展开准备的额外簿记信息。
- 标准库要求: 许多标准库操作(如 std::vector 的 resize、移动构造函数/赋值运算符)在特定条件下要求操作是 noexcept 的,以提供强异常安全保证或启用优化路径。
class MyMovableType {
public:
// 移动构造函数标记为noexcept,使std::vector在扩容时能使用更高效的移动而非复制
MyMovableType(MyMovableType&& other) noexcept
: data(other.data), size(other.size) {
other.data = nullptr;
other.size = 0;
}
// 析构函数通常也应是noexcept的
~MyMovableType() noexcept {
delete[] data;
}
private:
int* data;
size_t size;
};
void simpleFunction() noexcept { // 承诺不抛异常
// 只能调用其他noexcept函数或进行不会抛出的操作
}
建议: 对于移动操作、交换操作、析构函数,如果确实能保证不抛出异常,就加上 noexcept。对于其他函数,除非你非常确定其内部和所有调用都不会抛出,否则不要轻易使用 noexcept,因为违反 noexcept 承诺会导致程序调用 std::terminate() 直接终止。
何时使用异常?
- 处理外部、不可预测的错误:文件I/O失败、网络断开、内存不足。
- 错误需要跨多层函数调用向上传递到合适的处理层。
- 构造函数失败(构造函数没有返回值,报告错误的唯一标准方式就是抛出异常)。
何时避免异常?
- 用于常规控制流(如遍历结束)。
- 在实时系统或性能极端敏感的代码中(异常处理有开销)。
- 在C语言接口或需要与不支持异常的语言交互的模块边界。
错误处理替代方案
- 返回错误码(简单,但需要每层检查,易遗漏)。
- 返回 std::optional 或 std::expected(C++17/23,用于可能失败且有返回值的函数)。
- 使用断言 assert(仅用于调试阶段捕捉程序逻辑错误,发布版本通常被禁用)。
动手试一试
1. 编写一个函数 readIntegerFromFile,接收一个文件名作为参数。尝试打开文件并读取一个整数。如果文件无法打开,抛出 std::runtime_error;如果文件内容不是有效整数,抛出 std::invalid_argument。在 main 函数中使用 try-catch 块捕获这两种异常并打印不同的错误信息。
2. 创建一个自定义异常类 InvalidConfigError,继承自 std::logic_error。为其添加一个成员变量存储错误的配置项名称。编写一个函数模拟读取配置,当遇到未知配置项时抛出此异常。
3. (挑战)修改第2题中的函数,使用RAII原则管理一个模拟的“配置解析器句柄”(可以用一个简单的 int 或 std::unique_ptr 模拟),确保在函数因异常退出时,该“句柄”能被正确“释放”(打印一条释放消息)。
检验你的理解
- 如果在一个函数内,try 块中抛出了异常,但该函数内没有匹配的 catch 块,会发生什么?
- 为什么说在析构函数中抛出异常是危险的?在什么情况下程序会直接终止?
- noexcept 关键字的主要作用是什么?将一个函数标记为 noexcept 后,它内部还能调用可能抛出异常的函数吗?
- 请简述RAII如何帮助实现异常安全。它与智能指针(第25章内容)有什么关系?
本章小结
- 异常处理通过 try(尝试)、throw(抛出)、catch(捕获)三个关键字,提供了将错误信息跨函数边界传递的机制。
- 应优先使用标准库异常类(如 std::runtime_error),并通过公开继承它们来创建具有领域意义的自定义异常类。
- 异常抛出会触发栈展开,自动析构栈上的局部对象。结合RAII(资源获取即初始化)技术,将资源管理绑定到对象生命周期,是确保异常安全、避免资源泄露的黄金法则。
- noexcept 说明符用于承诺函数不抛出异常,有助于编译器优化并满足某些标准库操作的强异常安全要求。
- 异常适用于处理罕见的、跨层的错误,不应替代正常的流程控制,并需注意在性能关键或与外部接口交互的场景下的使用限制。
微信扫码关注
抖音扫码关注