C++智能指针


前几天米哈游一面,面试官很帅,很温柔,问的也很基础,不过有些会结合场景进行追问。由于是第一次面试,整个人都挺紧张,感觉基础确实还是不扎实,有些很基础但没怎么用到过的东西就没太关注。这篇文章总结下面试遇到的C++智能指针问题。

介绍

智能指针主要有 unique_ptrshared_ptrweak_ptrauto_ptr 是在 C++ 11 之前作为智能指针实验品发明出来,但由于其违反人直觉的特性(后面会补充提到),在C++11提出右值引用与移动语义后,其已被弃用。

个人认为智能指针是对C++ RAII(Resource Acquisition Is Initialization,资源获取即初始化)的一种极佳实践。RAII要求,资源的有效期与持有资源的对象的生命期严格绑定,即由对象的构造函数完成资源的分配(获取),同时由析构函数完成资源的释放。在这种要求下,只要对象能正确地析构,就不会出现资源泄露问题。

用法

unique_ptr

#include <memory>
#include <iostream>

struct A {
  A() { std::cout << "create class A\n"; }
  void show() { std::cout << "class A\n"; }
  ~A() { std::cout << "delete class A\n"; }
};

int main() {
  // // std::unique_ptr<A> a(new A); 这种写法不推荐,后续会讲到
  auto a = std::make_unique<A>();
  a->show();
  return 0;
}

输出:

create class A
class A
delete class A

可见除了可以自动管理资源以外,就和一个普通的指针的用法差不多。不过,如同unique的含义一样,unique_ptr显式删除了拷贝构造函数与赋值运算符,以保证资源被独立管理。如果想要将一个unique的资源给另一个,则只能使用移动语义转移资源。

std::unique_ptr<A> b = std::move(a);

当然,智能指针里面有一些通用的函数,但是个人感觉那些函数其实都有打破指针管理的倾向,应当少用,初始化完后当成普通指针用是最好的。

shared_ptr

#include <iostream>
#include <memory>

struct A {
  A() { std::cout << "create class A\n"; }
  void show() { std::cout << "class A\n"; }
  ~A() { std::cout << "delete class A\n"; }
};

int main() {
  auto a = std::make_shared<A>();
  auto b(a);
  return 0;
}

输出:

create class A
delete class A

可见shared_ptr就是在内部只保留了一个对象,当指向一个对象的所有shared_ptr全部析构,对象资源才会被释放。其内部是使用引用计数实现的。需要说明的是,shared_ptr的引用计数是线程安全的,用了原子类型。

可见当 _Sp_counted_base 模板类型为 _S_atomic 时,引用计数的增加使用CAS(compare and swap)原语(一般是指由若干条指令组成的程序段,用来实现某个特定功能,在执行过程中不可被中断)。CAS大致是这样操作的,将预期值和内存地址的实际值比较,若不相等,则用内存地址的实际值替换预期值,并根据预期值重新计算修改值。当发现预期值和内存地址的实际值相等后,则用修改值替换内存地址实际值,此时CAS原语操作成功。而失败后重新尝试的过程被称为自旋。这是乐观锁的一种,其适用于临界区比较小、并发量比较低的情况。当临界区比较大、并发量比较高时,用互斥锁为更好的选择。

而从上图可以看到,默认的模板类型便是 _S_atomic ,因此 shared_ptr 内部的引用计数是线程安全的。

循环引用问题

但是,当两个类互相存有指向对方的shared_ptr时,析构时会导致引用计数均为1,双方的资源都无法释放。

如下所示

#include <memory>
#include <iostream>

struct A;
struct B;

struct A {
  std::shared_ptr<B> pb;
  A() { std::cout << "create class A\n"; }
  ~A() { std::cout << "delete class A\n"; }
};

struct B {
  std::shared_ptr<A> pa;
  B() { std::cout << "create class B\n"; }
  ~B() { std::cout << "delete class B\n"; }
};


int main() {
  auto a = std::make_shared<A>();
  auto b = std::make_shared<B>();
  a->pb = b;
  b->pa = a;
  return 0;
}

输出:

create class A
create class B

可见析构函数并没有并没有被调用。这个问题需要用后面说到的weak_ptr解决。

enable_shared_from_this

当一个对象需要从内部获取一个指向自己的 std::shared_ptr 时,很容易想到一种方法

struct Test {
  std::shared_ptr<Test> getPtr() {
    return std::shared_ptr<Test>(this);
  }
};

但若该对象已经被一组共享指针指向,这样返回便会重新创建一组共享指针。这样,最后析构时便会对Test对象进行两次delete,发生错误。

这种错误可以通过在类中保存一个 weak_ptr 成员来避免,也可以使用标准库的enable_shared_from_this解决。

标准库提供了一种使用 CRTP(Curiously Recurring Template Pattern) 方法的基类来避免这个问题,大致如下使用

