C++ 异常、RAII 与智能指针
摘要
本文完整梳理 C++ 异常处理与资源管理体系:从「C 语言错误码(errno 查表)vs C++ 异常(类对象携带信息)」的本质区别切入,讲透异常的抛出与捕获规则(类型匹配、离抛出位置最近优先、throw 后调用链提早退出且沿途对象按逆序销毁);接着沿调用链逐层向外查找 catch 的过程------栈展开 ------以及到 main 仍未匹配则
terminate终止程序;异常匹配允许的三种放松转换(非常量→常量、数组/函数→指针、派生→基类)与main末尾catch(...)兜底惯例;进而引出异常安全问题 :异常跳过后续代码导致资源泄漏的「捕获-释放-重抛」修法(throw e;vsthrow;)、析构函数里释放到一半抛异常的问题(《Effective C++》第 8 条:别让异常逃离析构函数),并指出 RAII 才是根本解法。第二、三部分落到 RAII 思想 (Resource Acquisition Is Initialization,用对象生命周期管理动态资源)与标准库四件智能指针:auto_ptr(拷贝转移所有权、悬空,已禁用)、unique_ptr(独占、只可移动)、shared_ptr(引用计数共享)、weak_ptr(不参与计数,破shared_ptr循环引用);最后补齐删除器(非 new 资源 /malloc资源)、T[]特化、make_shared、operator bool与explicit构造、noexcept(C++98throw()的简化,声明了却真抛异常会直接 terminate)与noexcept(expr)运算符。结尾附 15 道主动回忆清单与 10 条易错点。
一、异常的概念及使用
1.1 异常的概念
异常处理机制允许程序中独立开发的部分就运行时就出现的问题进行通信,并做出相应的处理。
- 异常把「检测问题 」与「解决问题」分开:程序的一部分负责检测,另一部分负责处理;检测环节无须知道处理模块的所有细节。
- C 语言主要通过错误码 (
errno)处理错误:错误码本质是对错误信息分类编号,拿到后还要去查表才知道发生了什么,比较麻烦。 - 异常抛出一个类对象,这个对象可以携带更全面的各种信息(错误类型 + 描述 + 上下文)。
cpp
// C 语言风格:错误码
int read_data();
if (read_data() == -1) {
switch (errno) { // 错误码是分类编号,还得查表
case EIO: ...
case ENOENT: ...
}
}
// C++ 风格:异常(类对象携带更多信息)
try {
read_data();
} catch (const std::ios::failure& e) {
// 可以拿到完整的错误信息
}
1.2 异常的抛出和捕获
程序出现问题时,通过抛出(throw)一个类对象 引发异常;该对象的类型 以及当前的调用链 ,决定由哪个 catch 来处理。
- 被选中的处理代码,是调用链中与对象类型匹配、且离抛出位置最近的那一个。
- 抛出部分通过对象的类型和内容,告知处理部分到底发生了什么错误。
throw执行时,throw 后面的语句不再执行;程序从 throw 位置跳到与之匹配的catch(可能是同一函数内的局部 catch,也可能是调用链中另一个函数的 catch),控制权从 throw 位置转移到 catch 位置。
两个重要含义:
- 沿着调用链的函数可能提早退出;
- 一旦开始执行异常处理程序,沿着调用链创建的对象都将销毁(析构函数按逆序调用)。
异常对象的拷贝规则:
- 抛出异常对象后,会生成一个异常对象的拷贝(因为抛出的对象可能是局部对象);
- 这个拷贝会在
catch子句结束后销毁 (处理类似函数的传值返回)。
实践约定:
- 抛类对象,不要抛基类指针(抛指针没有好处,还会带来对象切割问题)。
1.3 栈展开(Stack Unwinding)
抛出异常后,程序暂停当前函数的执行,开始寻找与之匹配的 catch 子句:
- 先检查 throw 本身是否在一个
try块内部;如果在,则查找匹配的catch,有匹配就跳到catch处处理。 - 当前函数没有
try/catch,或有但类型不匹配 → 退出当前函数,继续在外层调用链 中查找。这个逐层向外查找的过程就叫栈展开。 - 如果到达
main函数仍未找到匹配的catch,程序调用标准库的terminate终止。 - 找到匹配的
catch并处理完后,catch子句之后的代码继续执行。
栈展开示意(调用链上的对象按逆序销毁):
func3() → 抛出异常
func2() → 提早退出,局部对象销毁
func1() → 提早退出,局部对象销毁
main() → 最终到达匹配的 catch
1.4 查找匹配的处理代码
- 一般情况下,抛出对象和
catch是类型完全匹配 的;有多个类型匹配时,选择离抛出位置更近的那个。 - 但有几种「放松」的例外转换:
- 允许非常量 → 常量的类型转换(权缩);
- 允许数组 → 指向数组元素类型的指针 、函数 → 指向函数的指针;
- 允许派生类 → 基类类型的转换------这一点非常实用,实际中的异常类设计基本都是用继承体系(继承体系设计),抛派生类、接基类。
cpp
// 实际中的异常类设计:继承 std::exception 体系
try {
... // 可能抛出 FileException(派生自 std::exception)
} catch (std::exception& e) {
// 派生 → 基类,能被接住
}
1.5 实践中的用法
如果到 main 函数异常仍没被匹配,就会终止程序。不是发生严重错误的情况下,我们不期望程序终止,所以一般 main 函数最后都会用 catch(...) 兜底:
catch(...)可以捕获任意类型的异常;- 但缺点是不知道异常具体是什么错误 (
...处理器拿不到类型信息)。
cpp
int main() {
try {
// 业务逻辑
} catch (const std::exception& e) {
std::cout << e.what() << std::endl;
} catch (...) { // 兜底:接住所有未知类型的异常
std::cout << "未知异常" << std::endl;
}
}
1.6 异常安全问题
异常抛出后,后面的代码不再执行;前面申请了资源(内存、锁、文件句柄等)、本应后面释放,但中间抛异常就会跳过释放代码 ,引发资源泄漏。
常见修法:
- 中间捕获异常 → 释放资源 → 再重新抛出 :
throw e;:重新抛出异常对象的一份拷贝(会再次走拷贝构造);throw;(在catch块内):原样重抛当前异常,保留原始类型和内容,一般优先用这种。
- 更好的方式:用下一章的 RAII 思想从根本上解决,不再手工「捕获-释放-重抛」。
析构函数中的注意点:
- 析构函数里如果也要释放资源(比如要释放 10 个,释放到第 5 个时抛出异常),后面的 5 个就不会释放了,同样是资源泄漏;
- 《Effective C++》第 8 个条款专门讲了这个问题:别让异常逃离析构函数。
与其他语言对比:
| 语言 | 资源释放机制 |
|---|---|
| Java | 垃圾回收器 GC,自动回收内存 |
| C++ | 没有 GC,靠手动管理,或交给智能指针(RAII) |
1.7 异常规范
对用户和编译器而言,预先知道某个函数会不会抛出异常大有裨益,有助于简化调用方代码。
C++98 方式:
throw()接在参数列表后面:表示函数不抛异常;throw(类型1, 类型2, ...):表示可能抛出多种类型的异常(类型用逗号分隔)。- 这种方式过于复杂,实践中并不好用。
C++11 简化:
- 参数列表后加
noexcept:表示不会抛出异常; - 啥都不加:表示可能会抛出异常。
运行时行为:
- 编译器不会在编译时检查
noexcept:一个noexcept函数里包含throw、或调用了可能抛异常的函数,照样能编译通过(部分编译器会报个警告); - 但声明了
noexcept的函数在运行时真的抛出了异常,程序会直接调用terminate终止。
noexcept(expr) 还能作为运算符,编译期检测一个表达式是否会抛出异常:
- 可能会抛出 → 返回
true; - 不会抛出 → 返回
false。
cpp
void f() noexcept; // 声明不抛异常
int a[10];
constexpr bool b = noexcept(a[10]); // true:越界访问是运行时异常源
二、RAII 和智能指针的设计思路
RAII = R esource A cquisition I s Initialization,一种管理资源的类的设计思想。
本质:利用对象的生命周期来管理获取到的动态资源,避免资源泄漏。
- 「资源」可以是内存、文件指针、网络连接、互斥锁等;
- 流程:获取资源 → 把资源委托给一个对象 → 资源在对象生命周期内始终有效 → 对象析构时释放资源。
智能指针在 RAII 之上再加一层:
- 除了满足 RAII,还要方便资源的访问;
- 所以像迭代器类一样,重载
operator*/operator->/operator[]等运算符,用起来和裸指针一样。
cpp
// RAII 骨架:
template <typename T>
class RAII {
public:
explicit RAII(T* p) : _p(p) {}
~RAII() { delete _p; } // 析构即释放
T& operator*() const { return *_p; }
T* operator->() const { return _p; }
private:
T* _p;
};
三、C++ 标准库智能指针的使用
标准库好几种智能指针,除了 weak_ptr,都符合「RAII + 像指针一样访问」的行为 ;原理上的差异主要是解决智能指针拷贝时的思路不同。
| 类型 | 年代 | 拷贝 | 移动 | 定位 |
|---|---|---|---|---|
auto_ptr |
C++98 | ✖(拷贝时转移所有权) | ✔ | 强烈建议不要使用 |
unique_ptr |
C++11 | ✖ | ✔ | 独占所有权 |
shared_ptr |
C++11 | ✔ | ✔ | 共享所有权(引用计数) |
weak_ptr |
C++11 | ✔ | ✔ | 观察 shared_ptr,解决循环引用 |
3.1 auto_ptr(C++98,已废弃)
- 拷贝时把被拷贝对象资源的管理权转移给拷贝对象;
- 非常糟糕的设计:原对象变成悬空,访问即报错;
- C++11 之后强烈建议不要使用;很多公司在 C++11 出来之前也是明令禁止使用的。
3.2 unique_ptr
- C++11 设计,名字翻译过来是「唯一指针」;
- 不支持拷贝,只支持移动;
- 不需要拷贝的场景强烈建议用它(无引用计数开销,独占语义最清晰)。
cpp
std::unique_ptr<int> up1(new int(1));
std::unique_ptr<int> up2 = std::move(up1); // up1 变空,所有权转移
int* p = up2.get();
3.3 shared_ptr
- C++11 设计,名字翻译过来是「共享指针」;
- 支持拷贝,也支持移动;需要拷贝共享资源的场景用它;
- 底层用引用计数 实现:拷贝计数 +1,销毁计数 -1,计数归零才真正
delete。
cpp
std::shared_ptr<int> sp1(new int(42));
std::shared_ptr<int> sp2 = sp1; // sp1、sp2 共享,use_count 都为 2
int v = *sp1;
3.4 weak_ptr
- C++11 设计,名字翻译过来是「弱指针」;
- 完全不同于上面的智能指针:不支持 RAII,不能直接管理资源(它不是资源的主人);
- 产生的本质是要解决
shared_ptr循环引用导致内存泄漏的问题。
循环引用问题(两个 shared_ptr 互相持有):
cpp
struct Wife {
std::shared_ptr<Hubby> hubby;
};
struct Hubby {
std::shared_ptr<Wife> wife;
};
// A 引用 B、B 引用 A,引用计数永远不归 0,两个都泄漏
修复:把其中一方降级成 weak_ptr(不参与计数):
cpp
struct Wife {
std::shared_ptr<Hubby> hubby;
};
struct Hubby {
std::weak_ptr<Wife> wife; // 不增加引用计数
// 需要时用 auto sp = wife.lock(); 检查是否还活着
};
3.5 析构行为与自定义删除器
- 智能指针析构时默认执行
delete释放资源:- 所以不是
new出来的资源 交给智能指针管理,析构时就会崩溃;
- 所以不是
- 智能指针支持在构造时给一个删除器 (deleter):
- 删除器本质是一个可调用对象(函数指针、lambda、仿函数);
- 在构造时传入定制删除器,析构时就会调用删除器去释放资源。
cpp
int* q = (int*)malloc(sizeof(int) * 10);
std::shared_ptr<int> sp(q, [](int* p) { free(p); }); // 自定义删除器
- 因为
new[]经常使用,为了简洁,unique_ptr和shared_ptr都特化了一份T[]的版本:
cpp
std::unique_ptr<Date[]> up1(new Date[5]);
std::shared_ptr<Date[]> sp1(new Date[5]); // 析构时用 delete[]
3.6 make_shared
shared_ptr 除了支持用指向资源的指针构造,还支持用 make_shared 以初始化资源对象的值直接构造:
cpp
template <class T, class... Args>
std::shared_ptr<T> make_shared(Args&&... args);
std::shared_ptr<Date> sp = std::make_shared<Date>(2026, 10, 28);
3.7 布尔判断与 explicit 构造
shared_ptr和unique_ptr都支持operator bool类型转换:- 空对象(没有管理资源)→ 返回
false; - 否则 → 返回
true; - 意味着可以直接把智能指针对象丢进
if判断是否为空 :if (sp) { ... }
- 空对象(没有管理资源)→ 返回
shared_ptr和unique_ptr的构造函数都用explicit修饰,防止普通指针隐式转换成智能指针对象:
cpp
void f(std::shared_ptr<int> p);
int* raw = new int(1);
f(raw); // 编译错误:禁止隐式转换
f(std::shared_ptr(raw)); // 显式构造,OK
f(std::make_shared<int>(1));
四、主动回忆清单
- C 语言错误码(
errno)和 C++ 异常类对象有什么本质区别? throw执行后,调用链上会发生哪两件事?- 什么是栈展开?到
main仍找不到匹配的catch会怎样? catch匹配规则允许哪几种「放松」转换(非常量→常量、数组→指针、派生→基类)?catch(...)能做什么、做不到什么?为什么main要用它兜底?- 「异常导致资源泄漏」怎么手工修?比它更好的方案是什么?
- RAII 全称的三个关键字是什么?核心思想一句话?
auto_ptr/unique_ptr/shared_ptr/weak_ptr的拷贝、移动、所有权语义各有什么特点?为什么auto_ptr被禁用?- 两个
shared_ptr互相指向为什么会泄漏?weak_ptr怎么解决? - 为什么智能指针默认的
delete对非new资源会崩溃?删除器是什么、怎么用? noexcept和 C++98 的throw()有什么区别?noexcept函数真的抛出异常会怎样?noexcept(expr)作为运算符返回什么?operator bool对智能指针意味着什么?- 为什么智能指针要重载
operator*/->/[]? - 智能指针的两种构造形态(裸指针构造 /
make_shared直接构造)? - 为什么智能指针的构造函数要
explicit?
五、易错点汇总
| 易错点 | 后果 |
|---|---|
| 抛基类指针 / 基本类型而不是类对象 | 信息丢失、对象切割,难以处理 |
| 资源申请后未「捕获-释放-重抛」 | 异常使后续释放代码被跳过,资源泄漏 |
| 析构函数里抛出异常 | 剩余资源不释放;程序直接终止(别让异常逃离析构函数) |
使用 auto_ptr 并拷贝它 |
原对象悬空,访问报错 |
非 new 资源交给智能指针管理却不给删除器 |
析构默认 delete,崩溃 |
两个 shared_ptr 循环引用 |
引用计数永远不归 0,内存泄漏 |
用 weak_ptr 直接管理资源 |
它不是资源主人,不支持 RAII |
noexcept 函数中真的抛出异常 |
程序直接调用 terminate 终止 |
用普通 T 版智能指针管理 new[] |
用 delete 释放数组,非法行为;要用 T[] 特化 |
| 靠隐式转换把裸指针当智能指针传参 | 编译错误;必须显式构造或 make_shared |
任何的批评和建议欢迎交流,我们共同进步!