
观众老爷们大家好 我是邪修KING 本文属于系列C++ 进阶篇 ,欢迎来到C++进阶篇博客 C++重点语法运用! 本文是 C++ 进阶系统教程系列终章。前面我们已经系统学习了 STL 容器底层实现、哈希表原理、C++11 核心新特性(移动语义、Lambda、可变参数模板、包装器等),今天我们拿下现代 C++ 最后两块核心拼图:异常处理机制与智能指针。
异常是 C++ 官方推荐的错误处理方案,彻底替代了 C 语言繁琐易错的错误码模式;智能指针则是 RAII 思想的集大成者,从机制上解决了内存泄漏难题,是每一个 C++ 开发者必须掌握的核心技能,更是校招与社招面试的「必考题」。
本文将从「痛点引入 → 语法原理 → 代码实践 → 常见坑点 → 面试考点」五个维度,把异常与智能指针讲透,作为 C++ 进阶阶段的收官之作。\
第一部分:C++ 异常机制 ------ 更优雅的错误处理方式
1.1问题: 为什么需要异常?------ C 语言错误码的痛点
在 C 语言中,我们处理错误的主流方式是错误码:函数通过返回值标识错误,比如返回-1、返回NULL、设置全局errno变量。调用者拿到返回值后,需要查表才能知道具体是什么错误。
这种方式有三个非常明显的痛点:
信息表达能力弱 :一个整数错误码能承载的信息有限,想附带错误描述、错误位置等信息非常麻烦。
调用链传递繁琐 :如果函数调用层级很深,底层发生的错误需要层层向上传递,每一层都要判断返回值、透传错误码,代码冗余且容易遗漏。
错误处理与正常逻辑混杂 :返回值既要承载正常结果,又要承载错误信息,边界模糊,可读性差。
C++ 引入的异常机制,从设计上分离了「错误检测」和「错误处理」:
底层检测到错误时,直接throw抛出一个携带完整信息的异常对象
异常沿着函数调用链向上传播,直到找到匹配的catch处理模块
中间层函数不需要关心错误处理,专注业务逻辑即可
💡 类比理解:就像公司里基层员工遇到解决不了的问题,不用自己硬扛,也不用挨个部门去问,直接把问题上报(throw),系统会沿着管理层级(调用链)自动找到能负责的领导(catch)来处理。
1.2 异常基本语法:throw /try/catch
异常由三个关键字构成完整闭环:
throw:抛出异常,后面跟一个异常对象(可以是任意类型,int、字符串、自定义类都可以)
try:包裹可能抛出异常的代码块
catch:捕获并处理指定类型的异常
cpp
#include <iostream>
#include <string>
using namespace std;
// 错误检测:检测到除零错误,抛出异常
double Divide(int a, int b) {
if (b == 0) {
// 抛出一个字符串异常对象,携带错误信息
throw "Divide by zero condition!";
}
return (double)a / (double)b;
}
int main() {
int len, time;
cin >> len >> time;
try {
// 可能抛出异常的代码,放在try块中
cout << Divide(len, time) << endl;
}
catch (const char* errmsg) {
// 捕获到对应类型的异常,进行处理
cout << "捕获到异常:" << errmsg << endl;
}
cout << "程序继续执行..." << endl;
return 0;
}
两个核心注意点:
throw执行后,其后的代码将不再执行,程序控制权直接跳转到匹配的catch块。
抛出的异常对象会生成一份拷贝(类似传值返回),因为抛出的可能是局部对象,拷贝后的异常对象会在 catch 处理完后销毁。
1.3 核心机制:栈展开(Stack Unwinding)
栈展开是异常机制的底层核心,也是面试高频考点。
抛出异常后,程序会按照以下规则查找匹配的 catch:
首先检查throw是否在当前函数的try块内部,如果是,查找同层匹配的catch,找到就跳转处理。
如果当前函数没有匹配的 catch,当前函数栈帧销毁,所有局部对象自动析构,然后回到上层调用函数,重复第一步的查找。
这个逐层退出栈帧、查找 catch 的过程,就叫做栈展开。
如果一直找到main函数都没有匹配的 catch,程序会调用标准库的terminate()函数,直接终止运行。
📌 【面试高频考点】请简述异常的栈展开过程。

