
前言很多 C 程序员对异常exception的理解停留在 try包起来、catch打印一下 的层面于是写出这样的代码资源在new之后手动delete中途一旦抛出异常内存就泄漏了。问题的根源不是catch写得不够多而是没有理解异常真正做了什么——栈展开stack unwinding。理解栈展开之后你会发现 C 异常机制其实是一套控制流 资源管理的契约控制流负责跳到能处理它的地方资源管理负责跳之前把栈上所有对象析构掉。本文从运行时行为出发讲清这三件事栈展开怎么发生、异常如何传播、以及由此推导出的异常安全exception safety分级与写法。一、异常的基本模型抛出点与捕获点解耦传统错误码error code的问题在于错误必须逐层手动传递。中间层只要忘了if (ret ! OK) return ret;错误就被吞掉了。异常的模型完全不同#include iostream #include stdexcept void deep() { throw std::runtime_error(something broke); } void middle() { std::cout middle: entering\n; deep(); // 不写任何 if/return std::cout middle: never printed\n; // 不会执行 } int main() { try { middle(); } catch (const std::exception e) { // 捕获点在 main std::cout caught: e.what() \n; } }输出middle: entering caught: something broke三件事值得注意deep()抛出后middle()中deep()之后的语句被跳过middle()也没有return正常返回。middle()完全不需要知道错误的存在错误从deep()飞到了main()。捕获使用的是const std::exception——按引用捕获基类这是 C 异常的正确捕获方式。这里的跳过和飞出就是栈展开在起作用。二、栈展开stack unwinding的本质当异常对象被throw出来后运行时开始沿着调用栈往回走从抛出点所在的函数一路退到能够匹配该异常类型的catch所在函数。每退出一个函数帧stack frame该帧内的所有局部对象都会按构造的逆序被析构。这个过程由编译器生成的 unwind table异常表驱动不需要程序员写任何代码。关键结论是抛出异常时栈上已经构造完成的对象一定会被析构而裸资源raw resource不会被释放因为它不是对象。看一个对比#include iostream #include stdexcept struct Tracer { const char* name; explicit Tracer(const char* n) : name(n) { std::cout ctor name \n; } ~Tracer() { std::cout dtor name \n; } }; void f() { Tracer a(a); Tracer b(b); throw std::runtime_error(boom); } int main() { try { f(); } catch (const std::exception e) { std::cout caught\n; } }输出严格是ctor a ctor b dtor b dtor a caught注意dtor b先于dtor a——逆序析构。这就是 RAIIResource Acquisition Is Initialization资源获取即初始化能成立的全部理论基础析构函数一定会被调用所以把资源交给对象的析构函数管理就永远不会泄漏。反过来如果我们用裸指针void bad() { Tracer* p new Tracer(heap); Tracer guard(local); throw std::runtime_error(boom); // p 泄漏 delete p; // 永远到不了 }guard是对象会被析构p只是个指针变量析构它什么也不会发生new Tracer的堆内存泄漏了。这就是裸资源在异常下不安全的经典例子。三、异常传播exception propagation路径一次异常从throw到被catch经历了完整的传播链阶段行为1. 分配异常对象在实现定义的存储区通常不在栈上构造异常对象副本2. 查找处理程序沿调用栈向上寻找类型匹配的catch3. 栈展开逐帧析构局部对象直到目标帧4. 进入 catch用异常对象初始化 catch 的参数5. 销毁异常对象当最后一个 catch 结束或被 rethrow 后最终处理完时销毁几个关键细节异常对象是拷贝出来的。throw会拷贝或移动异常对象到一块独立存储区因此即使原对象已经离开作用域catch里取到的仍然是有效的。这也意味着抛出大对象是有成本的。按值抛出、按引用捕获。throw std::runtime_error(x); // ✅ 抛出一个完整的对象 catch (const std::exception e) // ✅ 引用捕获不切片不要抛指针。抛出new出来的异常对象谁来delete异常机制只负责销毁异常对象本身不会销毁你指向的堆内存。异常传播可以被中途拦截后重新抛出rethrowtry { risky(); } catch (const std::exception e) { std::cerr logging: e.what() \n; throw; // ✅ 空 throw 重新抛出原异常 // throw e; // ❌ 这会切片丢失派生类型的部分 }throw;裸 throw重新抛出当前正在处理的异常对象本体保留原始动态类型throw e;则会把e静态类型为std::exception的副本抛出去切掉派生类的信息——这是异常传播中最常见的精度损失。没有匹配的 catch 时会调用std::terminate()终止程序。异常可以跨越 C 函数边界但跨越 C 函数时行为未定义因为 C 代码不会运行栈展开。如果异常要穿过用 C 编译的第三方库回调必须在那里catch(...)兜底。noexcept 是传播的边界。标记为noexcept的函数如果抛出异常会直接std::terminate()栈展开不会发生这是刻意的性能优化——noexcept函数不必生成 unwind 信息void f() noexcept { throw std::runtime_error(x); // 程序直接 terminate不会传播 }因此在析构函数、移动构造函数、移动赋值、swap 这类必须不抛出的地方要么保证不抛要么显式noexcept声明并自己吞掉异常。四、异常安全exception safety的四个等级David Abrahams 提出的分级是理解异常安全的通用语言等级含义何时能满足不抛出保证nothrow保证不抛异常最简单的操作如 swap、移动强保证strong失败则回滚对象如同操作从未发生事务式语义需要 copy-and-swap基本保证basic失败后对象仍有效、可析构但状态可能改变大多数操作的底线无保证no guarantee失败后对象可能损坏不可接受应避免基本保证是底线即使抛异常也不能泄漏资源、不能破坏类不变式invariant。强保证则要求要么完全成功要么完全不变。实现强保证的经典手段是copy-and-swap#include vector #include utility class Buffer { std::vectorint data_; public: // 强保证先构造副本全部成功后再 swap 换入 void assign(const std::vectorint src) { std::vectorint tmp(src); // 可能抛异常但此时 *this 未改动 data_.swap(tmp); // swap 是 noexcept } // tmp 析构释放旧数据 };如果在std::vectorint tmp(src)处抛异常*this完全没动天然满足强保证只有拷贝成功后才用不可能失败的swap换入。把可能失败的操作全部前置把不可能失败的操作放在最后。代码实战一个具备强保证的资源管理类下面这个类封装一块堆内存演示 RAII copy-and-swap 如何共同提供异常安全#include cstddef #include cstring #include stdexcept #include utility class Buffer { public: Buffer() default; explicit Buffer(std::size_t n) : size_(n), data_(n ? new char[n]{} : nullptr) { if (n !data_) throw std::bad_alloc(); // 极端情形的兜底 } Buffer(const Buffer other) : size_(other.size_), data_(other.size_ ? new char[other.size_] : nullptr) { if (data_) std::memcpy(data_, other.data_, size_); // 拷贝构造中若抛异常new 的块由析构链自动回收 } Buffer operator(const Buffer other) { if (this ! other) { Buffer tmp(other); // 可能抛*this 不受影响 swap(tmp); // noexcept } return *this; } Buffer(Buffer other) noexcept : size_(other.size_), data_(other.data_) { other.size_ 0; other.data_ nullptr; } Buffer operator(Buffer other) noexcept { if (this ! other) { Buffer tmp(std::move(other)); swap(tmp); // tmp 析构时释放本对象原有内存 } return *this; } ~Buffer() { delete[] data_; } void swap(Buffer other) noexcept { std::swap(size_, other.size_); std::swap(data_, other.data_); } std::size_t size() const noexcept { return size_; } char* data() noexcept { return data_; } const char* data() const noexcept { return data_; } private: std::size_t size_ 0; char* data_ nullptr; }; // 演示即使中途抛异常也不会泄漏 void demo() { Buffer a(1024); try { Buffer b(2048); a b; // 拷贝赋值强保证 throw std::runtime_error(later failure); // Buffer 的析构会处理一切 } catch (const std::exception e) { // 此时 a、b 的内存都由析构函数回收 } }要点所有new的结果都在构造函数初始化列表里绑定到成员构造失败时已构造的成员会被自动析构构造函数的异常安全靠成员自动析构。拷贝赋值用 copy-and-swap 拿到强保证。移动操作标noexcept这样std::vectorBuffer扩容时会用移动而不是拷贝。常见坑点坑 1析构函数里抛出异常导致std::terminate。如果在栈展开过程中已经有一个异常在传播析构函数又抛出一个异常程序会立刻std::terminate()因为 C 无法同时处理两个异常。// ❌ 析构中可能抛出 ~Resource() { flush(); // flush 可能 throw close(); // close 可能 throw } // ✅ 析构中吞掉异常或提供单独的 close() 让用户显式处理 ~Resource() noexcept { try { flush(); } catch (...) { /* 记录日志 */ } try { close(); } catch (...) { } }原则析构函数在默认情况下就是noexcept的不要让它抛出。坑 2构造函数中抛异常导致资源泄漏未用 RAII。// ❌ name 分配成功、age 分配失败不这里演示的是两段裸资源 class Person { char* name_; char* addr_; public: Person(const char* n, const char* a) { name_ new char[std::strlen(n) 1]; // 若下一行抛出name_ 泄漏 addr_ new char[std::strlen(a) 1]; } ~Person() { delete[] name_; delete[] addr_; } }; // ✅ 用智能指针构造中途抛异常时已获取的资源自动释放 class Person2 { std::unique_ptrchar[] name_; std::unique_ptrchar[] addr_; public: Person2(const char* n, const char* a) : name_(new char[std::strlen(n) 1]), addr_(new char[std::strlen(a) 1]) {} // 任一失败已构造的成员被析构 };坑 3在 catch 里修改异常对象后 rethrow 丢失类型。// ❌ 切片 catch (const std::exception e) { throw e; // 抛的是 std::exception 副本派生信息丢失 } // ✅ catch (const std::exception e) { throw; // 保留原始动态类型 }坑 4用 catch(...) 吞掉所有异常后无法定位问题。catch(...)应当只用于资源清理 重新抛出或真正的边界拦截try { do_work(); } catch (...) { cleanup(); // 只做清理 throw; // 重新抛出不吞 }坑 5把异常当作普通控制流。异常的构造、传播、栈展开都很昂贵尤其是需要 unwind table 遍历。高频路径上的可预期失败比如解析到非法字符应当用返回值/std::optional异常只用于真正异常的情形。坑 6异常穿越 C 回调导致未定义行为。extern C void callback(void* ctx) { try { real_work(ctx); } catch (...) { // 必须在 C 边界兜住不能让 C 异常穿过 C 栈帧 } }总结栈展开是异常机制的骨架从抛出点退栈逐帧逆序析构直到匹配的catch。栈展开只析构对象不释放裸资源——这正是 RAII 存在的理由把资源交给对象的析构函数异常就无法泄漏它。异常传播按值抛出、按引用捕获throw;重新抛出保留类型throw e;会切片noexcept函数抛异常直接terminate。异常安全分四级底线是基本保证不泄漏、不变式不破强保证用 copy-and-swap 实现。三大铁律析构不抛异常、构造函数用 RAII 兜底、C 边界必须catch(...)。