Bootstrap

C++ 三种智能指针的使用场景

C++98中引入,但是实现有缺陷(使用copy语义转移资源),现已弃用,在实际项目中不应该使用。本文提到的三种智能指针主要指的得是、和。

TL,DL

  • 智能指针的基本哲学是RAII(Resource Acquisition Is Initialization). 将内存申请放在对象构造的时候,而在对象析构的时候自动释放。

  • RAII的思想还应用到例如 之类的用于对锁、线程、socket、handle等资源的管理

  • 把握unique含义,资源的所有权不是共享的,而是被某个对象独占的时候。当资源所有权发生转移,可以通过move或者release进行转移

  • 把握share的含义,对象的所有权是共享的,可以被拷贝

  • 把握weak的含义,是一种“弱指针”,就是指向的资源可能是不可用的(可能是个垂悬指针),需要通过 或者 方法检查,利用这个特性,在可能会失效的场合可以使用(例如缓存、订阅者等)。需要搭配使用,并且可以防止循环引用。

一、RAII

RAII(Resource Acquisition Is Initialization) 资源获得即初始化。首先解释资源的概念:

  • 资源: 程序中需要获取后才能用,然后需要隐式(implictly)或者显式(explicitly)释放的东西。例如内存、文件句柄、socket和锁等。如果没有释放,可能会造成资源泄漏或者程序出错。

  • 资源泄漏在简单调试中可能无法发现,但是在长期运行的时候可能会吃尽系统的可获得的资源,导致程序崩溃

中的一段代码为例:

void send(X* x, string_view destination)
{
    auto port = open_port(destination);
    my_mutex.lock();
    // ... 1
    send(port, x);
    // ... 2
    my_mutex.unlock();
    close_port(port);
    delete x; 
}

在这个函数中需要程序手动释放锁、关闭端口、并且delete x的内存。这样依靠程序员的自觉自律很容易发生遗漏,更糟糕的是,如果在 1 或者 2 处抛出了异常; 2 后面的代码将不会被执行,也就是说释放资源的代码会被跳过,将导致资源泄漏!因此这样无法保证异常安全

所以RAII的思想被提出来了,RAII 的理念是将资源的获取放在类的构造函数里,资源的释放放在类的析构函数里。在类的生存期结束的时候,析构函数会被自动调用,对应的资源将会释放。

例如刚刚的例子可以改成:

void send(unique_ptr x, string_view destination)  // x owns the X
{
    Port port{destination};            // port owns the PortHandle
    lock_guard guard{my_mutex}; // guard owns the lock
    // ...
    send(port, x);
    // ...
} // automatically unlocks my_mutex and deletes the pointer in x

在类的构造函数中获得资源,并且在析构的时候释放:

class Port {
    PortHandle port;
public:
    Port(string_view destination) : port{open_port(destination)} { }
    ~Port() { close_port(port); }
    operator PortHandle() { return port; }

    // port handles can't usually be cloned, so disable copying and assignment if necessary
    Port(const Port&) = delete;
    Port& operator=(const Port&) = delete;
};

RAII 是C++ best practice 最重要的思想之一, 在实际的开发中我们应该尽可能使用。这样才能保证资源安全异常安全

智能指针 和 就是RAII在内存管理上的实践。

二、 vs vs

智能指针的语义是拥有(own)一个对象的所有权,并且控制其生存期。

2.1

  • unique_ptr 没有拷贝语义(unique_ptr),不可以通过拷贝赋值和构造,但是可以通过移动语义进行资源所有权的转移。

  • unique_ptr的使用场景,最常见的就是拥有一个一个对象的所有权,就像传统C指针的最基础用法,保存一个申请于堆上的对象

  • 构造,使用工厂函数 ,更加简洁, 这是更加异常安全的使用方式(在复杂的表达式中,)

  auto q = std::make_unique(1);//better

2.2

  • shared_ptr 内部有引用计数,在对象所有权需要共享的时候(share)用,shared_ptr具有赋值拷贝的语义。

  • 用法:

  • 作为需要保存在容器里的对象,同时避免频繁创建引起性能上的开销

  • 如果一个类的创建需要比较多的资源(例如比较大的的内存和拷贝),如果我们直接保存在容器里可能会在拷贝时产生比较大的性能损失,这个时候可以考虑使用,然后将保存于容器。

     vector> foos;
     // ...
     for(auto &foo : foos){
         process_func(*foo);
     }

     // tranditionally 
     FILE *fp = fopen("./1.txt","r");
     // ...
     // ...
     fclose(fp);
     //-------
     // 通过使用定制删除器, 将删除器作为回调函数传入
     shared_ptr fp1(fopen("./1.txt","r"),fclose);