答:异常抛出后,从抛出位置开始,沿着函数调用链自底向上查找匹配的 catch 子句;每退出一层函数栈帧,都会先销毁该层的所有局部对象(保证析构函数执行);如果到 main 函数仍未找到匹配的 catch,则调用 terminate 终止程序。
1.4 异常类型的匹配规则
异常对象类型和 catch 参数类型并非必须完全一致,允许以下几种隐式转换 :
非常量 → 常量:抛出非 const 对象,可以被 const 引用的 catch 捕获(权限缩小,合法)。
数组 → 指向数组元素的指针、函数 → 函数指针。
派生类 → 基类:这是工程实践中最重要的匹配规则,也是标准异常体系的设计基础。
💡 最佳实践:通常我们会设计一个异常基类,各个模块的异常都继承它,捕获时只需要捕获基类引用,就能处理所有派生类异常,利用多态统一处理。
最后,catch(...) 可以捕获任意类型的异常,作为兜底防止程序崩溃,但缺点是无法知道异常的具体类型和信息。
cpp
int main() {
try {
// 业务代码
}
catch (const Exception& e) { // 捕获自定义异常基类
cout << e.what() << endl;
}
catch (...) { // 兜底:捕获所有未知异常
cout << "未知异常" << endl;
}
return 0;
}
1.5 异常的重新抛出
有些场景下,当前层 catch 只能做部分处理(比如释放本层资源),不能完全解决问题,需要把异常继续向上层传递,这就需要异常重新抛出。
语法非常简单:在 catch 块中直接写throw;(不带任何参数),就会把当前捕获的异常原封不动地重新抛出。
cpp
// 模拟消息发送:网络错误可以重试,其他错误直接上报
void _SendMsg(const string& s) {
if (rand() % 2 == 0) {
throw HttpException("网络不稳定,发送失败", 102, "put");
} else if (rand() % 7 == 0) {
throw HttpException("非好友关系,发送失败", 103, "put");
}
cout << "发送成功" << endl;
}
void SendMsg(const string& s) {
for (size_t i = 0; i < 4; i++) {
try {
_SendMsg(s);
break; // 发送成功,跳出循环
}
catch (const Exception& e) {
if (e.getid() == 102) { // 102是网络错误,可以重试
if (i == 3) { // 重试3次都失败,再抛出
throw; // 重新抛出异常
}
cout << "第" << i + 1 << "次重试" << endl;
} else {
throw; // 不是网络错误,直接重新抛出
}
}
}
}
1.6 异常安全问题(重点 + 难点)
什么是异常安全?
异常抛出后,程序能保证:
不发生资源泄漏(内存、锁、文件句柄等)
对象状态保持一致,不出现半损坏的状态
这就是异常安全。最典型的异常安全问题就是异常导致的内存泄漏:
cpp
void Func() {
int* array = new int[10]; // 申请资源
int len, time;
cin >> len >> time;
// 如果Divide抛出异常,后面的delete永远不会执行 → 内存泄漏
cout << Divide(len, time) << endl;
delete[] array; // 释放资源
}
更好的解法:RAII
这正是智能指针登场的舞台。用 RAII 思想封装资源,对象出作用域自动析构释放资源,不管是正常返回还是异常退出,都能保证资源一定释放,从机制上杜绝泄漏。
⚠️ 另一个重要注意点:析构函数尽量不要抛出异常。
如果析构函数抛出异常,在栈展开过程中,可能会同时有两个异常在传播,导致程序直接终止。《Effective C++》条款 08 明确指出:「别让异常逃离析构函数」。如果析构中可能出错,应该内部捕获处理,不能向外抛出。
📌 【面试高频考点】什么是异常安全?如何保证异常安全?
答:异常安全指程序抛出异常后,不会发生资源泄漏,对象状态保持有效。保证异常安全的核心手段是 RAII,用对象生命周期管理资源,利用析构函数自动释放,避免异常导致资源泄漏。
1.7 异常规范
为了让调用者知道一个函数会不会抛异常、会抛什么类型的异常,C++ 提供了异常声明规范。
C++98 的异常规范(已废弃)
cpp
// 表示只会抛出bad_alloc一种异常
void* operator new (size_t size) throw (bad_alloc);
// 表示不会抛出任何异常
void* operator delete (void* ptr) throw();
C++11 的 noexcept
C++11 简化为noexcept关键字,声明一个函数不会抛出异常。
cpp
// 声明size()函数不会抛出异常
size_t size() const noexcept;
⚠️ 重要细节:
编译器不会强制编译期检查 noexcept 函数是否真的不抛异常(最多给警告)。但如果 noexcept 函数真的抛出了异常,程序会直接调用terminate终止。
noexcept可以作为运算符,编译期判断一个表达式是否会抛出异常,返回 bool 值。
📌 【面试高频考点】移动构造函数为什么建议加 noexcept?
答:STL 容器(如 vector)扩容时,如果元素的移动构造是 noexcept 的,就会调用移动构造转移资源,效率更高;如果移动构造没有 noexcept,为了保证异常安全,容器会保守地调用拷贝构造。因此自定义类的移动构造建议加上 noexcept,配合 STL 发挥最佳性能。
1.8 标准异常体系 & 自定义异常最佳实践
C++ 标准库提供了一套完整的异常继承体系,基类是std::exception,其中定义了虚函数what(),返回异常描述字符串。
常用派生类:
bad_alloc:new 申请内存失败时抛出
bad_cast:dynamic_cast 转换失败时抛出
out_of_range:下标越界时抛出(如 vector::at)
runtime_error:运行时错误基类
logic_error:逻辑错误基类
工程级自定义异常设计
真实项目中,我们通常继承exception,按模块设计派生异常类,每个模块可以携带自己的专属错误信息。
cpp
#include <iostream>
#include <string>
#include <exception>
using namespace std;
// 异常基类
class Exception : public exception {
public:
Exception(const string& errmsg, int id)
: _errmsg(errmsg), _id(id)
{}
virtual string what() const {
return _errmsg;
}
int getid() const {
return _id;
}
protected:
string _errmsg;
int _id;
};
// SQL模块异常
class SqlException : public Exception {
public:
SqlException(const string& errmsg, int id, const string& sql)
: Exception(errmsg, id), _sql(sql)
{}
virtual string what() const override {
string str = "SqlException:";
str += _errmsg;
str += " -> SQL: ";
str += _sql;
return str;
}
private:
const string _sql;
};
// 缓存模块异常
class CacheException : public Exception {
public:
CacheException(const string& errmsg, int id)
: Exception(errmsg, id)
{}
virtual string what() const override {
return "CacheException:" + _errmsg;
}
};
// HTTP模块异常
class HttpException : public Exception {
public:
HttpException(const string& errmsg, int id, const string& type)
: Exception(errmsg, id), _type(type)
{}
virtual string what() const override {
return "HttpException[" + _type + "]:" + _errmsg;
}
private:
const string _type;
};
// 模拟多模块调用
void SQLMgr() {
if (rand() % 7 == 0) {
throw SqlException("权限不足", 100, "select * from user where name='张三'");
}
cout << "SQL模块调用成功" << endl;
}
void CacheMgr() {
if (rand() % 5 == 0) {
throw CacheException("数据不存在", 101);
}
cout << "缓存模块调用成功" << endl;
SQLMgr();
}
void HttpServer() {
if (rand() % 3 == 0) {
throw HttpException("请求资源不存在", 102, "GET");
}
cout << "HTTP服务调用成功" << endl;
CacheMgr();
}
int main() {
srand((unsigned)time(0));
while (true) {
this_thread::sleep_for(chrono::seconds(1));
try {
HttpServer();
}
catch (const Exception& e) { // 捕获基类,多态处理所有异常
cout << e.what() << endl;
}
catch (...) { // 兜底未知异常
cout << "未知异常" << endl;
}
}
return 0;
}
这种设计的优势非常明显:新增模块只需要新增异常派生类,上层捕获逻辑完全不用改,完美符合开闭原则。
第二部分:智能指针 ------ RAII 思想的巅峰实现,彻底告别内存泄漏
承接上一部分的异常安全问题:手动管理堆内存,在异常场景下极易泄漏,代码还写得臃肿。有没有一种机制,能让堆内存也像栈对象一样,出作用域自动释放?
有,这就是RAII思想,而智能指针就是 RAII 在堆内存管理上的经典实现。
2.1 先搞懂灵魂思想:RAII
RAII 是 Resource Acquisition Is Initialization 的缩写,翻译为「资源获取即初始化」。
核心思想非常朴素:
用对象的生命周期来绑定资源的生命周期。
对象构造时,完成资源的申请与初始化
对象析构时,自动完成资源的清理与释放
只要对象能正常析构,资源就一定能释放,不管是正常返回还是异常退出
💡 类比理解:
住酒店时,你办理入住(构造)拿到房间使用权,到时间退房(析构)酒店自动收回房间。你不用时时刻刻记着「我要退房」,到了时间酒店自动处理。哪怕你中途有事提前走,只要办理了退房(对象析构),房间也会被收回。
RAII 不是什么复杂语法,就是一种利用构造析构管理资源的设计思想。我们之前接触过很多例子:
lock_guard 管理互斥锁:构造加锁,析构解锁
ifstream 管理文件:构造打开文件,析构关闭文件
智能指针管理堆内存:构造接管内存,析构释放内存
智能指针在 RAII 的基础上,还重载了*、->、\[\]等运算符,让它可以像原生指针一样访问资源,兼顾安全与易用。
2.2 C++ 智能指针总览
所有智能指针都在头文件中,C++ 标准一共推出过四种:
| 智能指针 | 出现版本 | 核心特点 | 拷贝策略 | 推荐程度 |
|---|---|---|---|---|
| auto_ptr | C++98 | 管理权转移 | 拷贝时转移所有权,原对象悬空 | 完全不推荐 |
| unique_ptr | C++11 | 独占所有权 | 禁止拷贝,只支持移动 | 大部分场景用它 |
| shared_ptr | C++11 | 共享所有权 | 引用计数,支持拷贝 | 需要共享资源时用 |
| weak_ptr | C++11 | 弱引用,不管理资源 | 辅助 shared_ptr,不增加计数 | 解决循环引用 |
| 📌 【面试高频考点】C++ 有哪几种智能指针?各自有什么特点? |
2.3 被淘汰的前辈:auto_ptr(了解踩坑点)
auto_ptr是 C++98 最早的智能指针,设计思路非常简单粗暴:拷贝时把资源管理权转移给新对象,原对象置空。
cpp
#include <memory>
int main() {
auto_ptr<int> ap1(new int(10));
auto_ptr<int> ap2(ap1); // 拷贝,管理权转移
*ap2 = 20; // 正常
// *ap1 = 30; // 崩溃!ap1已经悬空,变成空指针了
return 0;
}
这是一个非常反直觉的失败设计:拷贝操作居然会修改原对象,非常容易写出悬空访问的 bug。因此 C++11 推出后,auto_ptr被彻底废弃,项目中绝对不要使用。
📌 【面试考点】为什么 auto_ptr 不推荐使用?
答:auto_ptr 采用管理权转移的拷贝策略,拷贝后原对象会变成空指针,非常容易造成悬空访问,语义反直觉,存在严重安全隐患。
2.4 简单高效的唯一指针:unique_ptr
设计思想
unique_ptr 秉承「独占所有权」的理念:一份资源只能由一个 unique_ptr 管理,禁止拷贝,只支持移动转移。
它的实现非常简单:直接把拷贝构造和拷贝赋值用=delete禁用,只提供移动构造和移动赋值。没有引用计数的开销,性能和原生指针几乎一致。
💡 类比理解:独家代理权,一个区域只能有一个代理商;代理权可以转让,但不能同时有两家。
cpp
#include <iostream>
#include <memory>
using namespace std;
int main() {
// 1. 构造:用new出来的指针初始化
unique_ptr<int> up1(new int(10));
// unique_ptr<int> up2 = new int(20); // 错误:构造函数是explicit,禁止隐式转换
// 2. 像指针一样使用
*up1 = 20;
cout << *up1 << endl;
// 3. 禁止拷贝
// unique_ptr<int> up2(up1); // 编译报错:拷贝构造被删除
// 4. 支持移动:转移所有权
unique_ptr<int> up3(move(up1));
// 移动后up1悬空,不要再访问
// 5. 判空:operator bool
if (up1) {
cout << "up1不为空" << endl;
} else {
cout << "up1已悬空" << endl;
}
// 6. 数组版本:自动调用delete[]释放
unique_ptr<int[]> upArr(new int[10]);
upArr[0] = 1;
upArr[1] = 2;
return 0;
} // up3、upArr出作用域,自动调用析构释放内存
定制删除器
默认情况下,智能指针析构时用delete释放资源。如果管理的是new\[\]的数组、文件指针、socket、互斥锁等其他资源,就需要定制删除器。
unique_ptr的删除器是模板参数,需要在模板类型中指定删除器类型:
cpp
// 仿函数删除器:释放数组
template<class T>
struct DeleteArray {
void operator()(T* ptr) {
delete[] ptr;
}
};
// 仿函数删除器:关闭文件
struct Fclose {
void operator()(FILE* ptr) {
cout << "关闭文件" << endl;
fclose(ptr);
}
};
int main() {
// 1. 仿函数作为删除器
unique_ptr<int, DeleteArray<int>> up2(new int[10]);
// 2. Lambda作为删除器
auto del = [](FILE* ptr) { fclose(ptr); };
unique_ptr<FILE, decltype(del)> upFile(fopen("test.txt", "w"), del);
// 3. 管理文件指针,出作用域自动关闭
unique_ptr<FILE, Fclose> up3(fopen("test.txt", "r"));
return 0;
}
📌 【面试考点】unique_ptr 如何实现禁止拷贝?
答:使用 C++11 的=delete语法,显式删除拷贝构造函数和拷贝赋值运算符,只提供移动构造和移动赋值。
2.5 支持共享的引用计数指针:shared_ptr(重中之重)
设计思想
很多场景下,一份资源需要被多个指针同时管理,unique_ptr 的独占语义就不够用了。shared_ptr采用引用计数机制,支持多个 shared_ptr 共享同一份资源。
原理:
每新增一个指向资源的 shared_ptr,引用计数 +1
每销毁一个指向资源的 shared_ptr,引用计数 -1
当引用计数减到 0 时,说明是最后一个管理者,释放资源
💡 类比理解:合租公寓,入住时登记人数 + 1,退房时登记人数 - 1,最后一个走的人负责关灯锁门、交还钥匙。
⚠️ 非常重要的细节:引用计数必须在堆上动态开辟,不能是类的静态成员。因为每一份资源对应一个独立的计数,静态成员是所有对象共享的,无法区分不同资源。
基本用法
cpp
#include <iostream>
#include <memory>
using namespace std;
struct Date {
int _year;
int _month;
int _day;
Date(int y = 1, int m = 1, int d = 1) : _year(y), _month(m), _day(d) {}
~Date() { cout << "~Date()" << endl; }
};
int main() {
// 1. 普通构造
shared_ptr<Date> sp1(new Date(2024, 1, 1));
cout << "sp1计数:" << sp1.use_count() << endl; // 1
// 2. 拷贝构造:计数+1
shared_ptr<Date> sp2(sp1);
shared_ptr<Date> sp3 = sp2;
cout << "sp1计数:" << sp1.use_count() << endl; // 3
// 3. 像指针一样访问
sp1->_year = 2025;
cout << sp2->_year << endl; // 2025,指向同一份资源
// 4. 赋值:旧资源计数-1,新资源计数+1
shared_ptr<Date> sp4(new Date);
sp4 = sp1; // sp4原来的资源计数减到0,自动释放
cout << "sp1计数:" << sp1.use_count() << endl; // 4
// 5. 推荐:make_shared构造,更高效
// 一次申请资源+计数的内存,减少内存碎片,异常安全更好
auto sp5 = make_shared<Date>(2025, 10, 1);
// 6. 移动:转移所有权,计数不变
shared_ptr<Date> sp6(move(sp5));
// 移动后sp5悬空
// 7. get()获取原生指针(不推荐常用,容易破坏封装)
Date* raw = sp1.get();
return 0;
}
make_shared的优势:
一次内存分配同时申请资源和控制块(引用计数等),减少内存分配次数和碎片。
异常安全性更好,避免构造时抛异常导致的泄漏。
shared_ptr 模拟实现(完整带注释)
手写 shared_ptr 是面试手撕代码的高频题,核心要实现:引用计数管理、拷贝赋值、析构释放、运算符重载。
cpp
#include <iostream>
#include <functional>
using namespace std;
namespace bit {
template<class T>
class shared_ptr {
public:
// 普通构造:接管原生指针,初始化引用计数为1
explicit shared_ptr(T* ptr = nullptr)
: _ptr(ptr)
, _pcount(new int(1))
, _del([](T* ptr) { delete ptr; }) // 默认删除器:delete
{}
// 带定制删除器的构造
template<class D>
shared_ptr(T* ptr, D del)
: _ptr(ptr)
, _pcount(new int(1))
, _del(del)
{}
// 拷贝构造:共享资源,计数+1
shared_ptr(const shared_ptr<T>& sp)
: _ptr(sp._ptr)
, _pcount(sp._pcount)
, _del(sp._del)
{
++(*_pcount);
}
// 赋值重载:先释放旧资源,再共享新资源
shared_ptr<T>& operator=(const shared_ptr<T>& sp) {
if (_ptr != sp._ptr) { // 防止自己给自己赋值
release(); // 释放当前管理的资源
// 共享新资源
_ptr = sp._ptr;
_pcount = sp._pcount;
_del = sp._del;
++(*_pcount);
}
return *this;
}
// 析构:计数-1,减到0则释放资源和计数
~shared_ptr() {
release();
}
// 像指针一样使用
T& operator*() { return *_ptr; }
T* operator->() { return _ptr; }
// 获取引用计数
int use_count() const { return *_pcount; }
// 获取原生指针
T* get() const { return _ptr; }
// 判空
operator bool() const { return _ptr != nullptr; }
private:
// 辅助函数:释放当前资源
void release() {
if (--(*_pcount) == 0) {
_del(_ptr); // 调用删除器释放资源
delete _pcount; // 释放引用计数
_ptr = nullptr;
_pcount = nullptr;
}
}
private:
T* _ptr; // 指向资源的原生指针
int* _pcount; // 指向堆上引用计数的指针
function<void(T*)> _del;// 删除器,用function包装,支持任意可调用对象
};
} // namespace bit
注意:shared_ptr的删除器是构造函数参数,不是模板参数。这是和 unique_ptr 非常重要的区别,面试经常考。
原因:shared_ptr 的删除器被封装在控制块里,做了类型擦除,同一个shared_ptr类型可以搭配不同删除器,不影响外部类型,使用更灵活;代价是有一点点运行时开销。
📌 【面试高频考点】
shared_ptr 的实现原理?
答:基于引用计数实现共享所有权。每个 shared_ptr 对象包含资源指针和指向引用计数的指针;拷贝时计数 + 1,析构时计数 - 1;计数减到 0 时释放资源和计数空间。
shared_ptr 和 unique_ptr 删除器的区别?
答:unique_ptr 的删除器是模板参数,属于类型的一部分,零开销;shared_ptr 的删除器是构造函数参数,封装在控制块中,类型擦除,使用更灵活但有少量开销。
2.6 shared_ptr 经典问题一:循环引用与 weak_ptr
什么是循环引用?
当两个对象内部都用shared_ptr互相指向对方时,就会形成循环引用,导致引用计数永远无法减到 0,资源永远不会释放,造成内存泄漏。
最典型的场景就是双向链表节点:
cpp
struct ListNode {
int _data;
shared_ptr<ListNode> _next;
shared_ptr<ListNode> _prev;
~ListNode() { cout << "~ListNode()" << endl; }
};
int main() {
shared_ptr<ListNode> n1(new ListNode);
shared_ptr<ListNode> n2(new ListNode);
cout << "n1计数:" << n1.use_count() << endl; // 1
cout << "n2计数:" << n2.use_count() << endl; // 1
n1->_next = n2; // n2的计数变成2
n2->_prev = n1; // n1的计数变成2
cout << "赋值后n1计数:" << n1.use_count() << endl; // 2
cout << "赋值后n2计数:" << n2.use_count() << endl; // 2
return 0;
// 程序结束,析构都没调用 → 内存泄漏!
}
为什么会泄漏?
我们拆解一下逻辑死锁:
n1要释放,需要引用计数减到 0 → 需要n2->_prev先释放
n2->_prev要释放,需要n2节点先释放
n2要释放,需要引用计数减到 0 → 需要n1->_next先释放
n1->_next要释放,需要n1节点先释放
绕了一圈又回来了,谁都等不到对方先释放,最终两个节点的引用计数永远是 1,永远不会析构。
💡 类比理解:两个人吵架,都说「你先道歉我就道歉」,结果谁都不肯先开口,一直僵着。
解决方案:weak_ptr 弱指针
weak_ptr 就是专门用来打破循环引用的。它的定位是「观察者」:
可以绑定到shared_ptr上,但不增加引用计数 ,不参与资源生命周期管理
不支持 RAII,不能直接管理资源,也没有重载*和->
可以检查资源是否已经释放,需要访问时通过lock()获取一个shared_ptr
把节点内部的指针换成weak_ptr,问题就解决了:
cpp
struct ListNode {
int _data;
weak_ptr<ListNode> _next; // 改成weak_ptr
weak_ptr<ListNode> _prev; // 改成weak_ptr
~ListNode() { cout << "~ListNode()" << endl; }
};
int main() {
shared_ptr<ListNode> n1(new ListNode);
shared_ptr<ListNode> n2(new ListNode);
n1->_next = n2; // weak_ptr赋值,不增加n2的计数
n2->_prev = n1; // weak_ptr赋值,不增加n1的计数
// n1和n2的计数始终是1,出作用域正常析构,没有泄漏
return 0;
}
weak_ptr常用接口
cpp
int main() {
shared_ptr<string> sp1 = make_shared<string>("hello");
weak_ptr<string> wp = sp1; // 用shared_ptr初始化weak_ptr
cout << wp.use_count() << endl; // 1,获取引用计数
cout << wp.expired() << endl; // false,检查资源是否过期(已释放)
// 安全访问资源:lock()返回shared_ptr,如果已过期则返回空
shared_ptr<string> sp2 = wp.lock();
if (sp2) {
cout << *sp2 << endl;
}
// 释放sp1,资源销毁
sp1.reset();
cout << wp.expired() << endl; // true,资源已过期
return 0;
}
📌 【面试高频考点】
weak_ptr 的作用是什么?如何解决循环引用?
答:weak_ptr 是弱引用指针,用于辅助 shared_ptr,绑定 shared_ptr 时不增加引用计数,不参与资源管理。将循环引用中的一方或双方换成 weak_ptr,就不会导致计数死锁,从而打破循环引用,避免内存泄漏。
weak_ptr 可以直接访问资源吗?
答:不能。weak_ptr 没有重载 * 和 ->,需要调用 lock () 方法获取一个 shared_ptr 后才能访问,保证访问时资源有效。
2.7 shared_ptr 经典问题二:线程安全
关于 shared_ptr 的线程安全,一定要分两部分说,不能一概而论:
引用计数是线程安全的:标准库的 shared_ptr 引用计数采用原子操作(atomic)实现,多线程下同时拷贝、析构智能指针对象是安全的,不会出现计数错乱。
指向的对象不是线程安全的:多线程同时修改智能指针指向的资源,和普通指针多线程访问一样,存在数据竞争,需要使用者自己加锁保护。
简单说:智能指针本身的管理是安全的,资源的访问不安全。
2.8 智能指针避坑指南
❌ 不要用同一个原生指针初始化多个智能指针 → 会导致重复释放
❌ 不要把栈对象的地址交给智能指针 → 析构时 delete 栈内存直接崩溃
❌ 不要在函数实参中 new 对象创建智能指针 → 可能因参数求值顺序导致异常泄漏,推荐用 make_shared
❌ 避免 shared_ptr 的 this 指针问题:类内部要返回自身的 shared_ptr,不要直接shared_ptr(this),会重复计数;要继承enable_shared_from_this,调用shared_from_this()
❌ 双向引用、环形结构注意循环引用,及时用 weak_ptr 打破
❌ unique_ptr 移动后、shared_ptr 被 move 后,不要再访问原对象
2.9 智能指针选型建议
优先选 unique_ptr:绝大多数场景下,资源都是独占的,unique_ptr 开销最小、语义最清晰,是默认首选。
需要共享时选 shared_ptr:只有当一份资源确实需要多个管理者时,才使用 shared_ptr。
打破循环引用选 weak_ptr:观察资源、打破循环引用时使用 weak_ptr,不要用它管理资源。
绝对不用 auto_ptr:语义有缺陷,已被废弃。
第三部分:终章总结 ------ 现代 C++ 的核心思想升华
3.1 异常与智能指针的联动
我们把两部分内容串起来看,会发现它们是相辅相成的:
异常机制给了我们优雅的错误处理方式,但也带来了异常安全的新问题
智能指针(RAII 思想)则从机制上解决了异常安全问题,让我们不用再小心翼翼地手动释放资源
二者结合,才能写出既优雅又健壮的现代 C++ 代码。
3.2 RAII:现代 C++ 的灵魂
RAII 绝不仅仅是智能指针,它是一种普适的资源管理思想。
内存 → 智能指针
互斥锁 → lock_guard /unique_lock
文件句柄 → fstream
数据库连接、socket 连接、线程句柄...... 所有需要申请和释放的资源,都应该用 RAII 封装。
它的伟大之处在于:不依赖程序员的细心和记忆力,而是利用语言机制(对象生命周期 + 析构函数)从根源上保证资源释放。人总会犯错,但机制不会。
