首页 > 代码库 > 话说智能指针发展之路
话说智能指针发展之路
从RAII说起
教科书里关于“动态创建内存”经常会提醒你,new一定要搭配delete来使用,并且delete掉一个指针之后,最好马上将其赋值为NULL(避免使用悬垂指针)。
这么麻烦,于是乎,这个世界变成11派人:
一派人勤勤恳恳按照教科书的说法做,时刻小心翼翼,苦逼连连;
一派人忘记教科书的教导,随便乱来,搞得代码处处bug,后期维护骂声连连;
最后一派人想了更轻松的办法来管理动态申请的内存,然后悠闲唱着小曲喝着茶~
(注:应该没人看不懂11是怎么来的……就是十进制的3的二进制形式)
正式介绍开始,RAII全称为Resource Acquisition Is Initialization,引用维基百科的解释:
RAII要求,资源的有效期与持有资源的对象的生命期严格绑定,即由对象的构造函数完成资源的分配(获取),同时由析构函数完成资源的释放。在这种要求下,只要对象能正确地析构,就不会出现资源泄露问题。
更详细的阐释可以参考《Effective C++》(第三版,条款13:以对象管理资源)。
众所周知,分配在栈上的对象在退出作用域时会自动销毁,所以需要关注的是动态申请内存的做法。用原始指针,正如一开始所说的,很麻烦,最后一派的人就根据RAII思想的指引,创造了智能指针!
随着编译器对智能指针的支持,对于C++程序猿来说应该是一件很值得高兴的事,引用一句话:
智能指针的出现,给不支持垃圾回收机制的C++带来了一丝曙光。
声明
本文为了突出对比表现各种智能智能的优劣,所以虚构了一个“发展之路”,原创者的想法不一定真的如我所说~
auto_ptr
先看一个实例感受一下auto_ptr相较于原始指针的方便之处:
#include <iostream>
#include <string>
#include <memory>
using namespace std;
int main() {
auto_ptr<string> ps1(new string("Hello, auto_ptr!"));
cout << "The content is: " << *ps1 << endl;
return 0;
}
是不是实现了上面所说的“自动管理动态内存”的目标呢?
不需要手动释放,自动析构!!!
想要了解更多操作的,可以看一下下图所示的auto_ptr的接口说明:
但是,在C++11标准中,auto_ptr已经被弃用了(虽然为了向前兼容,还原封不动保留着,但是有了更好的工具,为何不用呢?)。
问题来了,为什么它这么好,还会被抛弃呢?
wiki说了,auto_ptr是“复制语义”,这个名词不懂无所谓,举个例子就知道了:
#include <iostream>
#include <string>
#include <memory>
using namespace std;
int main() {
auto_ptr<string> ps1(new string("Hello, auto_ptr!"));
auto_ptr<string> ps2;
ps2 = ps1;
//【E1】下面这行注释掉才可正确运行,原因见下文分析
//cout << "ps1: " << *ps1 << endl;
cout << "ps2: " << *ps2 << endl;
return 0;
}
简单理解的话,可以认为是,auto_ptr是支持复制的(不信回头去看上面的auto_ptr接口说明里的constructor项),一旦允许复制,就很容易发现一个问题——若两个auto_ptr对象都包含了同一个指针(即指向同个对象,同一块内存),那么当它们都析构的时候,同一个对象就会被析构两次!!!运行出错了吧?
解决方法是很简单了,auto_ptr的做法是:当发生复制/赋值时,把被复制/赋值的对象内部的指针值赋值为NULL,这样始终只有一个auto_ptr对象指向同一个对象,所以保证了不会多次析构!
由此,关于上述代码【E1】处的原因解析是:
运行到这里会出错,因为ps1内部的指针值经过赋值运算后,已经变为NULL,解引用就会导致运行错误!
这是auto_ptr一个很重大的缺陷,因为使用auto_ptr的程序员需要时刻警惕这样的用法。
于是乎,不满于繁琐的人们又创造了unique_ptr等一系列新一代的智能指针!
unique_ptr
可以简单认为,unique_ptr是为了解决上述auto_ptr的问题而诞生的(但事实上是不是呢,就得问当初的设计者了)。
它是怎么解决的呢?
很简单粗暴的,它禁止了赋值操作和复制构造。
那么问题来了,如果我想把控制权从一个unique_ptr对象转移给另一个unique_ptr对象,该怎么办呢?
答案是:用移动构造!
这是C++11的一个概念,可以参考这篇IBM的博客。意思大概是,把某个对象的内容直接给另一个对象使用(,而自己失效了)。
给个例子:
#include <iostream>
#include <string>
#include <memory>
using namespace std;
int main() {
unique_ptr<string> ps1(new string("Hello, unique_ptr!"));
cout << "ps1 is: " << *ps1 << ", ptr value is: " << ps1.get() << endl;
// unique_ptr<string> ps2(ps1);// 编译将会出错!因为禁止复制
// unique_ptr<string> ps2 = ps1;// 编译将会出错!因为禁止赋值
unique_ptr<string> ps2 = move(ps1);
cout << "ps1 is: " << ps1.get() << endl;
cout << "ps2 is: " << *ps2 << ", ptr value is: " << ps2.get() << endl;
return 0;
}
输出结果为:
ps1 is: Hello, unique_ptr!, ptr value is: 0x1d8dc20
ps1 is: 0
ps2 is: Hello, unique_ptr!, ptr value is: 0x1d8dc20
值得注意的是,在不同机器上,指针的输出值不一定一样,但是第二行一定是一样的,因为ps1经过move之后失效了,其内部的指针值被赋值为NULL!
unique_ptr就是用它这种强硬的方式来消除auto_ptr带来的(程序员需时刻注意不能解引用已经被复制/赋值过的对象的)问题。
shared_ptr
是不是有了unique_ptr就万事大吉了呢?
(我会这样问,)答案(肯定)是否定的。
比如,如果程序需要在多个不同的地方(比如多线程)用到同一份内容,而unique_ptr只允许最多只有一个地方持有对原始对象的指针,这就麻烦了……
于是创造力旺盛的程序员们又发明了shared_ptr,一个满足你上述需求的智能指针,它的思想很简洁:
引用计数!每次有一个shared_ptr关联到某个对象上时,计数值就加上1;相反,每次有一个shared_ptr析构时,相应的计数值就减去1。当计数值减为0的时候,就执行对象的析构函数,此时该对象才真正被析构!
由此,shared_ptr很明显是支持复制构造和赋值操作的,因为它有了计数机制之后,就不需要unique_ptr那样严格地控制复制、赋值来维护析构操作的时机。
用法示例如下:
#include <iostream>
#include <string>
#include <memory>
using namespace std;
int main() {
shared_ptr<string> ps1(new string("Hello, shared_ptr!"));
shared_ptr<string> ps3(ps1); // 允许复制
shared_ptr<string> ps2 = ps1; // 允许赋值
cout << "Count is: " << ps1.use_count() << ", "
<< ps2.use_count() << ", " << ps3.use_count() << endl;
cout << "ps1 is: " << *ps1 << ", ptr value is: " << ps1.get() << endl;
cout << "ps2 is: " << *ps2 << ", ptr value is: " << ps2.get() << endl;
cout << "ps3 is: " << *ps3 << ", ptr value is: " << ps3.get() << endl;
shared_ptr<string> ps4 = move(ps1); // 注意ps1在move之后,就“失效”了,什么都是“0”
cout << "Count is: " << ps1.use_count() << ", "
<< ps2.use_count() << ", " << ps3.use_count() << ", " << ps4.use_count() << endl;
cout << "ps1 is: " << ps1.get() << endl;
cout << "ps4 is: " << *ps4 << ", ptr value is: " << ps4.get() << endl;
return 0;
}
输出结果为:
Count is: 3, 3, 3
ps1 is: Hello, shared_ptr!, ptr value is: 0x1210c20
ps2 is: Hello, shared_ptr!, ptr value is: 0x1210c20
ps3 is: Hello, shared_ptr!, ptr value is: 0x1210c20
Count is: 0, 3, 3, 3
ps1 is: 0
ps4 is: Hello, shared_ptr!, ptr value is: 0x1210c20
注意它们输出的指针值都是一样的,也表明它们引用的是同个对象。
weak_ptr
哎,问题到shared_ptr不都完全解决了吗?怎么又蹦出一个weak_ptr来?这个又是干什么的?
咳咳,这是因为人们在使用shared_ptr的过程中,发现了一个问题——循环引用,具体的例子和说明可以参考我的另一篇博客shared_ptr循环引用的例子及解决方法示例。
因为内容比较多,就独立成为一篇新的博客了,其中有实际的例子以及很多解释,这里就不赘述了。
最后再引用一段话来分辨强引用(比如shared_ptr)和弱引用(比如weak_ptr)(来自博客Boost智能指针——weak_ptr):
一个强引用当被引用的对象活着的话,这个引用也存在(就是说,当至少有一个强引用,那么这个对象就不能被释放)。boost::share_ptr就是强引用。
相对而言,弱引用当引用的对象活着的时候不一定存在。仅仅是当它存在的时候的一个引用。弱引用并不修改该对象的引用计数,这意味这弱引用它并不对对象的内存进行管理,在功能上类似于普通指针,然而一个比较大的区别是,弱引用能检测到所管理的对象是否已经被释放,从而避免访问非法内存。
最后的注意事项
1. 何时需要用智能指针?
如果是在栈上创建的对象,一旦出了作用域,自动会析构,杀鸡不需要牛刀。
正如一开始所说的应用场景,管理动态创建的内存时才需要智能指针登场!
所以不要乱搞,写出下面的错误代码来:
#include <iostream>
#include <string>
#include <memory>
using namespace std;
int main() {
// 注意这是一个分配在栈上的对象,而不是在堆上的
string hello("Hello, auto_ptr!");
auto_ptr<string> ps1(&hello);
cout << "ps1: " << *ps1 << endl;
// 让程序析构auto_ptr对象之前暂停一下,以分辨程序崩溃是谁的锅
string str;
cin >> str;
return 0;
}
2. 示例程序
注意,本文为了代码简单,所以直接用了string类来演示。
不过,为了更好地观察对象析构的时机,建议读者使用自定义的且重载了析构函数的类,在析构函数里输出一些提示信息以便观察。
智能指针的接口不多,有兴趣的读者可以自行找更多的实例来学习。
3. 性能
本文只是简单介绍怎么使用智能指针,以及它们是怎么起作用的。性能方面也是很值得考虑的一个问题,不过等日后再分析清楚再来写博客了。
4. 多线程安全
本文没有分析,但是这是值得考虑的一个问题,推荐看陈硕的《Linux 多线程服务端编程:使用 muduo C++ 网络库》。
其它参考资料
- C++智能指针简单剖析
话说智能指针发展之路