16、异常安全测试:如何测试函数在抛出异常时的资源管理和状态一致性?
异常安全,说白了就是你的代码在出岔子的时候,能不能体面地收场。
我见过太多项目,功能跑得飞起,一遇到异常就崩得稀里哗啦。内存泄漏、文件句柄没关、数据写到一半卡住……这些坑,我几乎都踩过。今天我们就来聊聊,怎么用测试驱动开发(TDD)的思路,把异常安全这块硬骨头啃下来。
异常安全的基本保证
在动手写测试之前,我们得先统一一下语言。C++ 社区对异常安全有三个级别的保证,我习惯把它们记成「三不伤害」原则:
| 保证级别 | 含义 | 通俗理解 |
|---|---|---|
| 基本保证 | 抛出异常后,资源不泄漏,对象处于有效但不确定的状态 | 程序还能继续跑,但数据可能变了样 |
| 强保证 | 操作要么完全成功,要么回滚到操作前的状态 | 像数据库事务一样,要么全做,要么不做 |
| 不抛保证 | 承诺函数永远不会抛出异常 | 最安全,但也最难做到 |
我个人习惯,在写任何可能抛出异常的代码前,先问自己一个问题:「如果这里炸了,我的对象还能用吗?」如果答案是否定的,那测试就得先补上。
测试资源管理:RAII 的守护者
异常安全测试的第一道防线,就是检查资源有没有泄漏。C++ 里我们靠 RAII 来管理资源,但 RAII 本身也需要测试来验证。
举个例子,一个简单的智能指针封装:
class ResourceGuard {
public:
ResourceGuard() : data_(new int[1024]) {}
~ResourceGuard() { delete[] data_; }
void riskyOperation() {
// 可能抛出异常的操作
if (/* 某些条件 */) {
throw std::runtime_error("出错了");
}
}
private:
int* data_;
};
测试这个类,我会这样写:
TEST(ResourceGuardTest, NoLeakOnException) {
auto raw_ptr = new int[1024]; // 记录初始分配
{
ResourceGuard guard;
EXPECT_THROW(guard.riskyOperation(), std::runtime_error);
}
// 离开作用域后,guard 应该已经释放了 data_
// 这里可以用内存检测工具验证没有泄漏
}
嗯,这里要注意。光靠单元测试很难直接检测内存泄漏,我通常配合 Valgrind 或 AddressSanitizer 来跑。但测试的意图要写清楚——它告诉后来者:「这个类在异常发生时,资源必须被正确释放。」
状态一致性测试:回滚的艺术
比资源泄漏更隐蔽的,是状态不一致。我曾经在一个交易系统里遇到过这样的 bug:账户扣款成功了,但订单创建失败了,结果钱没了,货也没到。这就是典型的强保证被打破。
测试状态一致性,核心思路是「快照对比」。操作前记录状态,操作后无论成功还是失败,状态都应该回到预期值。
class Account {
public:
void transfer(Account& to, double amount) {
if (amount <= 0) throw std::invalid_argument("金额必须为正");
if (balance_ < amount) throw std::runtime_error("余额不足");
balance_ -= amount;
to.balance_ += amount;
}
double balance() const { return balance_; }
private:
double balance_ = 0.0;
};
测试强保证的写法:
TEST(AccountTest, StrongGuaranteeOnTransferFailure) {
Account from, to;
from.deposit(100);
double from_before = from.balance();
double to_before = to.balance();
// 故意让目标账户抛出异常
// 这里假设 to.deposit() 可能抛出异常
EXPECT_THROW(from.transfer(to, 50), std::runtime_error);
// 验证状态回滚
EXPECT_EQ(from.balance(), from_before);
EXPECT_EQ(to.balance(), to_before);
}
你想想看,如果没有这个测试,哪天有人改了 transfer 的实现,把加减顺序调换了,或者加了中间状态,那后果不堪设想。
异常安全测试的常见陷阱
我总结了几条避坑指南,都是血泪教训:
- 不要只测一种异常:同一个函数可能在不同位置抛出不同类型的异常,每个分支都要覆盖。
- 注意异常中立代码:有些函数自己不抛异常,但调用的函数会抛。这种代码也要测试,确保异常能正确传播。
- 小心析构函数中的异常:析构函数里抛异常是 C++ 的大忌,会导致 terminate()。我建议析构函数里永远不要抛异常。
- 测试拷贝和移动操作:容器扩容时可能会拷贝元素,如果拷贝构造函数抛异常,容器必须保持原状。
实战:测试一个栈容器
我们来写一个完整的例子。假设我们要实现一个简单的栈,支持 push 和 pop,要求强异常安全。
template<typename T>
class SafeStack {
public:
void push(const T& value) {
// 先拷贝,再修改内部状态
auto temp = data_;
temp.push_back(value);
data_.swap(temp); // swap 不会抛异常
}
void pop() {
if (data_.empty()) throw std::runtime_error("空栈");
data_.pop_back();
}
size_t size() const { return data_.size(); }
private:
std::vector<T> data_;
};
测试代码:
TEST(SafeStackTest, PushStrongGuarantee) {
SafeStack<std::string> stack;
stack.push("hello");
// 假设 std::string 的拷贝构造函数可能抛出 bad_alloc
// 但我们的 push 实现保证了强保证
EXPECT_NO_THROW(stack.push("world"));
EXPECT_EQ(stack.size(), 2);
}
TEST(SafeStackTest, PopOnEmptyStack) {
SafeStack<int> stack;
EXPECT_THROW(stack.pop(), std::runtime_error);
// 空栈抛出异常后,栈的状态不变
EXPECT_EQ(stack.size(), 0);
}
异常安全测试的层次结构
我把异常安全测试的整个思路画成了一张图,方便你理解各个层次的关系:
从这张图你可以看到,异常安全测试是层层递进的。我建议先从基本保证开始,确保资源不泄漏,再逐步追求强保证和不抛保证。不要一上来就想做到完美,那样反而容易顾此失彼。
写在最后
异常安全测试,说白了就是给你的代码买一份「意外险」。平时看着没用,真出事的时候能救命。我个人习惯,在写任何可能抛出异常的代码时,先写测试,再写实现。这样不仅能保证代码质量,还能逼着自己想清楚异常路径。
记住一句话:测试不是为了证明代码没错,而是为了在出错时,代码还能体面地收场。
核心要点回顾:
- 异常安全三级别:基本保证、强保证、不抛保证
- 资源管理测试:RAII + 内存检测工具
- 状态一致性测试:快照对比 + 回滚验证
- 常见陷阱:析构函数不抛异常、覆盖所有异常路径
- 实战技巧:使用「会抛异常的计数器」模拟资源耗尽