C++ 智能指针

恰当使用智能指针可以防止内存错误

Posted by kevin on October 31, 2019

preface

最近复习了一些 C++ 方面的知识,越来越觉得编程语言都是相通的,特别是越精通底层的语言就越能够理解那些高级语言的特性,所以 C++ 还是得一直学下去,精通是不可能的,C++ 真的太复杂了,只能说尽量去了解它的这些特性,今天看到了 C++11 出现的新特性:智能指针(smart pointer)

智能指针是什么

说到这个问题之前我们先来看看之前我们申请动态内存时可能会存在的弊端:

内存泄露

我们可以看出下面这个函数是有问题的,每次调用函数时都会在堆上分配内存,但是从来没有将内存收回,从而导致了内存泄漏

void remodel(std::string & str){
    std::string * ps = new std::string(str);
    str = *ps;
    return;
}

计算机科学中,内存泄漏指由于疏忽或错误造成程序未能释放已经不再使用的内存。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费。

内存泄漏通常情况下只能由获得程序源代码的程序员才能分析出来。 –维基百科

想起之前上课的时候老师一直说内存泄露,但是只是一句话带过,又不告诉我们内存泄露会怎样,如果内存泄露的话,那段泄露的内存就不能再被程序利用,导致系统可利用的内存越来越小。因此,如果是小型程序的话倒还好,危害不大,因为物理系统一般都有一个较大的内存量,要是大型程序比如跑在服务器端或者单片机中的程序,如果内存泄露了就会大大降低计算机的性能,除非重启程序,否则迟早出问题。

我自己试了一下,用 while 循环无限调用上面那个函数,录了个 gif 来让大家感受一下内存泄露是怎么个一回事:

memory_leak

可以看到,我电脑的内存使用量一直在上升,还好这个程序比较短,我可以知道他是因为内存泄露了,万一是个后台的大型程序,要是一直让这个程序跑的话,将极大衰减计算机性能。所以给我们提了个醒,一定要养成好习惯,申请了内存的话一定要记得释放

智能指针

回归正题,说说我们的智能指针。通过上面的那段程序我们可以知道,在程序的最后加上一个 delete 关键字释放内存就可以避免内存泄露,但是总有些时候有可能会忘记的,所以这还不是最佳的方法。并且如果程序出现了异常而终止的话,申请的内存也不会被释放,像下面这样:

void remodel(std::string & str){
    std::string * ps = new std::string(str);
    if (something_wrong){
        throw exception();
    }
    str = *ps;
    delete ps;
    return;
}

对于上面这段代码, ps 是我们申请的局部变量,所以程序退出时(无论是正常终止还是异常终止),局部变量都会从栈内存中删除,因此 ps 所占据的内存将会被回收,但是 ps 所指向的内存却不一定被释放,所以,如果 ps 有一个析构函数能在 ps 过期时释放 ps 指向的内存该有多好啊,但是 ps 只是个常规的指针,并不是具有析构函数的类对象。因此,智能指针应时而生!

用智能指针时要包含头文件 #include “memory”

智能指针是一个模板类,构造函数的参数传入一个常规指针变量,它的核心思想就是在该指针过期时通过析构函数删除该指针指向的内存,确保不会出现内存泄露。在 C++ 里面,有四种智能指针模板,分别是 auto_ptrunique_ptrshared_ptrweak_ptr ,他们都定义了类似指针的对象,可以将 new 所获得的地址赋值给这种对象,这样子的话,我们就不用再自己释放这些内存了,在智能指针过期时,这些内存将由智能指针的析构函数自动释放,下图是书上附图,直观地表达了 auto_ptr 的原理

smart_ptr

因此上面那段有问题的程序就可以用智能指针进行如下修改:

void remodel(std::string & str){
    std::auto_ptr ps(new std::string(str));
    if (something_wrong){
        throw exception();
    }
    str = *ps;
    // delete ps; 不必手动删除了
    return;
}

attention

我们应该坚决避免下列使用智能指针的情形:

string vocation("I am kevin");
auto_ptr<string> ps(&vocation);

无论是哪种智能指针,都不能像上面那样使用,因为 vocation 是我们声明的局部变量,存在于栈内存上,当 ps 过期时将会把 delete 用于非堆内存,这是错误的(虽然我用 gcc 并没有报错)。

auto_ptr

auto_ptr 在 C++11 中已经被比摒弃了,但是它已经被用了很多年,所以还是有必要了解一下它的原理,这玩意的构造就像下面这样,其他的智能指针也差不多

