话说智能指针发展之路

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的接口说明
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 1

jacket loves Angela whose use_count = 3
Angela loves ivany whose use_count = 3

Later, 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 1

jacket loves Angela whose use_count = 3
Angela loves ivany whose use_count = 3
ivany loves jacket whose use_count = 3

Later, 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++ 网络库》。

其它参考资料

  1. C++智能指针简单剖析

以上是关于话说智能指针发展之路的主要内容,如果未能解决你的问题,请参考以下文章

2019年机器学习:追踪人工智能发展之路

指针辨析:悬垂指针哑指针野指针智能指针

人工智能的发展之路,居然要从春秋时期讲起?

大数据赋能智能场景新应用 贵阳探索人工智能便民化之路

带有智能指针的 C++11 向量

腾讯电量仪——智能硬件测试工具尝试之路