shared_ptr的原理与应用(一)

2014-11-24 02:08:41 · 作者: · 浏览: 8
new与赋值的坑
赋值(assignment)和new运算符在C++Java(或C#)中的行为有本质的区别。在Java中,new是对象的构造,而赋值运算是引用的传递;而在C++中,赋值运算符意味着"构造",或者"值的拷贝",new运算符意味着在堆上分配内存空间,并将这块内存的管理权(责任)交给用户。C++中的不少坑,就是由new和赋值引起的。
在C++中使用new的原因除了堆上能定义体积更大的数据结构之外,就是能使用C++中的dynamic dispatch(也叫多态)了:只有指针(和引用)才能使用虚函数来展现多态性。在这时,new出来的指针变得很像Java中的普通对象,赋值意味着引用的传递,方法调用会呈现出多态性,我们进入了面向对象的世界,一切十分美好,除了"要手动释放内存"。
在简单的程序中,我们不大可能忘记释放new出来的内存,随着程序规模的增大,我们忘了delete的概率也随之增大,这是因为C++是如此一个精神分裂的语言,赋值运算符竟然同时展现出"值拷贝"和"引用传递"两种截然不同的语义,这种不一致性导致"内存泄漏"成为C++新手最常犯的错误之一。当然你可以说,只要细心一点,一定能把所有内存泄漏从代码中清除。但手动管理内存更严重的问题在于,内存究竟要由谁来分配和释放呢?指针的赋值将同一对象的引用散播到程序每个角落,但是该对象的删除却只能发生一次,当你在代码中用完这么一个资源指针:resourcePtr,你敢delete它吗?它极有可能同时被多个对象拥有着,而这些对象中的任何一个都有可能在之后使用该资源,而这些对象中的另外一个,可能在它的析构函数中释放该资源。"那我不delete不就行了吗?",你可能这么问,当然行, 这时候你要面对另外一种可能性:也许你是这个指针的唯一使用者,如果你用完不delete,内存就泄漏了。
开发者日常需要在工作中使用不同的库,而以上两种情况可能会在这些库中出现,假设库作者们的性格截然不同,导致这两个库在资源释放上采取了不同的风格,在这个时候,你面对一个用完了的资源指针,是删还是不删呢?这个问题从根本上来说,是因为C++的语言特性让人容易搞错"资源的拥有者"这个概念,资源的拥有者,从来都只能是 系统,当我们需要时便向系统请求,当我们不需要时就让系统自己捡回去(Garbage Collector),当我们试图自己当资源的主人时,一系列坑爹的问题就会接踵而来。
异常安全的类
我们再来看另外一个与new运算符紧密相关的问题:如何写一个异常安全(exception safe)的类。
异常安全简单而言就是:当你的类抛出异常后,你的程序会不会爆掉。爆掉的情况主要包括:内存泄漏,以及不一致的类状态(例如一个字符串类,它的size()方法返回的字符串大小与实际的字符串大小不同),这里仅讨论内存泄漏的情况。
为了让用户免去手动delete资源的烦恼,不少类库采用了RAII风格,即Resource Acquisition Is Initialization,这种风格采用类来封装资源,在类的构造函数中获取资源,在类的析构函数中释放资源,这个资源可以是内存,可以是一个网络连接,也可以是mutex这样的线程同步量。在RAII的感召下,我们来写这么一个人畜无害的类:
class TooSimple {
private:
Resource *a;
Resource *b;
public
TooSimple() {
a = new Resource();
b = new Resource(); //在这里抛出异常
}
~TooSimple() {
delete a;
delete b;
}
};
这个看似简单的类,是有内存泄漏危险的哟!为了理解这一点,首先简单介绍一下C++在抛出异常时所做的事吧:
如果一个new操作(及其调用的构造函数)中抛出了异常,那么它分配的内存空间将自动被释放。
一个函数(或方法)抛出异常,那么它首先将当前栈上的变量全部清空(unwinding),如果变量是类对象的话,将调用其析构函数,接着,异常来到call stack的上一层,做相同操作,直到遇到catch语句。
指针是一个普通的变量,不是类对象,所以在清空call stack时,指针指向资源的析构函数将不会调用。
根据这三条规则,我们很容易发现,如果b = new Resource()句抛出异常,那么构造函数将被强行终止,根据规则1,b分配的资源将被释放(假设Resource类本身是异常安全的),指针a,b从call stack上清除,由于此时构造函数还未完成,所以TooSimple的析构函数也不会被调用(都没构造完呢,现在只是一个"部分初始化"的对象,析构函数自然没理由被调用),a已经被分配了资源,但是call stack被清空,地址已经找不到了,于是delete永远无法执行,于是内存泄漏发生了。
这个问题有一个很直接的"解决"方案,那就是把b = new Resource()包裹在一个try-catch块中,并在catch里将执行delete a,这样做当然没问题,但我们的代码逻辑变得复杂了,且当类需要分配的资源种类增多的时候,这种处理办法会让程序的可读性急剧下降。这时候我们不禁想:要是指针变量能像类对象一样地"析构"就好了,一旦指针具有类似析构的行为,那么在call stack被清空时,指针会在"析构"时实现自动的delete。怀着这种想法,我们写了这么一个类模版:
template
class StupidPointer {
public:
T *ptr;
StupidPointer(T *p) : ptr(p) {}
~StupidPointer() { delete ptr; }
};
有了这个"酷炫"的类,现在我们的构造函数可以这么写:
TooSimple() {
a = StupidPointer(new Resource());
b = StupidPointer(new Resource());
};
由于此时的a,已经不再是指针,而是StupidPointer类,在清空call stack时,它的析构函数被调用,于是a指向的资源被释放了。但是,StupidPointer类有一个严重的问题:当多个StupidPointer对象管理同一个指针时,一个对象析构后,剩下对象中保存的指针将变成指向无效内存地址的"野指针"(因为已经被delete过了啊),如果delete一个野指针,电脑就会爆炸(严肃)。
C++11的标准库提供了两种解决问题的思路:1、不允许多个对象管理一个指针(unique_ptr);2、允许多个对象管理同一个指针,但仅当管理这个指针的最后一个对象析构时才调用delete(shared_ptr)。这两个思路的共同点是:只!允!许!delete一次!
本篇文章里,我们仅讨论shared_ptr。
shared_ptr
在将sha