struct Test : std::enable_shared_from_this<Test> {
  std::shared_ptr<Test> getPtr() {
    return shared_from_this();
  }
};

当一个类继承自 enable_shared_from_this 时,这个类的实例在创建时,会额外分配一个 weak_ptr 类型的数据成员 _weak_this,用来记录一个弱引用(weak reference)指向自身。同时,enable_shared_from_this 还提供了一个 shared_from_this() 方法,用来返回一个指向自身的 shared_ptr

当调用 shared_from_this() 方法时,它会先检查 _weak_this 是否已经被初始化,如果没有被初始化,就抛出一个 std::bad_weak_ptr 异常。否则,它会返回一个指向自身的 shared_ptr,这个 shared_ptr 的计数器会与所有其他指向该对象的 shared_ptr 共享一个计数器。

enable_shared_from_this 的一个常见用法是在实现异步操作的回调函数中。在这种情况下,需要将一个回调函数绑定到一个异步操作中,并且当异步操作完成时,需要调用回调函数并将某些数据传递给它。如果回调函数需要访问异步操作所使用的对象,就可以使用 enable_shared_from_this 来获取一个指向该对象的 std::shared_ptr

另一个使用场景是在对象之间建立弱引用(weak reference)。通过 std::weak_ptr 可以实现对被管理对象的引用,但需要先将其与 std::shared_ptr 绑定。而通过 enable_shared_from_this 可以方便地获取一个 std::shared_ptr,从而避免手动维护 std::shared_ptr 的引用计数和生命周期。

weak_ptr

std::weak_ptr 用来表达临时所有权的概念:当某个对象只有存在时才需要被访问,而且随时可能被他人删除时,可以使用 std::weak_ptr 来跟踪该对象。需要获得临时所有权时,则将其转换为 std::shared_ptr,此时如果原来的 std::shared_ptr 被销毁,则该对象的生命期将被延长至这个临时的 std::shared_ptr 同样被销毁为止。

#include <iostream>
#include <memory>

struct A {
  A() { std::cout << "create class A\n"; }
  void show() { std::cout << "class A\n"; }
  ~A() { std::cout << "delete class A\n"; }
};

std::weak_ptr<A> gw;

void observe() {
  std::cout << "use_count == " << gw.use_count() << ": ";
  if (auto spt = gw.lock()) { // 使用之前必须复制到 shared_ptr
    spt->show();
  } else {
    std::cout << "gw is expired\n";
  }
}

int main() {
  {
    auto sp = std::make_shared<A>();
    gw = sp;

    observe();
  }

  observe();
}

输出:

create class A
use_count == 1: class A
delete class A
use_count == 0: gw is expired

std::weak_ptr 的另一用法是打断 std::shared_ptr 所管理的对象组成的环状引用。若这种环被孤立(例如无指向环中的外部共享指针),则 shared_ptr 引用计数无法抵达零,而内存被泄露。能令环中的指针之一为弱指针以避免此情况。

上面shared_ptr循环引用的例子这样修改后,便可以正常释放资源

#include <memory>
#include <iostream>

struct A;
struct B;

struct A {
  std::shared_ptr<B> pb;
  A() { std::cout << "create class A\n"; }
  ~A() { std::cout << "delete class A\n"; }
};

struct B {
  std::weak_ptr<A> pa;
  B() { std::cout << "create class B\n"; }
  ~B() { std::cout << "delete class B\n"; }
};


int main() {
  auto a = std::make_shared<A>();
  auto b = std::make_shared<B>();
  a->pb = b;
  b->pa = a;
  return 0;
}

输出:

create class A
create class B
delete class A
delete class B

智能指针的创建

《Effective Modern C++》中的条款 21有写到:优先使用 std::make_unique 和 std::make_shared 而不是直接使用new,在上面的所有代码的示例中,我也都遵守了这一条款。那么,为什么要这样呢?

一、避免代码重复

auto a = make_unique<VeryLongClass>();
unique_ptr<VeryLongClass> b(new VeryLongClass);

自行体会

二、保证异常安全

void processWidget(std::shared_ptr<Widget> spw, int priority);

考虑上面的代码,如果有一个计算 priority的函数

int computePriority()

那么,下面这段代码就有可能内存泄露

processWidget(std::shared_ptr<Widget>(new Widget), computePriority())

因为进入 processWidget 前,包含三步操作

执行new Widget,执行computePriority,执行std::shared_ptr的构造函数

但这三步顺序是不一定的,如果 computePriority 先执行,那么当其发生异常,就无事发生。

但是如果执行顺序上面所列的顺序一样,那么当 computePriority 发生异常,那么new出来的Widget就会发生内存泄露,因为它永远不会存储在Step 3中产生的本应负责管理它 的 std::shared_ptr 中。

