cpp / 2023.02.02

C++11 の 智能指针(Smart Pointer)

C++ 不像 Java 那样有虚拟机动态的管理内存,如果使用裸指针,在程序运行过程中可能就会出现内存泄漏等问题,然而这种问题其实都可以通过 C++11 引入的智能指针来解决。

C++ 不像 Java 那样有虚拟机动态的管理内存,如果使用裸指针,在程序运行过程中可能就会出现内存泄漏等问题,然而这种问题其实都可以通过 C++11 引入的智能指针来解决。

裸指针的内存泄漏问题

前面提到使用裸指针会存在内存泄漏等问题。这里用具体代码来说明:

1class A {};
2void func() {
3  auto p = new A; // 定义 p 为指向 A 对象的裸指针
4  if (...) {
5    throw exception(SomeExceptionReason);
6  }
7  delete p;
8}

如果 func() 运行过程中抛出异常或其他原因中途退出,则 new 出来的内存得不到释放,随着函数退出,p 的生存周期结束,之后这片内存就永远无法被访问,导致内存泄漏

智能指针

智能指针设计出来就是为了解放程序员,使其无需担心是否释放内存的问题。其本质上是对裸指针进行封装,利用 RAII 思想在构造函数中 new,在析构函数中 delete,保证了内存不泄露。

根据不同特性,智能指针分为以下三种。

std::unique_ptr

unique_ptr 是一个独占型的智能指针,它不允许其它 unique_ptr 共享其管理的内部指针,故不允许拷贝构造拷贝赋值,只能通过移动语义来获得资源。

unique_ptr 有两个版本:

  1. 管理单个对象(例如以 new 分配);

    1template<
    2    class T,
    3    class Deleter = std::default_delete<T>
    4> class unique_ptr;
    
  2. 管理动态分配的对象数组(例如以 new[] 分配);

    1template <
    2    class T,
    3    class Deleter
    4> class unique_ptr<T[], Deleter>;
    

其中 Deleter 指定删除器类型,即析构函数中调用的对内存进行释放的可调用对象。一旦某个 unique_ptr 对象以移动方式将资源转移给另一个 unique_ptr,或是在生命周期结束后被系统回收时,其关联的删除器会释放内部指针的所有权(即赋值为 nullptr)。

unique_ptr删除器要求必须是能且仅能够传入 T* 类型参数的左值可调用对象,而不能是 lambda 表达式:

1template<typename T>
2struct deleter {
3  void operator(T* p) { delete p; }
4};
5
6deleter<int> d;
7std::unique_ptr<int, deleter<int>> ptr(new int, d);

用法如下:

 1struct A {
 2  ~A() { std::cout << "~A\n"; }
 3  void Print() { std::cout << "Print\n"; }
 4};
 5const int size = 2;
 6// 两种版本
 7auto ptr = std::unique_ptr<A>(new A);
 8auto ptrs = std::unique_ptr<A[]>(new A[size]);
 9
10// 移动语义
11auto new_ptr = std::unique_ptr<A>(std::move(ptr)); // 此时 ptr 失去了关联对象指针的所有权
12if (ptr) {  // operator bool 检查是否有关联的管理对象
13  std::cout << "ptr is empty";
14}
15std::cout << "\n";
16
17// 成员函数,最常用的就是 get()
18auto raw = new_ptr.get(); // get() 返回 A 类型的指针
19raw->Print();
20
21// 管理单个对象的 unique_ptr
22new_ptr->Print();   // 可以通过 operator-> 解引用,看起来就像一个裸指针一样
23(*new_ptr).Print(); // 可以通过 operator* 解引用
24
25// 管理对象数组的 unique_ptr 可以通过 operator[] 以下标访问对象(而非指针)
26ptrs[1].Print();
27
28/*
29 * output:
30 * ptr is empty
31 * Print
32 * Print
33 * Print
34 * Print
35 * ~A
36 * ~A
37 * ~A
38 */

std::shared_ptr

unique_ptr 相反,shared_ptr 是一个共享型的智能指针,它允许其它 shared_ptr 共享其管理的内部指针,故实现了拷贝构造拷贝赋值,并通过这两种方式进行资源共享。

