异常处理:try、throw、catch;异常安全;标准异常类;noexcept说明符
异常处理这个话题,说实话,很多C++程序员对它又爱又恨。爱它是因为它能优雅地处理错误,恨它是因为用不好反而会让代码变得一团糟。我在项目中见过太多滥用异常的例子,也见过因为不用异常导致代码臃肿不堪的情况。今天我们就来好好聊聊这个。
一、异常处理的基本机制:try、throw、catch
异常处理的核心思想很简单:把错误检测和错误处理分离开。你想想看,传统的错误处理方式是什么?函数返回错误码,调用方检查返回值。这种方式的问题在于,错误处理代码会散落在业务逻辑的各个角落,代码可读性极差。
C++的异常机制用三个关键字搞定:
- 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;
}
这段代码很简单,但背后有几个关键点:
- throw抛出的可以是任何类型的对象,但建议继承自std::exception
- catch按类型匹配,可以写多个catch块处理不同类型的异常
- catch(...)可以捕获所有异常,但一般只用在最外层兜底
我的习惯:我一般会先写具体的异常类型,最后写catch(...)兜底。这样既能精确处理已知异常,又不会漏掉未知异常。
二、异常安全:写代码要有底线
异常安全这个概念,说白了就是:当异常发生时,你的程序不能处于一个「半死不活」的状态。我早期写代码时吃过这个亏——资源没释放、数据状态不一致,都是血泪教训。
异常安全有三个级别:
| 级别 | 含义 | 示例 |
|---|---|---|
| 基本保证 | 异常发生后,资源不泄漏,对象处于有效但可能不一致的状态 | vector的push_back失败时,vector大小不变 |
| 强保证 | 操作要么完全成功,要么回滚到操作前的状态 | 数据库事务的提交/回滚 |
| 不抛异常 | 保证函数永远不会抛出异常 | swap操作、析构函数 |
实现异常安全的关键技术:
- RAII:资源获取即初始化,用对象管理资源,析构函数自动释放
- copy-and-swap:先拷贝一份,修改拷贝,如果成功就交换,失败则原对象不变
- scope guard:作用域守卫,在离开作用域时自动执行清理操作
// 使用RAII实现异常安全的文件操作
class FileGuard {
FILE* file_;
public:
FileGuard(const char* filename, const char* mode)
: file_(fopen(filename, mode)) {
if (!file_) {
throw std::runtime_error("文件打开失败");
}
}
~FileGuard() {
if (file_) {
fclose(file_);
}
}
// 禁止拷贝
FileGuard(const FileGuard&) = delete;
FileGuard& operator=(const FileGuard&) = delete;
// 提供移动语义
FileGuard(FileGuard&& other) noexcept : file_(other.file_) {
other.file_ = nullptr;
}
};
我曾经踩过的坑:在构造函数中抛出异常,但忘记释放已经分配的资源。后来我学乖了,所有资源都用RAII管理,构造函数里只做一件事——初始化成员变量。这样即使抛出异常,已经构造好的成员变量也会自动析构。
三、标准异常类:别自己造轮子
C++标准库提供了一套完整的异常类体系,都继承自std::exception。我个人建议,除非有特殊需求,否则直接用标准异常类就好。
常用的标准异常类:
| 异常类 | 头文件 | 使用场景 |
|---|---|---|
| std::runtime_error | <stdexcept> | 运行时错误,如除零、越界 |
| std::logic_error | <stdexcept> | 逻辑错误,如参数无效、状态错误 |
| std::bad_alloc | <new> | 内存分配失败 |
| std::bad_cast | <typeinfo> | dynamic_cast失败 |
| std::out_of_range | <stdexcept> | 容器访问越界 |
| std::invalid_argument | <stdexcept> | 无效参数 |
如果你需要自定义异常,继承std::exception并重写what()方法即可:
class MyException : public std::exception {
std::string msg_;
public:
explicit MyException(const char* msg) : msg_(msg) {}
const char* what() const noexcept override {
return msg_.c_str();
}
};
四、noexcept说明符:承诺的力量
noexcept是C++11引入的关键字,用来告诉编译器:这个函数不会抛出异常。为什么需要这个?因为编译器可以根据这个信息做优化,比如移动构造函数如果声明为noexcept,标准库容器在重新分配内存时就会使用移动而不是拷贝,性能提升明显。
使用场景:
- 析构函数:默认就是noexcept,不要破坏这个约定
- 移动构造函数和移动赋值:强烈建议声明为noexcept
- swap函数:通常应该是noexcept
- 简单访问器:比如getter函数,不会抛出异常
class Buffer {
char* data_;
size_t size_;
public:
// 移动构造函数声明为noexcept
Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}
// 移动赋值也声明为noexcept
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}
// 析构函数默认noexcept
~Buffer() {
delete[] data_;
}
};
小技巧:如果你不确定某个函数是否应该声明为noexcept,可以先不声明。等代码稳定后,用静态分析工具检查,再逐步加上noexcept。记住,noexcept是一个承诺,一旦违反程序就会直接终止。
五、异常处理的最佳实践
说了这么多,总结几条我在项目中一直遵循的原则:
- 不要用异常控制流程:异常是给异常情况用的,不是给正常逻辑用的。循环、条件判断才是控制流程的正确方式。
- 按值抛出,按引用捕获:throw异常对象时按值,catch时用const引用,避免对象切片和额外的拷贝。
- 保持异常层次扁平:不要搞太复杂的异常继承体系,够用就好。
- 在合适的地方捕获异常:不要在每个函数里都try-catch,让异常传播到能正确处理它的地方。
- RAII是异常安全的基础:所有资源都用RAII管理,这是C++异常安全的基石。
最后,用一张图来总结本章的知识体系:
异常处理不是银弹,但用好了确实能让代码更优雅、更健壮。记住一点:异常是用来处理「意料之外」的情况的,不是用来替代「if-else」的。把握好这个度,你的代码质量会上一个台阶。