C++ 第二阶段:智能指针与现代 RAII
前言
第一阶段我们手动管理过:
FILE* → fopen / fclose char* → new[] / delete[]第二阶段的目标不是“把裸指针换成智能指针”,而是建立清晰的资源所有权模型:
谁拥有资源? 谁负责释放? 什么时候释放? 能不能共享? 能不能转移?本阶段重点:
RAII unique_ptr / make_unique get / release / reset shared_ptr / 控制块 weak_ptr / 循环引用 自定义删除器 异常安全 Rule of Five / Rule of Zero一、RAII
RAII 的核心:
把资源生命周期绑定到对象生命周期。
voidTest(){FileSessionsession("log.txt",1024);// 正常退出、提前 return、异常退出// 都不依赖手动 Close()}只要session已成功构造,离开作用域时就会析构,进而释放资源。
RAII 不只管理内存,还适用于:
FILE* Windows HANDLE HKEY SOCKET 互斥锁 数据库连接 第三方库句柄二、std::unique_ptr:独占所有权
autop=std::make_unique<Data>();含义:
同一时刻只有一个 owner 负责资源生命周期。
unique_ptr:
不能拷贝 可以移动 析构自动释放资源autop1=std::make_unique<Data>();autop2=std::move(p1);移动后:
p1 → 空 p2 → Data注意:
std::move()本身不移动资源,真正转移所有权的是unique_ptr的移动构造或移动赋值。
2.1make_unique
推荐:
autop=std::make_unique<Data>(100);而不是:
std::unique_ptr<Data>p(newData(100));优点:
更简洁 减少裸 new 暴露 创建后立即进入 RAII 管理版本注意:
unique_ptr → C++11 make_unique → C++14所以 VS2008 / C++03 老项目不能直接使用。
2.2 管理数组
autobuffer=std::make_unique<char[]>(1024);对应:
new[] / delete[]可以下标访问:
buffer[0]='A';但unique_ptr<T[]>不记录数组长度,因此通常还要单独保存:
std::size_t bufferSize;三、unique_ptr 的 get / release / reset
get()
int*raw=p.get();作用:
取得内部裸指针,但不转移所有权。
p → 资源(owner) raw → 同一资源(观察)不能:
deleteraw;否则p后续析构时会再次释放。
常用于:
LegacyApi(p.get());release()
int*raw=p.release();作用:
返回裸指针,并让
unique_ptr放弃所有权,但不会释放资源。
结果:
p → 空 raw → 原资源后续必须由其他代码接管,否则泄漏。
reset()
p.reset();作用:
释放当前资源,并让
p为空。
也可以:
p.reset(newint(20));表示:
释放旧资源 ↓ 接管新资源一句话:
get() → 看,不拿走 release() → 拿走,不释放 reset() → 释放当前资源 / 换资源四、std::shared_ptr:共享所有权
autop1=std::make_shared<Data>();autop2=p1;关系:
p1 ─┐ ├──→ Data p2 ─┘多个shared_ptr可以共同拥有一个对象。
最后一个强引用消失时,对象销毁。
五、shared_ptr 控制块
shared_ptr通常关联一个控制块:
shared_ptr │ ├──→ 被管理对象 │ └──→ 控制块 ├── 强引用计数 ├── 弱引用计数 ├── 删除器 └── 其他管理信息例如:
autop1=std::make_shared<Data>();// strong = 1autop2=p1;// strong = 2当:
strong → 0被管理对象销毁。
如果仍有weak_ptr,控制块可能继续存在。
所以:
对象生命周期和控制块生命周期不是一回事。
5.1use_count()
p1.use_count();可以观察强引用计数。
但不建议用它作为复杂业务逻辑判断条件,尤其在并发环境中。
六、make_shared
推荐:
autop=std::make_shared<Data>(100);而不是:
std::shared_ptr<Data>p(newData(100));优点:
代码更简洁 异常安全更自然 对象和控制块通常可以一次分配注意:
“通常一次分配”属于主流实现方式,不要说成 C++ 标准规定绝对必须如此。
七、shared_ptr 的严重陷阱:两个控制块
错误:
int*raw=newint(10);std::shared_ptr<int>p1(raw);std::shared_ptr<int>p2(raw);可能形成:
p1 → 控制块 A ─┐ ├──→ 同一个 int p2 → 控制块 B ─┘两个控制块都认为自己拥有同一个资源。
最终可能:
p1 析构 → delete p2 析构 → 再 delete导致:
Double Free / UB正确:
autop1=std::make_shared<int>(10);autop2=p1;这样两个shared_ptr共享同一个控制块。
八、std::weak_ptr:观察,不拥有
autop=std::make_shared<Data>();std::weak_ptr<Data>weak=p;特点:
不增加强引用计数 不延长对象生命周期 不能直接解引用8.1lock()
if(autop2=weak.lock()){p2->Run();}如果对象还活着:
lock() → 得到有效 shared_ptr → 强引用计数临时 +1如果对象已销毁:
lock() → 得到空 shared_ptrlock()的价值不仅是检查:
它还能保证拿到的
shared_ptr存在期间,对象不会被销毁。
8.2expired()
weak.expired();可以判断对象是否已经失效。
真正访问对象时,通常优先:
lock()而不是:
先 expired() 再访问因为两步之间对象状态可能发生变化。
九、循环引用
classB;classA{public:std::shared_ptr<B>b;};classB{public:std::shared_ptr<A>a;};如果:
a->b=b;b->a=a;形成:
A ──shared_ptr──→ B ↑ │ └──shared_ptr────┘即使外部shared_ptr都消失:
A 仍持有 B B 仍持有 A强引用计数无法归零,导致对象无法析构。
9.1 用 weak_ptr 打破环
例如:
classB{public:std::weak_ptr<A>a;};变成:
A ──shared_ptr──→ B ↑ └────weak_ptr────此时:
A 拥有 B B 只观察 A核心不是机械记“其中一边 weak_ptr”,而是先判断:
谁真正拥有谁? 谁只是回调 / 观察 / 父子关联?十、shared_ptr 的线程安全边界
面试高频:
shared_ptr的引用计数管理具有线程安全保证,但被管理对象本身不会因此自动线程安全。
例如:
autop=std::make_shared<std::vector<int>>();多个线程分别持有自己的shared_ptr副本,引用计数管理没问题。
但多个线程同时:
p->push_back(...);仍然可能产生数据竞争,需要:
mutex 其他同步机制所以:
shared_ptr 线程安全 ≠ T 对象线程安全十一、自定义删除器
智能指针不只能管理:
new / delete例如文件:
structFileCloser{voidoperator()(std::FILE*file)constnoexcept{if(file!=nullptr){std::fclose(file);}}};定义:
usingFilePtr=std::unique_ptr<std::FILE,FileCloser>;创建:
FilePtrfile(std::fopen("log.txt","a"));离开作用域:
FilePtr 析构 ↓ FileCloser ↓ fcloseWindows 客户端同样可以封装:
HANDLE → CloseHandle HKEY → RegCloseKey SOCKET → closesocket十二、RAII 与异常安全
裸资源:
voidFunc(){char*buffer=newchar[1024];DoSomething();// 抛异常delete[]buffer;}如果DoSomething()抛异常:
delete[] 没执行 → 泄漏使用:
autobuffer=std::make_unique<char[]>(1024);异常离开作用域:
栈展开 ↓ buffer 析构 ↓ 资源自动释放所以 RAII 的核心价值之一是:
让异常路径也能依赖对象析构完成资源清理,而不是依赖手写 cleanup。
12.1 构造函数中途抛异常
如果外层对象构造函数中途抛异常:
外层对象没有构造成功 → 外层析构函数不会执行但:
已经成功构造完成的成员对象会正常析构。
因此成员使用:
unique_ptr string vector 其他 RAII 类型比直接持有裸资源更安全。
十三、Rule of Three / Rule of Five
如果类自己直接管理资源,需要认真考虑:
析构函数 拷贝构造 拷贝赋值这是:
Rule of ThreeC++11 后再加:
移动构造 移动赋值形成:
Rule of Five重点不是背“五个函数”,而是:
一旦类直接承担资源所有权,就必须明确复制、移动和销毁语义。
十四、Rule of Zero
现代 C++ 更推荐:
把资源交给已经正确实现 RAII 的成员,让外层类不再手写资源管理。
例如:
classSession{private:std::string m_name;std::vector<char>m_buffer;};这些成员已经正确处理:
析构 拷贝 移动外层类往往不需要自己写特殊成员函数。
所以:
能 Rule of Zero 就优先 Rule of Zero十五、FileSession:先改造 buffer
原来:
char*m_buffer=nullptr;改成:
std::unique_ptr<char[]>m_buffer;创建:
m_buffer=std::make_unique<char[]>(bufferSize);之后不再需要:
delete[]m_buffer;移动:
m_buffer=std::move(other.m_buffer);unique_ptr自动完成:
释放目标旧缓冲区 接管源缓冲区 让源 unique_ptr 变空十六、FileSession:文件也交给 RAII
定义:
structFileCloser{voidoperator()(std::FILE*file)constnoexcept{if(file!=nullptr){std::fclose(file);}}};usingFilePtr=std::unique_ptr<std::FILE,FileCloser>;最终成员:
classFileSession{public:FileSession(conststd::string&filename,std::size_t bufferSize):m_file(std::fopen(filename.c_str(),"a")),m_buffer(std::make_unique<char[]>(bufferSize)),m_bufferSize(bufferSize){if(bufferSize==0){throwstd::invalid_argument("缓存大小不能为 0");}if(!m_file){throwstd::runtime_error("打开文件失败");}}voidLogWrite(conststd::string&text){if(!m_file){throwstd::runtime_error("文件不可用");}if(std::fprintf(m_file.get(),"%s\n",text.c_str())<0){throwstd::runtime_error("写入失败");}}private:FilePtr m_file;std::unique_ptr<char[]>m_buffer;std::size_t m_bufferSize=0;};现在:
m_file → 自动 fclose m_buffer → 自动 delete[]不再需要:
手写析构释放 手写异常 cleanup 手动清空移动后的裸指针这就是现代 RAII + Rule of Zero 的方向。
十七、智能指针如何选择
能直接创建普通对象 → 直接创建 需要动态生命周期且只有一个 owner → unique_ptr 确实有多个 owner → shared_ptr 只观察 shared_ptr 管理的对象 → weak_ptr不要为了“安全”把所有指针都改成shared_ptr。
因为它会增加:
控制块 引用计数成本 生命周期复杂度 循环引用风险默认优先:
unique_ptr十八、第二阶段高频面试回答
1. unique_ptr 和 shared_ptr 的区别?
unique_ptr表示独占所有权,不能拷贝,只能移动;shared_ptr表示共享所有权,通过控制块中的强引用计数决定对象生命周期。工程里默认优先unique_ptr,只有确实需要多个 owner 时才使用shared_ptr。
2. weak_ptr 解决什么问题?
weak_ptr是非拥有型观察者,不增加强引用计数,主要用于观察shared_ptr管理的对象以及打破循环引用。使用前通常通过lock()获取临时shared_ptr,保证访问期间对象仍然存活。
3. get / release / reset 区别?
get()只取得裸指针,不改变所有权;release()返回裸指针并让unique_ptr放弃所有权,但不释放资源;reset()会释放当前资源,并可以让智能指针为空或接管新资源。
4. shared_ptr 控制块是什么?
控制块通常保存强引用计数、弱引用计数、删除器等管理信息。多个共享同一所有权的
shared_ptr会关联同一个控制块。最后一个强引用消失时对象销毁,而如果还有weak_ptr,控制块可能继续存在。
5. 为什么会循环引用?
两个对象如果通过
shared_ptr相互持有,会互相增加强引用计数,即使外部引用消失,强引用仍不能归零,所以对象无法析构。通常把非拥有的一侧改成weak_ptr。
6. shared_ptr 是线程安全的吗?
控制块中的引用计数管理具有线程安全保证,但被管理对象本身不会因为使用
shared_ptr就自动线程安全。多个线程并发修改对象仍需要自己的同步机制。
7. Rule of Five 和 Rule of Zero?
Rule of Five 是资源管理类需要认真处理析构、拷贝构造、拷贝赋值、移动构造和移动赋值。现代 C++ 更推荐 Rule of Zero:把资源交给
unique_ptr、vector、string等 RAII 成员,让成员和编译器自然提供正确的生命周期行为。
十九、第二阶段验收清单
如果下面问题可以稳定回答,第二阶段就可以结束:
1. RAII 真正解决什么问题? 2. unique_ptr 为什么不能拷贝但可以移动? 3. get / release / reset 区别? 4. shared_ptr 如何知道什么时候销毁对象? 5. 什么是控制块? 6. 强引用归零后,为什么控制块可能还存在? 7. weak_ptr 为什么不延长生命周期? 8. lock() 为什么比单独 expired() 更适合访问对象? 9. 什么是 shared_ptr 循环引用? 10. 为什么一个裸指针不能分别构造两个独立 shared_ptr? 11. shared_ptr 的线程安全边界是什么? 12. 自定义删除器能解决什么问题? 13. 为什么 RAII 对异常安全重要? 14. Rule of Five 和 Rule of Zero 的区别? 15. 为什么现代 C++ 默认优先 unique_ptr?总结
第二阶段真正要建立的是:
普通对象 → 生命周期最简单,优先使用 unique_ptr → 一个 owner shared_ptr → 多个共同 owner weak_ptr → 观察,不拥有现代 C++ 的资源管理目标:
RAII + 清晰所有权 + Rule of Zero而不是:
new / delete 到处出现 手写 cleanup 手写复杂资源释放逻辑