既然允许共享,那就存在多个 shared_ptr 使用同一片内存,故不能直接在析构时释放内存。在典型的实现中,std::shared_ptr 只保有两个指针:数据指针 _M_ptr 以及计数器指针 _M_refcount

计数器包含:

  • 指向被管理对象的指针或被管理对象本身;
  • 删除器;
  • 分配器;
  • 被管理对象的 shared_ptr 的数量(引用计数);
  • 被管理对象的 weak_ptr 的数量;

每当以拷贝的形式新建了一个 shared_ptr,则指向同一片内存的所有 shared_ptr 的引用计数都会加一,每次析构的时候引用计数减一,在最后一个 shared_ptr 析构的时候(也就是引用计数归零时),对应的内存才会释放。

同为智能指针家族,shared_ptr 也实现了以下功能:

  1. get():获取内部关联的对象指针。
  2. operator* / operator->:解引用关联的指针;
  3. operator bool:检查是否有关联的管理对象;

除此之外,其还对外公开了一个 use_count() 函数,用于返回引用计数值。

shared_ptr 还可以自定义删除器,在引用计数为零的时候自动调用删除器来释放对象的内存,这里删除器只需要是传入 T* 参数的可调用对象即可:

1std::shared_ptr<int> ptr(new int, [](int *p){ delete p; });

shared_ptr 实现上较 unique_ptr 更为宽松,但代价为存在一些安全隐患,需要注意的是:

  1. 不用同一个裸指针初始化多个 shared_ptr,也不要对 get() 返回的裸指针进行 delete,否则会出现 double free 导致出问题;

  2. 不将 this 指针初始化 shared_ptr 并返回,否则会出现 double free,比如:

    1class A {
    2  shared_ptr<A> func() {
    3    return std::shared_ptr<A>(this);
    4  }
    5};
    

    A 本身会调用析构函数,函数返回值由于关联了 this 指针,进行 delete this 时还会调用一遍析构函数。如果非要实现这一功能,请继承自 std::enable_shared_from_this<A>,之后就可以调用 shared_from_this() 来获取一个指向自身的 shared ptr 了。

  3. 尽量用 make_shared 代替 new,比如:

    1class A {
    2  A(int i) { std::cout << i; }
    3};
    4std::shared_ptr<A> p(new int(1));
    5std::shared_ptr<A> q = std::make_shared<A>(1); // 等价于上面那种,but better
    

    make_shared() 为我们提供了一个新的创建共享指针的方法,其函数原型为:

    1template< class T, class... Args >
    2shared_ptr<T> make_shared( Args&&... args );
    

    argsT 的构造函数参数列表,构造 T 类型对象并将它包装于 shared_ptr

    等价于用表达式 new T(std::forward<Args>(args)...) 构造,其中 pv 是内部指向适合保有 T 类型对象的存储的 void* 指针。

  4. 避免循环引用。所谓循环引用,就是存在一个引用通过一系列的引用链,最后引用回自身,且看代码:

     1struct A;
     2struct B;
     3
     4struct A {
     5  std::shared_ptr<B> other;
     6};
     7
     8struct B {
     9  std::shared_ptr<A> other;
    10};
    11
    12int main() {
    13  auto aptr = std::make_shared<A>(); // aptr.count = 1
    14  auto bptr = std::make_shared<B>(); // bptr.count = 1
    15  aptr->other = bptr;                // 由于 copy,bptr.count++
    16  bptr->other = aptr;                // 由于 copy,aptr.count++
    17  return 0;
    18}
    

    这里 aptr 指向了 A 类对象,随后 aptr.other 又与另一个指向 B 类对象的共享指针 bptr 通过拷贝的方式共享了指针,并且 bptr.other 也通过拷贝的方式与 aptr 共享了指针。这样一来 aptr 的 other 指向的对象 bptr,其 other 又指回了自身,从而存在循环引用,并且此时两者的引用计数值均为 2。

    当程序结束时,aptr, bptr 调用析构函数,引用计数值减一,但此时两块内存的引用计数值仍然大于零,永远得不到释放。糟糕透了!

std::weak_ptr

