《MC++》学习–智能指针
1. 为什么需要智能指针
1.1 传统动态内存管理的问题
传统的内存管理中,我们一般使用new/delete和malloc/free,但当程序发生异常时是有可能发生内存泄漏的,比如我有一个process()函数如下
void process() { int* ptr = new int(42); throw std::runtime_error("发生异常"); delete ptr; // 无法执行 }调用这个函数时,new int(42)会动态创建一个int对象,将它初始化为 42,并把地址赋给ptr。接着我们人为抛出std::runtime_error异常,程序开始寻找匹配的异常处理程序,后面的delete ptr就不会执行。
这里假设外层有匹配的catch。异常向外传播时会发生栈展开(Stack Unwinding),也就是逐层退出函数调用,并销毁已经构造完成的局部对象。
局部变量ptr的生命周期虽然结束了,但它只是一个裸指针,不会自动释放指向的动态内存,因此这里会出现内存泄漏。
1.2 RAII 是啥
RAII 全称是 Resource Acquisition Is Initialization(资源获取即初始化)。它的核心思想是:通过对象的构造与析构管理资源,使资源的生命周期与管理它的对象生命周期绑定。结合下面的例子,我的简单理解是:这里的局部智能指针离开作用域时,会自动调用析构函数释放它管理的资源,不需要我们手动调用delete。
那么智能指针如何践行RAII的思想呢?我们来看一个简单的例子,同样的process()函数,我们使用智能指针来申请空间
void process() { auto ptr = std::make_unique<int>(42); throw std::runtime_error("发生异常"); }首先ptr 是一个 std::unique_ptr 对象,当程序同样走到throw std::runtime_error(“发生异常”),同样程序开始寻找异常处理程序,栈展开过程中会调用 ptr 的析构函数,析构函数就会自己完成资源的清理。
1.3 智能指针有更高的性能
在《MC++》的《Memory and Performance Overhead of Smart Pointers》,作者说到:std::unique_ptr has no memory or performance overhead compared to the explicit usage of new and delete. That is great because std::unique_ptr offers an excellent benefit by automatically managing the lifetime of its resource without any additional cost.
里面一个比较关键的结论是与手动使用 new 和 delete 相比,std::unique_ptr 不会带来任何内存或性能方面的开销。当然作者也提到std::shared_ptr没有那么高的效率,根据作者的实验,std::shared_ptr 的效率大约只有 new 和 delete 操作的一半。就连 std::make_shared 的操作也会带来大约 10%的性能损耗。但作者也提到由于 std::shared_ptr 实际上是一种共享所有权的实现方式,这意味着,只有第一个 std::shared_ptr 需要承担性能和内存方面的开销。其余的 shared pointer 则共享了用于管理底层对象的基础设施。具体来说,每个 std::shared_ptr 只需要进行一次内存分配即可。
虽然作者的实验是在特定环境下,但我觉得我们可以认为智能指针有着更高的性能。
2. unique_ptr
2.1 unique_ptr创建方式
std::unique_ptr<int> p1(new int(42)); auto p2 = std::make_unique<int>(42);这是常见的两种创建方式,我个人比较常用第二种。std::make_unique从 C++14 开始提供,写起来更简洁,也减少了显式使用new。上面第一种写法本身也是安全的,它在同一条语句中将新建对象交给unique_ptr管理。
2.2 独占所有权
这里的独占所有权意思是只有一个所有者负责资源生命周期,而不是只有一个指针能指向这块内存。当我有一个unique_ptr p1,不可以用一个p2去复制p1,但是可以通过std::move将p1管理的对象交给p2。
2.3 auto_ptr的替代品
《MC++》中提到了auto_ptr,并说明unique_ptr为auto_ptr的替代品,原因是当你复制一个 std::auto_ptr 时,其所指向的资源会被移动。也就是说,看似是复制操作,但实际上却是移动操作。这里借用下面一张图描述:
3. shared_ptr
3.1 shared_ptr创建方式
std::shared_ptr<int> p1(new int(42)); auto p2 = std::make_shared<int>(42);创建的方式与unique_ptr类似,这里不多赘述。
3.2 共享所有权
这里稍微提一嘴 强引用 和 弱引用
- 强引用:指参与共享所有权,会让被管理对象保持存活,我们这里的 shared_ptr 就是强引用
- 弱引用:观察对象,但不参与共享所有权,也不会阻止对象被销毁。weak_ptr 持有弱引用。
关于shared_ptr,我觉得最重要的特性就是共享所有权。当你创建了一个管理对象的shared_ptr p1时,可以通过auto p2 = p1;这样的操作复制p1。在没有其他共享所有者的情况下,p1.use_count()和p2.use_count()都返回 2,表示有两个共享所有者;get()返回的是保存的裸指针,并不是引用计数。这点和unique_ptr有本质差异。最后一个共享所有者通过析构、reset()等方式放弃所有权后,被管理对象才会销毁。因此不必显式对p1和p2都调用reset(),它们离开作用域时的析构也会放弃所有权。
3.3 shared_ptr对多线程的适配
作者在《std::shared_ptr》结尾提到“std::shared_ptr has minimum multithreading guarantees”,即在多线程中,shared_ptr有最基本的保证,我们可以参考一下例子:
#include <iostream> #include <memory> #include <thread> int main() { auto ptr = std::make_shared<int>(42); std::thread t1([p = ptr]() { std::cout << "线程1:" << *p << '\n'; }); std::thread t2([p = ptr]() { std::cout << "线程2:" << *p << '\n'; }); t1.join(); t2.join(); std::cout << "引用计数:" << ptr.use_count() << '\n'; }在这个例子中:
- ptr 创建时,共享所有者数量为 1。
- 创建两个线程时,分别复制 shared_ptr,让线程各自持有一份所有权。
- 每个线程执行期间,它持有的 shared_ptr 保证被管理对象不会因为其他共享所有者释放所有权而提前销毁。
- 两个线程结束后,线程内部的 shared_ptr 被销毁。
- join() 返回之后,ptr.use_count() 为 1。
两个线程的输出顺序不确定,而且由于它们同时向 std::cout 输出,输出文本可能交错。
关键在于:不同的 shared_ptr 实例可以安全地在不同线程中修改各自的所有权状态,即使它们共享同一个控制块。 引用计数的相关更新具有原子性保障,可以防止多个线程同时释放所有权时破坏计数。
shared_ptr 保证共享所有权管理的安全;本例对被管理对象只有并发读取,因此也没有数据竞争。如果存在并发读写或写写,就需要额外同步。
4. weak_ptr
4.1 weak_ptr 创建方式
前面讲到,shared_ptr是强引用,会参与对象的生命周期管理。如果我只是想知道这个对象还在不在,需要的时候再访问它,但不想因为自己持有一个指针就让对象一直存活,这时候就可以使用weak_ptr。
autoshared=std::make_shared<int>(42);std::weak_ptr<int>weak=shared;这里的weak观察的是shared管理的对象。创建weak_ptr不会增加强引用计数,在这个例子中,shared.use_count()仍然是 1。weak.use_count()也返回 1,但这个值表示共享所有者的数量,并不是有多少个weak_ptr。这也呼应了前面说的弱引用:可以保持观察关系,但不负责让对象继续存活。
4.2 怎么理解“临时性所有权”
《MC++》的《std::weak_ptr》中用“temporary ownership”描述这种关系。我觉得这个说法适合帮助理解,但需要说明一点:weak_ptr本身没有对象的所有权,真正临时持有对象的是成功调用lock()后得到的shared_ptr。
weak_ptr不能像前两种指针一样直接使用*或->访问对象,需要先调用lock()。如果对象还存在,就得到一个共享所有者;如果已经过期,就得到一个空的shared_ptr。我们可以看下面这个片段,需要包含<memory>和<iostream>:
autoshared=std::make_shared<int>(42);std::weak_ptr<int>weak=shared;if(autotemporary=weak.lock()){std::cout<<*temporary<<'\n';std::cout<<temporary.use_count()<<'\n';// 此时有两个共享所有者}// temporary 析构,剩下 shared 继续管理对象shared.reset();// 最后一个共享所有者放弃所有权,int 对象被销毁if(autotemporary=weak.lock()){std::cout<<*temporary<<'\n';}else{std::cout<<"对象已经不存在了\n";}这里第一次lock()成功后,temporary会在持有期间保证对象继续存活。等它离开作用域,这份临时的共享所有权就结束了。
4.3 用 weak_ptr 打破循环引用
《MC++》在 std::weak_ptr 这一篇里还讲到了循环引用。比如对象 A 内部用shared_ptr持有 B,B 内部又用shared_ptr持有 A。即使外部的共享所有者都释放了,A 和 B 仍然相互持有,强引用计数无法自动归零,对象也就无法自动销毁。
对于这个简单的双向引用,可以把其中只需要观察对方的那一端改成weak_ptr。弱引用不增加强引用计数,因此可以打破这个共享所有权的环。需要访问对方时再调用lock(),检查是否成功即可。
5. 综合实验:所有权与对象生命周期
下面用一个 C++17 小实验,观察同一个对象从独占管理到共享管理,再到最后被销毁的过程。
5.1 实验代码
#include<iostream>#include<memory>#include<utility>// 用对象编号和析构日志观察资源的生命周期。classResource{public:explicitResource(intid):id_(id){std::cout<<"construct Resource "<<id_<<'\n';}~Resource(){std::cout<<"destroy Resource "<<id_<<'\n';}Resource(constResource&)=delete;Resource&operator=(constResource&)=delete;intid()const{returnid_;}private:intid_;};intmain(){// 步骤 1:用 make_unique 创建一个 Resource,保存 get() 返回的地址。// 用 std::move 交给 unique2;检查 unique1 为空、打印地址以便比较。// 后续步骤继续使用同一个 Resource,观察它完整的生命周期。autounique1=std::make_unique<Resource>(0);Resource*addr1=unique1.get();std::cout<<"addr1:"<<addr1<<'\n';autounique2=std::move(unique1);(unique1==nullptr)?std::cout<<"unique1 is nullptr"<<'\n':std::cout<<"unique1 is not nullptr"<<'\n';std::cout<<"addr2:"<<unique2.get()<<'\n';// 步骤 2:用 shared_ptr<Resource> shared1(std::move(unique2)) 接管。// 检查 unique2 为空、shared1.get() 地址未变,记录 shared1.use_count()。// 复制 shared1 得到 shared2,记录强引用计数。std::shared_ptr<Resource>shared1(std::move(unique2));(unique2==nullptr)?std::cout<<"unique2 is nullptr"<<'\n':std::cout<<"unique2 is not nullptr"<<'\n';(shared1.get()==addr1)?std::cout<<"same resource address"<<'\n':std::cout<<"address changed"<<'\n';std::cout<<"The number of strong ref is:"<<shared1.use_count()<<'\n';autoshared2=shared1;std::cout<<"The number of strong ref is:"<<shared2.use_count()<<'\n';// 步骤 3:建立 weak_ptr,再用 weak.lock() 获取临时 shared_ptr。// 成功后读取 use_count();离开 if 作用域后再次观察强引用计数。std::weak_ptr<Resource>weak=shared1;std::cout<<"The number of strong ref is:"<<shared2.use_count()<<'\n';if(autotemp=weak.lock()){std::cout<<"The number of strong ref is:"<<temp.use_count()<<'\n';}std::cout<<"The number of strong ref is:"<<shared2.use_count()<<'\n';// 步骤 4:逐个 reset 两个共享所有者,在最后一次 reset 前后打印标记,观察析构时机。// 确保 lock 返回的临时所有者已退出作用域,再检查 weak.expired() 和 lock()。// 最后一个共享所有者退出后,不再解引用最初保存的裸指针。shared1.reset();std::cout<<"The number of strong ref is:"<<shared2.use_count()<<'\n';std::cout<<"before last reset"<<'\n';shared2.reset();std::cout<<"after last reset"<<'\n';std::cout<<"The number of strong ref is:"<<weak.use_count()<<'\n';std::cout<<"weak expired: "<<std::boolalpha<<weak.expired()<<'\n';if(autotemp=weak.lock()){std::cout<<"object still exists"<<'\n';}else{std::cout<<"object is destroyed"<<'\n';}}5.2 运行输出
使用 g++ 16.2.0,以-std=c++17 -g -O0 -Wall -Wextra -pedantic编译,无编译诊断,运行退出码为 0。本次实际输出如下,地址每次运行可能不同,也不必与调试截图一致。
construct Resource 0 addr1:0x1eaf86b0ea0 unique1 is nullptr addr2:0x1eaf86b0ea0 unique2 is nullptr same resource address The number of strong ref is:1 The number of strong ref is:2 The number of strong ref is:2 The number of strong ref is:3 The number of strong ref is:2 The number of strong ref is:1 before last reset destroy Resource 0 after last reset The number of strong ref is:0 weak expired: true object is destroyed两次所有权转移没有创建新的Resource,对象地址保持相同。强引用计数依次为1 → 2 → 2 → 3 → 2 → 1 → 0:复制shared_ptr会增加计数,建立weak_ptr不会;lock()成功后临时增加一次,临时指针离开作用域后再减回去。
5.3 调试观察
释放前
此时shared2.reset()还没执行,shared1已为空,shared2是最后一个共享所有者,use count 1表示强引用计数为 1;调试器显示的weak count 1表示还有一个弱引用。
释放后
执行这一行后,强引用计数归零,Resource被销毁,weak显示expired。弱引用仍在,不代表对象仍然存活。此时控制块还保留,但它不能让对象继续存活;后续lock()返回空指针,程序输出object is destroyed。
图中旧地址仍然可见,id_显示成8306384,这只是调试器展示的失效内存内容,不是有效的对象值。地址仍然可见,不代表可以继续解引用。此时addr1已是悬空指针。另外,weak下的get()是调试器的展示,weak_ptr本身没有这个接口;weak.use_count()返回的仍是强引用计数。
5.4 实验结论
这个实验里,对象只构造和析构各一次。unique_ptr通过移动转交独占所有权,shared_ptr通过引用计数共享所有权;最后一个共享所有者释放时,对象销毁。weak_ptr只保留观察关系,只有成功lock()得到的shared_ptr才能临时持有对象。
6. 总结和一点碎碎念
6.1 总结
| 特性 | std::unique_ptr | std::shared_ptr | std::weak_ptr |
|---|---|---|---|
| 所有权 | 独占 | 共享 | 不拥有对象 |
| 能否复制 | 否 | 是,复制后共享所有权 | 是,复制观察关系 |
| 能否移动 | 是,转移独占所有权 | 是,转移已有的共享所有权 | 是,转移观察关系 |
| 引用计数 | 不使用引用计数 | 参与强引用计数 | 参与弱引用管理,不增加强引用计数 |
| 对对象生命周期的影响 | 持有期间负责管理对象 | 仍有共享所有者时,对象保持存活 | 不阻止对象销毁,但可以保持控制块存活 |
| 访问对象 | *、->(非空时) | *、->(指向有效对象时) | 先lock(),成功后通过得到的shared_ptr访问 |
| 典型用途 | 由一个所有者管理资源,需要时转交 | 多个所有者共同管理资源 | 观察共享资源、打破共享所有权的循环引用 |
| 管理开销 | 通常较低,无共享控制块;大小受删除器影响 | 有控制块及引用计数维护成本 | 复用共享控制块,有弱引用计数维护成本 |
我的选择思路是:先考虑unique_ptr,确实需要多个所有者共同管理时再用shared_ptr;需要观察共享对象但不想延长它的生命周期时,用weak_ptr。另外,weak.use_count()返回的是强引用计数,不是弱引用数量。
6.2 一点无关紧要的话
这是我第一篇 C++ 学习笔记。说实话,作为一名学了两年嵌入式软件的大三学生,我感觉现在再来补一些 C++ 应用有点晚,但是还是必要的。
这篇笔记不知道能有多少人看到,但我希望能给读到这里的人带来一点点帮助。也欢迎大家在评论区给我留下一点建议,不管是文章内容上,还是其他方面。大概就这样吧。
参考资料
- Rainer Grimm:Memory and Performance Overhead of Smart Pointers
- Rainer Grimm:std::unique_ptr
- Rainer Grimm:std::shared_ptr
- Rainer Grimm:std::weak_ptr