例如上面这个例子,在fp1生命周期结束的时候,将会调用而不是。 这个例子参考了

2.2

  • weak_ptr 的语义是并不真正own一个对象的所有权,而是需要在使用的时候检查一下指针的有效性,可以应用于可能失效的场景,例如缓存、观察者模式的订阅者等等。也应用于打破shared_ptr代理的循环引用无法析构的问题。

  • 例1

  #include 
  #include  // for std::shared_ptr
  #include 
  
  class Person
  {
    std::string m_name;
    std::shared_ptr m_partner; // initially created empty
  
  public:
  
    Person(const std::string &name): m_name(name)
    {
      std::cout << m_name << " created\n";
    }
    ~Person()
    {
      std::cout << m_name << " destroyed\n";
    }
  
    friend bool partnerUp(std::shared_ptr &p1, std::shared_ptr &p2)
    {
      if (!p1 || !p2)
        return false;
  
      p1->m_partner = p2;
      p2->m_partner = p1;
  
      std::cout << p1->m_name << " is now partnered with " << p2->m_name << "\n";
  
      return true;
    }
  };
  
  int main()
  {
    auto lucy = std::make_shared("Lucy") ; // create a Person named "Lucy"
    auto ricky = std::make_shared("Ricky"); // create a Person named "Ricky"
  
    partnerUp(lucy, ricky); // Make "Lucy" point to "Ricky" and vice-versa
  
    return 0;
  }

​ 在这个例子中产生了循环引用 ,在析构的时候将会尬住,lucky和ricky都无法正确析构。跑一下这个程序会发现析构函数没有被调用。解决办法就是使用 来替代 。

  • 例子2, 使用 保存二叉树的parent节点,作用于1相似,也是用于打破循环引用带来的资源泄漏。

    #include 
    
    struct node
    {
       std::shared_ptr left_child;
       std::shared_ptr right_child;
       std::weak_ptr parent;
       foo data;   
    };

  • 例子3. 带缓存的工厂函数

  • 在一些代价高昂的场景,例如操作了文件或者数据库I/O, 并且ID会被频繁重复使用,加上缓存可以优化性能,但是缓存需要在一定期限过期。

  • 因为要让调用者决定对象生存期,所以不能用

  • 因为工厂内部需要保存一个对象的缓存,所以不能用

  • 因为缓存管理器需要检查指针是否空悬,所以不能用裸指针

    class Base{
      //...  
    };
    class Foo:public Base{
      //  ...
    };
    class Bar:public Base{
       // ...
    }
    enum Type{
        k_FOO,
        k_Bar
    }
    std::shared_ptr MakeInstance(Type t){
        switch(t){
            case k_FOO:
               return make_shared();
            case k_BAR:
               return make_shared();
            default:
                return nullptr;
        }
    }
    std::shared_ptr FooFactory(Type t){
       static std::unordered_map> cache;
       auto p = cache[t].lock();
       if(!p){
           p = MakeInstance(t);
           cache[t] = p;
       }
       return p;
    }

  • 例子4. 观察者模式的订阅者

  • 观察者模式中,每个topic可以用容器保存一个观察者的指针,在有消息更新的时候,推送给订阅者。

  • 这个时候使用 的好处是,当观察者结束其生存期的时候,topic可以检查其是否expired,如果已经失效,则不去访问和推送。

​ 注: 例3和例4参考了

Best Practices

下面关于 Best Practices 的一些条款援引自 C++ Core Guidelines,建议细细阅读,可以转为实际的coding经验。

  • Resource 中关于指针相关的论述

  • 避免使用裸指针来拥有(owning)一个对象

  • 避免使用显式的使用 和

  • Smart pointer rule summary:

其他Tips

  • 对于 Qt 的QObject及子类对象,Qt框架使用 parent-children 的方式进行内存回收,此时不应该使用智能指针, 遵循 Qt 的设计即可