C++智能指针

写在前面

内存泄漏问题:也就是我们常说的动态内存分配。在程序员手动进行堆空间的分配时(指针无法控制所指堆空间的生命周期),往往在写完程序的时候,很容易会忘了释放已经手动分配的内存大小,导致软件Bug不断(也就是内存泄漏)。

在C++语言里面没有垃圾回收的机制(不像高级语言Java有自动的垃圾回收机制),所以程序员在写程序的时候,经常会发生刚才上面说的那种情况。当我们写一个new语句时,一般就会立即把delete语句直接也写了,但是我们不能避免程序还未执行到delete时就跳转了或者在函数中没有执行到最后的delete语句就返回了,如果我们不在每一个可能跳转或者返回的语句前释放资源,就会造成内存泄露。

这里我们来看一个例子:

#include <iostream>
#include <string>
using namespace std;
class Test
{
    int i;
public:
    Test(int i){
        this->i = i;
    }
    int value(){
        return i;
    }
    ~Test(){}
};

int main(){
    for(int i=0; i<5; i++)
    {
        Test* p = new Test(i);    
        cout << p->value() << endl;
    }    
    return 0;
}

注意:上面分配的堆空间,没有释放掉!!!

使用智能指针可以很大程度上的避免上面这个问题。智能指针就是一个类,这个类的构造函数中传入一个普通指针,析构函数中释放传入的指针。智能指针的类都是栈上的对象,所以当函数(或程序)结束时,也就是当超出了类的作用域时,类会自动调用析构函数,析构函数会自动释放资源。

正文:C++ 智能指针的正确使用方式

一. unique_ptr:专属所有权

unique_ptr 代表的是专属所有权,即由 unique_ptr 管理的内存,只能被一个对象持有。所以,unique_ptr 不支持复制和赋值,如下:

auto w = std::make_unique<Widget>();
auto w2 = w; // 编译错误

如果想要把 w 复制给 w2, 是不可以的。因为复制从语义上来说,两个对象将共享同一块内存。

unique_ptr 只支持移动, 即如下:

auto w = std::make_unique<Widget>();
auto w2 = std::move(w); // w2 获得内存所有权,w 此时等于 nullptr

unique_ptr 代表的是专属所有权,如果想要把一个 unique_ptr 的内存交给另外一个 unique_ptr 对象管理。只能使用 std::move 转移当前对象的所有权。转移之后,当前对象不再持有此内存,新的对象将获得专属所有权。如上代码中,将 w 对象的所有权转移给 w2 后,w 此时等于 nullptr,而 w2 获得了专属所有权。

性能

因为 C++ 的 zero cost abstraction 的特点,unique_ptr 在默认情况下和裸指针的大小是一样的。
所以 内存上没有任何的额外消耗,性能是最优的

使用场景 1:忘记 delete

unique_ptr 一个最简单的使用场景是用于类属性。如下:

class Box{
public:
    Box() : w(new Widget())
    {}

    ~Box()
    {
        // 忘记 delete w
    }
private:
    Widget* w;
};

如果因为一些原因,w 必须建立在堆上。如果用裸指针管理 w,那么需要在析构函数中 delete w;这种写法虽然没什么问题,但是容易漏写 delete 语句,造成内存泄漏。
如果按照 unique_ptr 的写法,不用在析构函数手动 delete 属性,当对象析构时,属性 w 将会自动释放内存。

使用场景 2:异常安全

假如我们在一段代码中,需要创建一个对象,处理一些事情后返回,返回之前将对象销毁,如下所示:

void process()
{
    Widget* w = new Widget();
    w->do_something(); // 可能会发生异常
    delete w;
}

在正常流程下,我们会在函数末尾 delete 创建的对象 w,正常调用析构函数,释放内存。但是如果 w->do_something() 发生了异常,那么 delete w 将不会被执行。此时就会发生 内存泄漏。我们当然可以使用 try…catch 捕捉异常,在 catch 里面执行 delete,但是这样代码上并不美观,也容易漏写。如果我们用 std::unique_ptr,那么这个问题就迎刃而解了。无论代码怎么抛异常,在 unique_ptr 离开函数作用域的时候,内存就将会自动释放。

二. shared_ptr:共享所有权