auto_ptr<std::string> ps(new std::string("Hello"));
auto_ptr<std::string> ps = auto_ptr<std::string>(new std::string("Hello"));

关于 auto_ptr , 它之所以会被淘汰肯定是有原因的,我们来看看为什么,先来看下面的这段代码:

auto_ptr<std::string> ps(new std::string("Hello"));
auto_ptr<std::string> vocation;
vocation = ps;

如果上面的 ps 和 vocation 不是智能指针而是常规指针,这样子的赋值将会出现什么事情呢?程序将两个指针指向同一个 string 对象,这是不允许的,因为这样子的话会将同一个对象删除两次,一次是 ps 过期时,一次是 vocation 过期时,无图言屌,还是通过数据说话,我们可以看到 ps 和 vocation 都是指向了同一片内存区

memory

上面我是用断点将程序给停住了,然后我用了两个 delete 将指针 ps 和 vocation 释放,再打断点,IDE 都给卡住了,一直在转圈圈哈哈哈哈哈,隐隐约约能看见两个指针的值都是乱的。。

error

为了避免这种问题,有下列几种解决方案:

  1. 定义赋值运算符,使之执行深拷贝,这样子的话两个指针就不会指向同一个对象,而是指向一个对象的副本

  2. 建立所有权(ownership)概念,对于特定对象,只能有一个智能指针能够拥有它,这样的话只有拥有该对象的智能指针的析构函数会删除该对象。然后,通过赋值操作就将所有权转让给另一个智能指针,这就是 auto_ptr 和 unique_ptr 的策略,但是 unique_ptr 会更加严格。我们用下面一段代码来验证一下这个策略:

    void func(){
        auto_ptr<string> ps(new string("Hello"));
        auto_ptr<string> v;
        v = ps;
    }
    

    我们看到了在一开始,ps 指向的地址为 string 对象的地址,在经过了赋值之后,ps 将所有权转让给了 v ,所以 ps 不再指向该地址,变成了一个空指针

    auto_ptr_ownership.gif

  3. 创建智能更高的指针,跟踪引用特定对象的智能指针数。这叫做引用计数(reference counting)。例如,赋值时,计数将会加一,而指针过期时,计数将减一。仅当最后一个指针过期时,才会调用 delete ,这是 shared_ptr 采用的策略。

unique_ptr

unique_ptr 的性能是要优于 auto_ptr 的,因为它避免了一些容易出错的地方,看下面的代码:

auto_ptr<string> p1(new string("Hello"));
auto_ptr<string> p2;
p2 = p1;
cout << *p2 << " " << *p1;

上面已经说了,在 p2 接管了 string 对象的所有权后,p1 的所有权将被剥夺,这可以防止将一个对象销毁两次,表面上是好事,但是如果程序后面试图使用 p1 将会出错,因为 p1 不再指向有效数据。下面看看 unique_ptr 的表现:

unique_ptr<string> p3(new string("Hey"));
unique_ptr<string> p4;
p4 = p3;
cout << *p4 << " " << *p3;

这段代码直接就会报错,因为 unique_ptr 认为这个赋值非法的操作,避免了 p3 不再指向有效数据的问题,因此 unique_ptr 比 auto_ptr 更加安全(比起程序存在 bug ,编译不通过的修改成本低得多)

但是也有一些情况可以将 unique_ptr 赋值给另一个 unique_ptr 并且不会留下危险的悬空指针,那就是当 unique_ptr 是临时右值的时候,如果 unique_ptr 要存在一段时间的话程序还是会报错,就像下面这样:

unique_ptr<string> func(string str){
    unique_ptr<string> tmp(new string(str));
    return tmp;
}
int main()
{
    unique_ptr<string> ps;
    ps = func("Hello");
}

上面这样是编译器允许的操作,因为 func() 返回了一个临时的 unique_ptr ,然后 ps 接管了原本返回的 unique_ptr 所拥有的对象,而返回的 unique_ptr 很快就被销毁,没有机会使用它来访问无效的数据,因此,这种赋值是比较安全的,下面这段代码也是被编译器所允许的:

unique_ptr<string> p5;
p5 = unique_ptr<string>(new string("Hello"));

这样赋值也不会留下悬挂的 unique_ptr ,因为它调用了 unique_ptr 的构造函数,构造函数创建了临时对象,该对象在所有权转让给 p5 后就会被销毁。