为了解决共享指针可能存在的循环引用问题,weak_ptr 被提出。

weak_ptr 用来表达临时所有权的概念:它不管理指针,也就没有 get() 函数,只能通过 weak_ptr / shared_ptr 进行构造。其拷贝与析构都不会影响引用计数,纯粹是作为一个旁观者监视 shared_ptr 中管理的资源是否存在,该功能可以通过成员函数 expire() 实现——检查被引用的对象是否已删除。

当某个对象只有存在时才需要被访问,而且随时可能被他人删除时,可以使用 weak_ptr 来监视该对象。

当真正需要通过 weak_ptr 去调用那片内存时,需创建一个新的临时 shared_ptr 来共享被管理对象的所有权,此时如果原来的 shared_ptr 被销毁,则该对象的生命周期将被延长至这个临时的 shared_ptr 同样被销毁为止。

具体用法为:

 1int* a = new int{0};
 2std::shared_ptr<int> shared_p(a);
 3std::weak_ptr<int> weak_p = shared_p; // weak_ptr 不共享所有权,仅作监视用
 4std::cout << shared_p.use_count() << " " << weak_p.use_count() << std::endl;
 5
 6auto q = weak_p.lock(); // lock() 创建新的 std::shared_ptr 对象
 7std::cout << shared_p.use_count() << " " << weak_p.use_count() << " " << q.use_count();
 8
 9// output:
10// 1 1
11// 2 2 2

在这样的基础上,weak_ptr 也就能够打破 shared_ptr 中所存在的循环引用现象——令循环中的其中一个指针为 weak_ptr 即可。

 1struct A;
 2struct B;
 3
 4struct A {
 5  std::shared_ptr<B> other;
 6};
 7
 8struct B {
 9  std::weak_ptr<A> other;
10};
11
12int main() {
13  auto aptr = std::make_shared<A>(); // aptr.count = 1
14  auto bptr = std::make_shared<B>(); // bptr.count = 1
15  aptr->other = bptr;
16  bptr->other = aptr;
17  // 不存在 shared_ptr 之间的拷贝,故引用计数值不会发生变化。
18  return 0;
19}

如何手撕一个简单的 shared ptr

首先思考的是:shared ptr 需要支持哪些特性?

  1. 类模板,支持所有类型及其构造函数参数;
  2. 线程安全的计数器;
  3. 拷贝/赋值/移动构造函数;
  4. 支持用派生类构造;
  5. 正确释放指针;
 1
 2template <class T>
 3class SharedPointer {
 4 public:
 5  class Counter {
 6   public:
 7    Counter(T* ptr) : ptr_(ptr), cnt_(0) {}
 8    ~Counter() { delete ptr_; }
 9    void addRef() { cnt_.fetch_add(1); }
10    void release() { cnt_.fetch_sub(1); }
11    int getCount() { return cnt_.load(); }
12    T* ptr_;
13
14   private:
15    std::atomic<int> cnt_;
16  };
17
18 public:
19  SharedPointer(T* ptr) { counter_ = new Counter(ptr); }
20  // copy constructor
21  SharedPointer(const SharedPointer<T>& sp) {
22    counter_ = sp.counter_;
23    counter_->addRef();
24  }
25  SharedPointer& operator=(const SharedPointer<T>& sp) {
26    counter_ = sp.counter_;
27    counter_->addRef();
28  }
29
30  // move constructor
31  SharedPointer(SharedPointer<T>&& sp) {
32    counter_ = sp.counter_;
33    sp.counter_ = nullptr;
34  }
35  SharedPointer& operator=(SharedPointer<T>&& sp) {
36    counter_ = sp.counter_;
37    sp.counter_ = nullptr;
38  }
39
40  // derived constructor
41  template <class U>
42  SharedPointer(U* derive) {
43    assert(std::is_base_of<T, U>::value);
44    counter_ = new Counter(derive);
45  }
46
47  ~SharedPointer() {
48    counter_->release();
49    if (counter_->getCount() == 0) {
50      delete counter_;
51    }
52  }
53
54  T* get() { return counter_->ptr_; }
55
56  bool isNull() { return get() == nullptr; }
57
58 private:
59  Counter* counter_;
60};