而如果使用make_shared,如下

processWidget(std::make_shared<Widget>(),computePriority);

便不会有内存泄露的风险。在runtime的时候, std::make_shared 或者computePriority都有可能被第一次调用。如果是 std::make_shared 先被调用,被动态分配的Widget安全的存储在返回的 std::shared_ptr 中 (在computePriority被调用之前)。如果computePriority产生了异常, std::shared_ptr 的析构函数会负责把它所拥有的Widget回收。如果computePriority首先被调用并且产生出一个异常, std::make_shared 不会被调用,因此也不必担心动态分配的Widget会产生泄漏的问题。

三、提升效率

std::shared_ptr<Widget> spw(new Widget);

很明显的情况是代码只需一次内存分配,但实际上它执行了两次。每一个 std::shared_ptr,都指向了一个包含被指向对象的引用计数的控制块,控制块的分配工作在 std::shared_ptr 的构造函数内部完成。直接使用new,就需要一次为Widget分配内存,第二次需要为控制块分配内存。

如果使用的是 std::make_shared

auto spw = std::make_shared<Widget>();

一次分配足够了。这是因为std::make_shared分配了一整块空间,包含了Widget对象和控制块。这个优化减少了程序的静态大小,因为代码中只包含了一次分配调用,并且加快了代码的执行速度,因为内存只被分配一次。此外,使用std::make_shared 避免了在控制块中额外添加的一些记录信息的需要,潜在的减少了程序所需的总内存消耗。

当然,make之类的函数也有一定的缺陷,比如不支持指定自定义的deleter。

深入理解

深入理解一样东西最好的办法,就是实现它。

unique_ptr

template<typename T>
class unique_ptr {
 public:
  explicit unique_ptr(T *ptr = nullptr) : ptr_(ptr) {}
  ~unique_ptr() { delete ptr_; }

  unique_ptr(const unique_ptr &) = delete;
  unique_ptr &operator=(const unique_ptr &) = delete;

  T &operator*() const { return *ptr_; }
  T *operator->() const { return ptr_; }

  T *get() const { return ptr_; }
  T *release() {
    T *ptr = ptr_;
    ptr_ = nullptr;
    return ptr;
  }

 private:
  T *ptr_;
};

在上面的代码中,unique_ptr 包含一个指向 T 类型对象的指针 _ptr。它的构造函数可以接受一个指针作为参数,并将其包装在 unique_ptr 对象中。在析构函数中,它会自动调用 delete 操作来销毁对象。同时,为了防止拷贝和赋值,我们禁止了 unique_ptr 的拷贝构造函数和赋值操作符。为了让 unique_ptr 用起来更加方便,我们还重载了 operator* 和 operator-> 运算符,以及提供了 get() 和 release() 方法。

shared_ptr

template<typename T>
class RefCounter {
 public:
  explicit RefCounter(T *ptr) : ptr_(ptr), count_(1) {}
  ~RefCounter() { delete ptr_; }
  void addRef() { ++count_; }
  void release() {
    --count_;
    if (count_ == 0) { delete this; }
  }
  int count() { return ptr_ ? count_ : 0; }
  T *get() const { return ptr_; }

 private:
  T *ptr_;
  int count_;
};

template<typename T>
class shared_ptr {
 public:
  explicit shared_ptr(T *ptr = nullptr) : counter_(new RefCounter<T>(ptr)) {}
  shared_ptr(const shared_ptr<T> &other) : counter_(other.counter_) {
    counter_->addRef();
  }
  ~shared_ptr() { counter_->release(); }
  int use_count() { return counter_->count(); }
  T *operator->() const { return counter_->get(); }
  T &operator*() const { return *(counter_->get()); }

 private:
  RefCounter<T> *counter_;
};

核心点在于引用计数的实现,而只有一个类的话,其自身是无法完成的,需要借助于一个计数类

use_count 方法返回当前指针指向的对象被多少指针共享

weak_ptr

template<typename T> class weak_ptr;

template<typename T>
class RefCounter {
 public:
  explicit RefCounter(T *ptr) : ptr_(ptr), count_(1), weak_count_(1) {}
  void addRef() { ++count_; }
  void release() {
    if (--count_ == 0) {
      delete ptr_;
      if (--weak_count_ == 0) { delete this; }
    }
  }
  void weak_release() {
    if (--weak_count_ == 0) { delete this; }
  }
  void add_weak_reference() { ++weak_count_; }
  int count() { return ptr_ ? count_ : 0; }
  T *get() const { return ptr_; }

 private:
  T *ptr_;
  int count_;
  int weak_count_;
};