相比于 auto_ptr , unique_ptr 还有另外一个优点,那就是它能够使用 new[]delete[] ,可用于申请数组,而 auto_ptr 却不能这样干。

shared_ptr

前面提到了一下,shared_ptr 用的是引用计数的策略,当有智能指针指向特定对象时,计数加一,当有智能指针过期时,计数减一,直到最后一个指针过期时才会调用 delete ,我们看一个更长的例子:

int main(){
    auto_ptr<string> films[5] = {
            auto_ptr<string> (new string("hapi")),
            auto_ptr<string> (new string("baopilong")),
            auto_ptr<string> (new string("xianren")),
            auto_ptr<string> (new string("banban")),
            auto_ptr<string> (new string("yaode"))
    };
    auto_ptr<string> pwn;
    pwn = films[4];
    for (int i = 0; i < 5; i++){
        cout << *films[i] << endl;
    }
    cout << "The winner is : " << *pwn << endl;
}

通过前面的分析我们也已经知道,auto_ptr 的策略就是夺取所有权,因此输出 film[4] 时会出现段错误(segment fault) ,这是因为此时的 film[4] 已经变成了一个空指针,同样的,用 unique_ptr 的话程序会直接在编译阶段报错

segment fault

那么用 shared_ptr 会怎样呢,经实验,完全没问题,pwn 和 films[4] 指向同一个对象,引用计数从 1 加到 2,程序末尾时,后声明的 pwn 首先调用析构函数,该析构函数将引用计数降为 1 ,然后 shared_ptr 数组的成员被释放,调用 films[4] 的析构函数时,将引用计数降到 0 ,并释放以前分配的空间。

shared_ptr

weak_ptr

这个在书上没有讲,去网上搜了一下,还挺有用的,这个弱引用的智能指针的出现是为了解决上面的 shared_ptr 引用计数的引用环问题,也就是说两个对象互相指向对方形成一个环,见下面这段代码(用 use_count() 方法可以获取引用计数)

struct Node{
    shared_ptr<Node> pPre;
    shared_ptr<Node> pNext;
    int val;
};
void fun(){
    shared_ptr<Node> p1(new Node());
    shared_ptr<Node> p2(new Node());
    cout << p1.use_count() << endl;
    cout <<p2.use_count() << endl;
    p1->pNext = p2;
    p2->pPre = p1;
    cout << p1.use_count() << endl;
    cout <<p2.use_count() << endl;
}

程序输出为 1 1 2 2 ,这里的 p1 和 p2 就是互相拷贝引用了,要想释放 p2 就要先释放 p1 ,而要想释放 p1 ,就得释放 p2 ,这样就是 循环引用 ,最后 p1 和 p2 指向的内存空间永远都无法释放掉,解决的办法就是使用 weak_ptr:

struct Node{
    weak_ptr<Node> pPre;
    weak_ptr<Node> pNext;
    int val;
};
void fun(){
    shared_ptr<Node> p1(new Node());
    shared_ptr<Node> p2(new Node());
    cout << p1.use_count() << endl;
    cout <<p2.use_count() << endl;
    p1->pNext = p2;
    p2->pPre = p1;
    cout << p1.use_count() << endl;
    cout <<p2.use_count() << endl;
}
// output: 1 1 1 1

weak_ptr 有两个好处 1、对象被析构了,weakptr会自动等于nullptr 2、weakptr可以还原成sharedptr而不会让引用计数错乱

这两者普通指针都是做不到的。

– 轮子哥

如何选择智能指针

如果程序需要使用多个指向同一对象的指针,那么应该选择 shared_ptr ,STL 容器包含指针,里面很多算法都支持赋值和复制操作,因此这些操作应该用于 shared_ptr 。如果不需要多个指向同一个对象的指针,则可以选择 unique_ptr,如果函数使用 new 分配内存,并且返回指向该内存的真,则将其返回类型声明成 unique_ptr 是个不错的选择。

智能指针的使用可以帮助开发者在一定程度上减少内存泄露的问题,然而大部分的程序员都不了解 C++ 这一机制,感觉书上讲得也不是太够,具体的细节部分还是得从实践工程中才能懂得,weak_ptr 这里讲道理没有看太懂,只浅显地了解他的策略,以后用到了再回来补!

reference

《C++ Primer Plus》

https://juejin.im/post/5c9d9d3b6fb9a070f5067b4e