在使用 shared_ptr 之前应该考虑,是否真的需要使用 shared_ptr, 而非 unique_ptr。shared_ptr 代表的是共享所有权,即多个 shared_ptr 可以共享同一块内存。因此,从语义上来看,shared_ptr 是支持复制的。如下:

auto w = std::make_shared<Widget>();
{
    auto w2 = w;
    cout << w.use_count() << endl;  // 2
}
cout << w.use_count() << endl;  // 1

shared_ptr 内部是利用引用计数来实现内存的自动管理,每当复制一个 shared_ptr,引用计数会 + 1。当一个 shared_ptr 离开作用域时,引用计数会 - 1。当引用计数为 0 的时候,则 delete 内存。

同时,shared_ptr 也支持移动。从语义上来看,移动指的是所有权的传递。如下:

auto w = std::make_shared<Widget>();
auto w2 = std::move(w); // 此时 w 等于 nullptr,w2.use_count() 等于 1

我们将 w 对象 move 给 w2,意味着 w 放弃了对内存的所有权和管理,此时 w 对象等于 nullptr。而 w2 获得了对象所有权,但因为此时 w 已不再持有对象,因此 w2 的引用计数为 1。

性能

    1. 内存占用高
      shared_ptr 的内存占用是裸指针的两倍。因为除了要管理一个裸指针外,还要维护一个引用计数。
      因此相比于 unique_ptr, shared_ptr 的内存占用更高

    2. 原子操作性能低
      考虑到线程安全问题,引用计数的增减必须是原子操作。而原子操作一般情况下都比非原子操作慢。

    3. 使用移动优化性能
      shared_ptr 在性能上固然是低于 unique_ptr。而通常情况,我们也可以尽量避免 shared_ptr 复制。
      如果,一个 shared_ptr 需要将所有权共享给另外一个新的 shared_ptr,而我们确定在之后的代码中都不再使用这个 shared_ptr,那么这是一个非常鲜明的移动语义。
      对于此种场景,我们尽量使用 std::move,将 shared_ptr 转移给新的对象。因为移动不用增加引用计数,性能比复制更好。

使用场景

  1. shared_ptr 通常使用在共享权不明的场景。有可能多个对象同时管理同一个内存时。
  2. 对象的延迟销毁。

三. weak_ptr

weak_ptr是为了解决 shared_ptr 双向引用的问题。如下:

class B;
struct A{
    shared_ptr<B> b;
};
struct B{
    shared_ptr<A> a;
};
auto pa = make_shared<A>();
auto pb = make_shared<B>();
pa->b = pb;
pb->a = pa;

pa 和 pb 存在着循环引用,根据 shared_ptr 引用计数的原理,pa 和 pb 都无法被正常的释放。对于这种情况, 我们可以使用 weak_ptr:

class B;
struct A{
    shared_ptr<B> b;
};
struct B{
    weak_ptr<A> a;
};
auto pa = make_shared<A>();
auto pb = make_shared<B>();
pa->b = pb;
pb->a = pa;

weak_ptr 不会增加引用计数,因此可以打破 shared_ptr 的循环引用。通常做法是 parent 类持有 child 的 shared_ptr, child 持有指向 parent 的 weak_ptr。这样也更符合语义。


拓展:选择哪种指针作为函数的参数

很多时候,函数的参数是个指针。这个时候就会面临选择困难症,这个参数应该怎么传,应该是 shared_ptr,还是 const shared_ptr&,还是直接 raw pointer 更合适。

1.只在函数使用指针,但并不保存对象内容
假如我们只需要在函数中,用这个对象处理一些事情,但不打算涉及其生命周期的管理,也不打算通过函数传参延长 shared_ptr 的生命周期。对于这种情况,可以使用 raw pointer 或者 const shared_ptr&。即:

void func(Widget*);
void func(const shared_ptr<Widget>&)

2.在函数中保存智能指针

假如我们需要在函数中把这个智能指针保存起来,这个时候建议直接传值。

void func(std::shared_ptr<Widget> ptr);

这样的话,外部传过来值的时候,可以选择 move 或者赋值。函数内部直接把这个对象通过 move 的方式保存起来。这样性能更好,而且外部调用也有多种选择。

参考博客:https://www.cyhone.com/articles/right-way-to-use-cpp-smart-pointer/

C++智能指针

上一篇:Go语言基础之for与for range的区别


下一篇:Python - 列表生成式