shared_ptr的原理与应用

两盒软妹~` 提交于 2019-11-28 20:28:01

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++在抛出异常时所做的事吧:

  1. 如果一个new操作(及其调用的构造函数)中抛出了异常,那么它分配的内存空间将自动被释放。
  2. 一个函数(或方法)抛出异常,那么它首先将当前栈上的变量全部清空(unwinding),如果变量是类对象的话,将调用其析构函数,接着,异常来到call stack的上一层,做相同操作,直到遇到catch语句。
  3. 指针是一个普通的变量,不是类对象,所以在清空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 <typename T>

class StupidPointer {

public:

    T *ptr;

    StupidPointer(T *p) : ptr(p) {}

    ~StupidPointer() { delete ptr; }

};

有了这个"酷炫"的类,现在我们的构造函数可以这么写:

TooSimple() {

    a = StupidPointer<Resource>(new Resource());

    b = StupidPointer<Resource>(new Resource());

};

由于此时的a,已经不再是指针,而是StupidPointer<Resource>类,在清空call stack时,它的析构函数被调用,于是a指向的资源被释放了。但是,StupidPointer类有一个严重的问题:当多个StupidPointer对象管理同一个指针时,一个对象析构后,剩下对象中保存的指针将变成指向无效内存地址的"野指针"(因为已经被delete过了啊),如果delete一个野指针,电脑就会爆炸(严肃)。

C++11的标准库提供了两种解决问题的思路:1、不允许多个对象管理一个指针(unique_ptr);2、允许多个对象管理同一个指针,但仅当管理这个指针的最后一个对象析构时才调用delete(shared_ptr)。这两个思路的共同点是:只!允!许!delete一次!

本篇文章里,我们仅讨论shared_ptr。

shared_ptr

在将shared_ptr的使用之前,我们首先来看看它的基本实现原理。

刚才说到,当多个shared_ptr管理同一个指针,仅当最后一个shared_ptr析构时,指针才被delete。这是怎么实现的呢?答案是:引用计数(reference counting)。引用计数指的是,所有管理同一个裸指针(raw pointer)的shared_ptr,都共享一个引用计数器,每当一个shared_ptr被赋值(或拷贝构造)给其它shared_ptr时,这个共享的引用计数器就加1,当一个shared_ptr析构或者被用于管理其它裸指针时,这个引用计数器就减1,如果此时发现引用计数器为0,那么说明它是管理这个指针的最后一个shared_ptr了,于是我们释放指针指向的资源。

在底层实现中,这个引用计数器保存在某个内部类型里(这个类型中还包含了deleter,它控制了指针的释放策略,默认情况下就是普通的delete操作),而这个内部类型对象在shared_ptr第一次构造时以指针的形式保存在shared_ptr中。shared_ptr重载了赋值运算符,在赋值和拷贝构造另一个shared_ptr时,这个指针被另一个shared_ptr共享。在引用计数归零时,这个内部类型指针与shared_ptr管理的资源一起被释放。此外,为了保证线程安全性,引用计数器的加1,减1操作都是原子操作,它保证shared_ptr由多个线程共享时不会爆掉。

这就是shared_ptr的实现原理,现在我们来看看怎么用它吧!(超简单)

std::shared_ptr位于头文件<memory>中(这里只讲C++11,boost的shared_ptr当然是放在boost的头文件中),下面我以代码示例的形式展现它的用法,具体文档可以看这里

// 初始化

shared_ptr<int> x = shared_ptr<int>(new int); // 这个方法有缺陷,下面我会说

shared_ptr<int> y = make_shared<int>();

shared_ptr<Resource> obj = make_shared<Resource>(arg1, arg2); // arg1, arg2是Resource构造函数的参数

// 赋值

shared_ptr<int> z = x; // 此时z和x共享同一个引用计数器

// 像普通指针一样使用

int val = *x;

assert (x == z);

assert (y != z);

assert (x != nullptr);

obj->someMethod();

// 其它辅助操作

x.swap(z); // 交换两个shared_ptr管理的裸指针(当然,包含它们的引用计数)

obj.reset(); // 重置该shared_ptr(引用计数减1)

太好用了!

错误用法1:循环引用

shared_ptr的一个最大的缺点,或者说,引用计数策略最大的缺点,就是循环引用(cyclic reference),下面是一个典型的事故现场:

class Observer; // 前向声明

class Subject {

private:

    std::vector<shared_ptr<Observer>> observers;

public:

    Subject() {}

    addObserver(shared_ptr<Observer> ob) {

        observers.push_back(ob);

    }

    // 其它代码

    ..........

};

class Observer {

private:

    shared_ptr<Subject> object;

public:

    Observer(shared_ptr<Object> obj) : object(obj) {}

    // 其它代码

    ...........

};

目标(Subject)类连接着多个观察者(Observer)类,当某个事件发生时,目标类可以遍历观察者数组observers,对每个观察者进行"通知",而观察者类中,也保存着目标类的shared_ptr,这样多个观察者之间可以以目标类为桥梁进行沟通,除了会发生内存泄漏以外,这是很不错的设计模式嘛!等等,不是说用了shared_ptr管理资源后就不会内存泄漏了吗?怎么又漏了?

这就是引用计数模型失效的唯一的情况:循环引用。循环引用指的是,一个引用通过一系列的引用链,竟然引用回自身,上面的例子中,Subject->Observer->Subject就是这么一条环形的引用链。假设我们的程序中只有一个变量shared_ptr<Subject> p,此时,p指向的对象不仅通过该shared_ptr引用自己,还通过它包含的Observer中的object成员变量引用回自己,于是它的引用计数是2,每个Observer的引用计数都是1。当p析构时,它的引用计数减1,变成2-1=1(大于0!),p指向对象的析构函数将不会被调用,于是p和它包含的每个Observer对象在程序结束时依然驻留在内存中没被delete,形成内存泄漏。

weak_ptr

为了解决这一问题,标准库提供了std::weak_ptr(弱引用),它也位于<memory>中。

weak_ptr是shared_ptr的"观察者",它与一个shared_ptr绑定,但却不参与引用计数的计算,在需要时,它还能摇身一变,生成一个与它所"观察"的shared_ptr共享引用计数器的新shared_ptr。总而言之,weak_ptr的作用就是:在需要时变出一个shared_ptr,在其它时候不干扰shared_ptr的引用计数。

在上面的例子中,我们只需简单地将Observer中object成员的类型换成std::weak_ptr<Subject>即可解决内存泄漏的问题,此刻(接着上面的例子),p指向对象的引用计数为1,所以在p析构时,Subject指针将被delete,其中包含的observers数组在析构时,内部的Observer对象的引用计数也将变为0,故它们也被delete了,资源释放得干干净净。

下面,是weak_ptr的使用方法:

std::shared_ptr<int> sh = std::make_shared<int>();

// 用一个shared_ptr初始化

std::weak_ptr<int> w(sh);

// 变出shared_ptr

std::shared_ptr<int> another = w.lock();

// 判断weak_ptr所观察的shared_ptr的资源是否已经释放

bool isDeleted = w.expired();

错误用法2:多个无关的shared_ptr管理同一裸指针

考虑下面这个情况:

int *a = new int;

std::shared_ptr<int> p1(a);

std::shared_ptr<int> p2(a);

p1和p2同时管理同一裸指针a,与之前的例子不同的是,此时的p1和p2有着完全独立的两个引用计数器(初始化p2时,用的是裸指针a,于是我们没有任何办法获取p1的引用计数!),于是,上面的代码会导致a被delete两次,分别由p1和p2的析构导致,电脑再一次爆炸了。

为了避免这种情况的发生,我们永远不要将new用在shared_ptr构造函数参数列表以外的地方,或者干脆不用new,改用make_shared。

即便我们的程序严格采取上述做法,C++还提供另外一种绕过shared_ptr,直接获取裸指针的方式,那就是this指针。请看下面的事故现场:

class A {

public:

    std::shared_ptr<A> getShared() {

        return std::shared_ptr<A>(this);

    }

};

int main() {

    std::shared_ptr<A> pa = std::make_shared<A>();

    std::shared_ptr<A> pbad = pa->getShared();

    return 0;

}

在此次事故中,pa和pbad拥有各自独立的引用计数器,所以程序将发生相同的"delete野指针"错误。总而言之,管理同一资源的shared_ptr,只能由同一个初始shared_ptr通过一系列赋值或者拷贝构造途径得来。更抽象的说,管理同一资源的shared_ptr的构造顺序,必须是一个无环有向的连通图,无环能够保证没有循环引用,连通性能够保证每个shared_ptr都来自于相同的源。

另外,标准库提供了一种特殊的接口,来解决"生成this指针的shared_ptr"的问题。

enable_shared_from_this

enable_shared_from_this是标准库中提供的接口(一个基类啦):

template<typename T>

class enable_shared_from_this {

public:

    shared_ptr<T> shared_from_this();

}

如果想要一个由shared_ptr管理的类A对象能够在方法内部得到this指针的shared_ptr,且返回的shared_ptr和管理这个类的shared_ptr共享引用计数,只需让这个类派生自enable_shared_from_this<A>即可,之后调用shared_from_this()即可获得正确的shared_ptr。

一般来说,这个接口是通过weak_ptr实现的:enable_shared_from_this中包含一个weak_ptr,在初始化shared_ptr时,构造函数会检测到这个该类派生于enable_shared_from_this(通过模版黑魔法很容易就能实现这个功能啦),于是将这个weak_ptr指向初始化的shared_ptr。调用shared_from_this,本质上就是weak_ptr的一个lock操作:

class A : enable_shared_from_this<A> {

    // ......

};

int main() {

    std::shared_ptr<A> pa = std::make_shared<A>();

    std::shared_ptr<A> pgood = pa->shared_from_this();

    return 0;

}

错误用法3:直接用new构造多个shared_ptr作为实参

之前提到的C++异常处理机制,让我们可以很容易发现下面的代码有内存泄漏的危险:

// 声明

void f(A *p1, B *p2);

// 使用

f(new A, new B);

假如new A先于new B发生(我说"假如",是因为C++的函数参数的计算顺序是不确定的),那么如果new B抛出异常,那么new A分配的内存将会发生泄漏。作为一个刚学会shared_ptr的优秀程序员,我们可以如此"解决"该问题:

// 声明

void f(shared_ptr<A> p1, shared_ptr<B> p2);

// 使用

f(shared_ptr<A>(new A), shared_ptr<B>(new B));

可惜,这么写依然有可能发生内存泄漏,因为两个shared_ptr的构造有可能发生在new A与new B之后,这涉及到C++里称作sequence after,或sequence point的性质,该性质保证:

  1. new A在shared_ptr<A>构造之前发生
  2. new B在shared_ptr<B>构造之前发生
  3. 两个shared_ptr的构造在f调用之前发生

在满足以上三条性质的前提下,各操作可以以任意顺序执行。详情请见Herb Shutter的文章:Exception-Safe Function Calls

make_shared

若我们这么调用f:

f(make_shared<A>(), make_shared<B>());

那么就不可能发生内存泄漏了,原因依然是sequence after性质。sequence after性质保证,如果两个函数的执行顺序不确定(如本例,作为另一个函数的两个参数),那么在一个函数执行时,另一个不会执行(倘若参数是1+1和3 + 3*6这种表达式,那么加法和乘法甚至允许交错执行,sequence after性质真是有够复杂),于是,如果make_shared<A>构造完成了,make_shared<B>中抛出异常,那么A的资源能被正确释放。与上面用new来初始化的情形对比,make_shared保证了第二new发生的时候,第一个new所分配的资源已经被shared_ptr管理起来了,故在异常发生时,能正确释放资源。

一句话建议:总是使用make_shared来生成shared_ptr!

结论

  1. 用shared_ptr,不用new
  2. 使用weak_ptr来打破循环引用
  3. 用make_shared来生成shared_ptr
  4. 用enable_shared_from_this来使一个类能获取自身的shared_ptr
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!