23、异常处理:try-catch-throw、标准异常类、自定义异常
写代码这么多年,我越来越觉得异常处理就像给程序买保险。平时觉得没用,真出事了才知道它的好。你想想看,一个程序跑着跑着突然崩溃了,用户看到的是个黑框或者闪退,多尴尬。异常处理就是让程序在出错时还能优雅地收场。
为什么需要异常处理?
传统的错误处理方式,无非是函数返回错误码,或者设置全局错误标志。我在早期项目里也这么干过,结果呢?调用方经常忘记检查返回值,错误就这样悄悄溜过去了。更麻烦的是,错误码层层传递,代码里全是 if 判断,可读性差得要命。
异常处理就不一样了。它能把错误检测和错误处理分离开来。出错的代码只管抛出异常,处理异常的代码集中在一个地方搞定。说白了,就是让正常逻辑和错误逻辑各走各的路。
核心思想:异常处理让程序在遇到错误时,能够跳转到预先准备好的处理代码,而不是直接崩溃或返回错误码。
try-catch-throw 基本用法
这三个关键字是异常处理的铁三角。我习惯这么理解:try 是监控区,throw 是报警器,catch 是消防队。
#include <iostream>
#include <stdexcept>
double divide(double a, double b) {
if (b == 0.0) {
throw std::runtime_error("除数不能为零!");
}
return a / b;
}
int main() {
try {
double result = divide(10.0, 0.0);
std::cout << "结果: " << result << std::endl;
} catch (const std::exception& e) {
std::cerr << "捕获到异常: " << e.what() << std::endl;
}
return 0;
}
你看,divide 函数发现除数为零时,直接抛出异常。调用方用 try-catch 包起来,异常就被捕获了。程序不会崩溃,而是打印出友好的错误信息。
我的习惯:catch 时尽量用引用捕获(const std::exception&),避免对象切片,也省去一次拷贝。
异常传播与栈展开
异常抛出后,会发生栈展开(stack unwinding)。什么意思呢?就是程序沿着调用链往回走,沿途的局部对象会被自动析构,直到找到匹配的 catch 块。我曾经在调试一个内存泄漏问题时,发现就是栈展开时某个指针没被释放。嗯,这里要注意:栈展开只保证局部对象的析构函数被调用,不保证堆内存被释放。
class Resource {
public:
Resource() { std::cout << "资源获取\n"; }
~Resource() { std::cout << "资源释放\n"; }
};
void func() {
Resource res; // 栈展开时会自动析构
throw std::runtime_error("出错了");
}
int main() {
try {
func();
} catch (...) {
std::cout << "捕获所有异常\n";
}
return 0;
}
输出结果:
资源获取
资源释放
捕获所有异常
看到没?Resource 对象在栈展开时被正确析构了。这就是 RAII 的力量。
避坑指南:我曾经在析构函数里抛出异常,结果程序直接 terminate 了。记住,析构函数永远不要抛出异常!如果析构函数里可能出错,请内部消化掉。
标准异常类
C++ 标准库提供了一套异常类体系,都继承自 std::exception。我平时用得最多的是这几个:
| 异常类 | 说明 | 使用场景 |
|---|---|---|
std::runtime_error |
运行时错误 | 除零、文件打开失败等 |
std::logic_error |
逻辑错误 | 参数无效、越界访问等 |
std::out_of_range |
越界错误 | vector::at() 越界 |
std::bad_alloc |
内存分配失败 | new 分配内存失败 |
std::invalid_argument |
无效参数 | 函数参数不合法 |
这些异常类都提供了 what() 方法,返回一个描述错误的 C 风格字符串。我个人建议优先使用标准异常类,而不是自己造轮子。除非标准库确实满足不了你的需求。
#include <iostream>
#include <vector>
#include <stdexcept>
int main() {
try {
std::vector<int> vec = {1, 2, 3};
int val = vec.at(10); // 抛出 out_of_range
} catch (const std::out_of_range& e) {
std::cerr << "越界了: " << e.what() << std::endl;
}
return 0;
}
自定义异常类
有时候标准异常类不够用。比如你在写一个网络库,需要表达「连接超时」、「DNS 解析失败」这类具体错误。这时候就该自定义异常了。
自定义异常很简单,继承 std::exception 或者它的子类,重写 what() 方法就行。
#include <iostream>
#include <exception>
#include <string>
class NetworkException : public std::runtime_error {
public:
explicit NetworkException(const std::string& message, int errorCode)
: std::runtime_error(message), m_errorCode(errorCode) {}
int getErrorCode() const { return m_errorCode; }
private:
int m_errorCode;
};
void connectToServer() {
// 模拟连接失败
throw NetworkException("连接服务器失败", 1001);
}
int main() {
try {
connectToServer();
} catch (const NetworkException& e) {
std::cerr << "网络错误: " << e.what()
<< " (错误码: " << e.getErrorCode() << ")" << std::endl;
}
return 0;
}
你看,自定义异常可以携带更多信息,比如错误码。这样 catch 块就能根据错误码做不同的处理。
我的建议:自定义异常类最好继承 std::runtime_error 或 std::logic_error,而不是直接继承 std::exception。因为前者已经帮你实现了 what() 的字符串存储,省事不少。
异常安全与最佳实践
写异常安全的代码,说白了就是要保证:即使发生异常,程序也不会泄漏资源或处于不一致状态。我总结了几个原则:
- 使用 RAII:让资源管理自动进行。智能指针、锁守卫都是好例子。
- 不要吞异常:catch 了异常却什么都不做,比不 catch 更糟糕。
- 按引用捕获:避免对象切片和额外拷贝。
- catch 顺序:子类在前,父类在后。否则子类异常永远捕获不到。
- 不要抛出异常:在析构函数、移动构造函数、swap 函数中。
// 不好的做法
try {
// ...
} catch (std::exception) { // 按值捕获,切片!
// 什么都不做,异常被吞了
}
// 好的做法
try {
// ...
} catch (const std::invalid_argument& e) { // 子类在前
// 处理无效参数
} catch (const std::exception& e) { // 父类在后
std::cerr << "未知异常: " << e.what() << std::endl;
throw; // 重新抛出,让上层处理
}
我曾经踩过的坑:在 catch 块里写 throw e; 而不是 throw;。前者会拷贝一份异常对象,丢失了多态信息。记住,重新抛出请用 throw; 不带参数。
异常处理知识体系
下面这张图帮你理清异常处理的整体脉络:
异常处理这东西,刚开始可能觉得麻烦。但用顺手了,你会发现它让代码干净很多。错误处理不再散落在各个角落,而是集中管理。程序出问题时,用户看到的是友好的提示,而不是莫名其妙的崩溃。
记住一点:异常不是为了处理「所有可能的错误」,而是为了处理「你预料到但无法就地解决的错误」。把握好这个度,你的代码就会既健壮又清晰。