template<typename T>
class shared_ptr {
 public:
  explicit shared_ptr(T *ptr = nullptr) : counter_(new RefCounter<T>(ptr)) {}
  shared_ptr(const shared_ptr<T> &other) : counter_(other.counter_) {
    counter_->addRef();
  }
  shared_ptr(const weak_ptr<T> &other) : counter_(other.counter_) {
    counter_->addRef();
  }
  ~shared_ptr() { counter_->release(); }
  int use_count() { return counter_->count(); }
  T *operator->() const { return counter_->get(); }
  T &operator*() const { return *(counter_->get()); }

 private:
  RefCounter<T> *counter_;
  friend class weak_ptr<T>;
};

template<typename T>
class weak_ptr {
 public:
  weak_ptr() : counter_(nullptr) {}
  explicit weak_ptr(const shared_ptr<T> &sp) : counter_(sp.counter_) {
    counter_->add_weak_reference();
  }
  ~weak_ptr() { counter_->weak_release(); }

  shared_ptr<T> lock() const {
    if (expired()) { return shared_ptr<T>(); }
    return shared_ptr<T>(*this);
  }

  [[nodiscard]] bool expired() const {
    return counter_ == nullptr or counter_->count() == 0;
  }

 private:
  RefCounter<T> *counter_;
  friend class shared_ptr<T>;
};

其中,weak_ptr 类包含一个指向对象的裸指针 ptr_ 和一个指向计数器对象的指针 counter_。同时,它还包含了 lock() 方法和 expired() 方法。

lock() 方法用于将 weak_ptr 转换为一个 shared_ptr 对象,如果指向对象的 shared_ptr 已经失效,返回一个空的 shared_ptr

expired() 方法用于判断指向的 shared_ptr 对象是否已经失效。如果 counter_ 指针为空或者计数器对象的引用计数已经为零,则表示指向的 shared_ptr 对象已经失效。

需要注意的是,weak_ptr 必须与 shared_ptr 共用同一个计数器对象,因此在构造函数中需要将 shared_ptr 对象的指针和计数器对象的指针都保存下来。

此处标准库实现是采取一个强计数与一个弱计数,强计数为0则释放资源,弱计数为0则析构计数类。

弃用的auto_ptr

从失败中吸取教训,才能够成功。auto_ptr便是C++委员会吸取的一次教训。

它的想法和unique_ptr一样,就是用来独立地管理内存。但是在C++11之前,还没有移动语义和右值引用。那是怎样实现独立管理内存的呢?

上面这些玩意在一起,就会达成这样一个效果。当你写下如下语句时

auto_ptr<mother> a;
auto b(a); 
decltype(a) c;
c = b;

ab 的 mother 就没了!!!!

auto b(a); 这个代码,a将对象所有权转移给了b,自己置空了。

c = b; 这个代码,b将对象所有权转移给了c,自己置空了。

但这还不是最狠的,看下面的代码

vector<auto_ptr<mother>> a;
  // ...Some code to input a
sort(a.begin(), a.end(), [](auto_ptr<mother> &A, auto_ptr<mother> &B){
	return A->a < B->a;
});

一趟排序下来,由于内部的swap操作,很多指针的 mother 都没了!!!!!

因此,在C++11中,为了完善类型系统,使语法更加优雅,使值的传递更加高效,减少拷贝等等,引入了伟大的右值引用与移动语义!!!

现在 swap 是这样实现的

而 auto_ptr 也被更换成了 unique_ptr。到此,auto_ptr的历史就结束了。

罕见的用法

前面都是管理单个对象,其实智能指针还能管理数组对象

这样子用的话,ptr 的模板类型是这个

有些人可能会觉得这为什么会罕见呢?呃,可能是因为有了vector了。反正我很少在实际项目中看到。

个人的理解

unique_ptr 经过编译器优化以后是完全零开销的,可以放心大胆地使用它来管理资源。

但 shared_ptr 其内部的原子计数对于性能是有一定的影响的,而其解决方案enable_shared_from_this用例下,基于shared_ptr的解决方案也并非是非侵入式的。并且,其还具有传播性,当在一个地方使用后,传入传出其他地方,往往又需要shared_ptr(否则对象资源释放会不正确),最后整个项目便到处是shared_ptr。所以使用时一定要小心,要少用。

那么如何减少shared_ptr的使用呢?其实仔细检视一下整个异步流程,有些资源虽然会先后被不同的对象所引用,但在其整个生存周期内,每一时刻都只有一个对象持有该资源的引用。用于数据收发的缓冲区对象就是一个典型。它们总是被从某个源头产生,然后便一直从一处被传递到另一处,最终在某个时刻被回收。

对于这样的对象,实际上没有必要针对流程中的每一次所有权转移都进行引用计数操作,只要简单地在分配时将引用计数置1,在需要释放时再将引用计数归零便可以了。

参考:

chatGPT

cppreference

《C++ 共享指针四宗罪》

《Effective Modern C++》


文章作者: caidd
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 caidd !
评论
  目录