话说智能指针发展之路
Posted Jacketinsysu
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了话说智能指针发展之路相关的知识,希望对你有一定的参考价值。
从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的过程中,发现了一个问题——循环引用,比如下面这个很简单的例子:
// 循环引用
#include <iostream>
#include <string>
#include <memory>
using namespace std;
class Human {
public:
Human(string name)
: name(name) {
cout << "Human born: " << name << endl;
}
~Human() {
cout << "Human died: " << name << endl;
}
void loves(shared_ptr<Human> someone) {
lover = someone;
cout << name << " loves " << someone->getName() << " whose use_count = " << someone.use_count() << endl;
}
string getName() const {
return name;
}
private:
string name;
shared_ptr<Human> lover;
};
int main() {
shared_ptr<Human> I(new Human("jacket"));
shared_ptr<Human> you(new Human("Angela"));
shared_ptr<Human> he(new Human("ivany"));
cout << "First, counts: " << I.use_count() << ' ' << you.use_count() << ' ' << he.use_count() << '\\n' << endl;
I->loves(you);
you->loves(he);
// he->loves(I);
cout << "\\nLater, counts: " << I.use_count() << ' ' << you.use_count() << ' ' << he.use_count() << endl;
return 0;
}
运行上面的代码,可以发现输出为:
Human born: jacket
Human born: Angela
Human born: ivany
First, counts: 1 1 1jacket loves Angela whose use_count = 3
Angela loves ivany whose use_count = 3Later, counts: 1 2 2
Human died: jacket
Human died: Angela
Human died: ivany
首先,三个人的引用计数都是1,这个没问题,都是由各自的智能指针对象在main函数的作用域中持有。
将Angela传递给jacket的loves函数时,注意参数传递方式是按值传递,所以会相当于在loves函数的作用域中,对Angela会多一份引用;紧接着,在loves函数中,jacket对象会复制一份对Angela的引用,所以计数值为3!
同理,对于ivany也是如此。
【标记一下这个位置,后面会继续讨论到】
然后,每次调用完loves函数,那份由于参数按值传递而复制的引用就被撤销了,所以目前的引用情况为:
jacket 1次:在main函数中;
Angela 2次:在main函数中 + jacket持有;
ivany 2次:在main函数中 + Angela持有;
在main函数退出之前,我们都知道,所有在其作用域中声明的对象都会被析构掉,所以I、you、he三个对象都被析构(请回顾一下,函数退出作用域时,是按照对象声明的顺序反过来析构),其实只是导致计数值减少而已,并不是真正的析构对象。
但是,由于ivany析构,所以he的引用值变为1;
然后Angela析构,引用值变为1;
最后jacket析构,引用值变为0(真正析构),从而导致Angela的引用变为0,Angela也随之析构,再导致ivany的引用值变为0,ivany紧跟着析构。
再讨论第二种情况,这里就会发生“循环引用”了!
把被注释掉的那一行的注释去掉,会发现输出变成:
Human born: Angela
Human born: jacket
Human born: ivany
First, counts: 1 1 1jacket loves Angela whose use_count = 3
Angela loves ivany whose use_count = 3
ivany loves jacket whose use_count = 3Later, counts: 2 2 2
没人被释放,对不对?(从没有任何析构函数被调用可以看出来)
这种情况其实跟第一种是完全一样的,只不过多了一点,从上面的标记处开始看,由于多了一个loves函数调用,所以目前的引用情况为:
jacket 3次:在main函数中 + ivany持有 + loves函数由于参数复制而持有;
Angela 3次:在main函数中 + jacket持有 + loves函数由于参数复制而持有;
ivany 3次:在main函数中 + Angela持有 + loves函数由于参数复制而持有;
到达Later点输出时,三者的引用值都变为2,这个也没问题。
最后是退出main函数时,三个对象的引用值都减掉1,全部变成了1,没人变为0,所以自然就没人析构了,这就是所谓的循环引用问题!
解决方法很简单,就是把上面代码的第29行的shared_ptr改成weak_ptr就可以了!!!
// 循环引用
#include <iostream>
#include <string>
#include <memory>
using namespace std;
class Human {
public:
Human(string name)
: name(name) {
cout << "Human born: " << name << endl;
}
~Human() {
cout << "Human died: " << name << endl;
}
void loves(shared_ptr<Human> someone) {
lover = someone;
cout << name << " loves " << someone->getName() << " whose use_count = " << someone.use_count() << endl;
}
string getName() const {
return name;
}
private:
string name;
weak_ptr<Human> lover; // 只改动了这里一行!!!
};
int main() {
shared_ptr<Human> I(new Human("jacket"));
shared_ptr<Human> you(new Human("Angela"));
shared_ptr<Human> he(new Human("ivany"));
cout << "First, counts: " << I.use_count() << ' ' << you.use_count() << ' ' << he.use_count() << '\\n' << endl;
I->loves(you);
you->loves(he);
he->loves(I);
cout << "\\nLater, counts: " << I.use_count() << ' ' << you.use_count() << ' ' << he.use_count() << endl;
return 0;
}
为什么这样子能做到呢?因为weak_ptr不直接参与计数(但是可以升级为shared_ptr来看计数值等等),所以自然就没了循环引用。看输出会发现,经过Later一行的时候,计数值都变成了1,再经过main函数的析构,全部就真正析构了。
我觉得,上面的例子还是很toy的,需要深入去找一些工业级别的场景来使用,才能有更深的感触(可惜我暂时还没找到)。
引用一段话来分辨强引用(比如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++ 网络库》。
其它参考资料
以上是关于话说智能指针发展之路的主要内容,如果未能解决你的问题,请参考以下文章