【c++面向对象编程】第34篇:栈展开(Stack Unwinding)与异常安全级别
目录
一、栈展开(Stack Unwinding)是什么?
当一个异常被抛出时,程序会从 throw 点向上查找匹配的 catch。在这个过程中,所有局部对象会被自动析构(按构造的逆序),就像正常离开作用域一样。
cpp
#include <iostream>
#include <stdexcept>
using namespace std;
class Resource {
string name;
public:
Resource(const string& n) : name(n) {
cout << "构造: " << name << endl;
}
~Resource() {
cout << "析构: " << name << endl;
}
};
void funcC() {
Resource r3("C3");
throw runtime_error("错误发生");
Resource r4("C4"); // 永远不会构造
}
void funcB() {
Resource r2("B2");
funcC();
Resource r5("B5"); // 永远不会构造
}
void funcA() {
Resource r1("A1");
funcB();
}
int main() {
try {
funcA();
}
catch (const exception& e) {
cout << "捕获异常: " << e.what() << endl;
}
return 0;
}
输出:
text
构造: A1 构造: B2 构造: C3 析构: C3 析构: B2 析构: A1 捕获异常: 错误发生
关键观察:
-
对象按构造逆序析构(C3 → B2 → A1)
-
throw之后的代码(如C4、B5)永远不会执行 -
只有已经完成构造的对象才会被析构
二、析构函数抛异常的危险
如果在栈展开过程中,某个对象的析构函数又抛出异常,就会同时存在两个异常,C++ 会立即调用 std::terminate(),程序崩溃。
cpp
class Dangerous {
public:
~Dangerous() {
throw runtime_error("析构时异常"); // ❌ 极其危险
}
};
void risky() {
Dangerous d;
throw runtime_error("原始异常");
}
int main() {
try {
risky();
}
catch (...) {
cout << "捕获异常" << endl; // 永远不会执行
}
return 0;
}
运行结果:程序直接崩溃(terminate)。
结论:析构函数永远不应该抛出异常。如果析构函数中的操作可能失败,应该:
-
在内部
catch所有异常并记录日志 -
或者重新设计,把可能失败的操作移到普通成员函数中
cpp
class Safe {
FILE* f;
public:
~Safe() {
try {
if (f) fclose(f);
}
catch (...) {
// 记录日志,但不传播异常
}
}
};
三、异常安全的三个级别
异常安全保证描述一个函数(或操作)在抛出异常时,对程序状态的影响程度。
级别1:不抛异常保证(nothrow)
函数保证永远不会抛出异常。析构函数和 swap 函数通常应该满足这个级别。
cpp
void swap(MyClass& a, MyClass& b) noexcept {
// 只做指针交换,不会抛异常
std::swap(a.ptr, b.ptr);
}
语法:noexcept 关键字明确声明。
级别2:强保证(strong guarantee)
操作要么完全成功,要么完全失败,失败时程序状态和操作前完全相同(原子性)。
cpp
class Vector {
int* data;
size_t size, capacity;
public:
void push_back(const int& value) {
if (size >= capacity) {
// 先分配新内存(可能抛异常)
int* new_data = new int[capacity * 2];
// 复制旧数据(可能抛异常)
std::copy(data, data + size, new_data);
// 到这里都没有抛异常,才替换
delete[] data;
data = new_data;
capacity *= 2;
}
data[size++] = value;
}
};
如果 new 或 copy 抛异常,原对象保持不变——强保证。
级别3:基本保证(basic guarantee)
操作失败后,程序处于有效状态(无资源泄漏、不变量保持),但具体内容可能已改变。
cpp
void assign(const string& new_value) {
// 可能修改了状态才抛异常
// 但不会泄漏资源,对象仍可正常使用
delete[] data;
data = new char[new_value.size()]; // 如果这里抛异常,data 已变成空指针
strcpy(data, new_value.c_str());
}
上面代码只满足基本保证(不泄漏,对象仍有效),但不满足强保证(状态已改变)。
四、异常安全级别对比
| 级别 | 保证 | 例子 | 适用场景 |
|---|---|---|---|
| 不抛异常 | 绝不抛异常 | 析构函数、swap |
栈展开期间、事务提交 |
| 强保证 | 成功或回滚 | vector::push_back(有时) |
数据库操作、事务 |
| 基本保证 | 无泄漏,有效状态 | 大多数函数 | 日常代码最低要求 |
| 无保证 | 可能泄漏或无效 | 旧代码 | 应该避免 |
实践建议:
-
析构函数:不抛异常保证
-
普通函数:至少基本保证
-
关键操作:尽量提供强保证
-
不满足任何保证的代码应该重构
五、完整例子:实现强保证
cpp
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
class String {
private:
char* data;
size_t len;
// 交换函数(不抛异常)
void swap(String& other) noexcept {
std::swap(data, other.data);
std::swap(len, other.len);
}
public:
String(const char* s = "") {
len = strlen(s);
data = new char[len + 1];
strcpy(data, s);
}
// 拷贝构造(可能抛异常)
String(const String& other) : len(other.len) {
data = new char[len + 1]; // 可能抛异常
strcpy(data, other.data);
}
// 移动构造(不抛异常)
String(String&& other) noexcept : data(other.data), len(other.len) {
other.data = nullptr;
other.len = 0;
}
// 析构函数(不抛异常)
~String() {
delete[] data;
}
// 赋值操作符(强保证——使用 copy-and-swap)
String& operator=(const String& other) {
if (this != &other) {
String temp(other); // 拷贝构造(可能抛异常)
swap(temp); // 不抛异常
}
return *this;
}
// 移动赋值(不抛异常)
String& operator=(String&& other) noexcept {
if (this != &other) {
delete[] data;
data = other.data;
len = other.len;
other.data = nullptr;
other.len = 0;
}
return *this;
}
const char* c_str() const { return data ? data : ""; }
void print() const { cout << "String: " << c_str() << endl; }
};
int main() {
String s1("hello");
String s2("world");
cout << "赋值前: ";
s1.print();
s2.print();
s1 = s2; // 强保证:如果拷贝失败,s1 不受影响
cout << "赋值后: ";
s1.print();
s2.print();
return 0;
}
关键技巧 copy-and-swap:
-
先创建临时副本(可能失败,但原对象不变)
-
临时副本成功创建后,用
swap交换(不抛异常) -
临时副本销毁时释放原资源
这样赋值操作就满足了强异常安全保证。
六、栈展开的实际影响
1. 智能指针在栈展开中自动释放资源
cpp
void func() {
unique_ptr<int> p(new int(42));
throw runtime_error("error");
// p 会被自动析构,内存释放
}
2. 构造函数中抛异常,已构造的成员会被析构
cpp
class Member {
public:
Member() { cout << "Member 构造" << endl; }
~Member() { cout << "Member 析构" << endl; }
};
class Container {
Member m1;
Member m2;
public:
Container() : m1(), m2() {
throw runtime_error("构造失败");
}
};
int main() {
try {
Container c;
}
catch (...) {
cout << "捕获异常" << endl;
}
}
输出:
text
Member 构造 Member 构造 Member 析构 Member 析构 捕获异常
已构造的 m1 和 m2 被正确析构。
七、常见错误
1. 析构函数中抛异常
cpp
~Resource() {
if (close() == -1) {
throw runtime_error("close failed"); // ❌ 程序可能崩溃
}
}
2. 认为基本保证足够所有场景
某些场景(如数据库事务、文件操作)必须要求强保证。
3. 忘记处理异常导致资源泄漏
cpp
void bad() {
int* p = new int[1000];
// 如果这里抛异常,p 不会 delete
delete[] p;
}
用智能指针或 RAII 类解决。
八、这一篇的收获
你现在应该理解:
-
栈展开:异常抛出后,从
throw到catch之间的局部对象按构造逆序自动析构 -
析构函数不能抛异常:栈展开期间再次抛异常会导致
terminate -
异常安全三级:
-
不抛异常:析构函数、
swap -
强保证:成功或回滚(如 copy-and-swap)
-
基本保证:无泄漏,状态有效(最低要求)
-
-
copy-and-swap 技巧:实现赋值操作的强保证
💡 小作业:写一个
Transaction类,在构造函数中开始事务,析构函数中如果未提交则自动回滚(不抛异常)。提供commit()方法(可能失败抛异常)。写代码演示:正常提交、异常回滚、栈展开时的自动回滚。
下一篇预告:第35篇《构造函数与异常:如何避免资源泄露?》——构造函数抛异常时,已完成构造的成员对象会被析构,但动态分配的资源(用裸指针)不会自动释放。如何用智能指针或 try-catch 避免泄露?下篇详解。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)