C++—智能指针

智能指针是C++面试的常见考点,答案可以从《effective modern C++》中寻找,本文从我的角度重新组织语言和逻辑,因为我觉得按自己的方式重新组织知识点的分类和顺序是消化吸收既有知识的良好方式

仅使用的角度

如果仅仅是使用C++的需求不需要专业理解,可以参考《C++核心准则》,我认为C++之父的《C++核心准则》是很好的规避难点的新手指南,核心准则强调从用户角度来说智能指针的所有权的理论概念和代表涵义。

智能指针规则概览:

R.20: 用 unique_ptr 或 shared_ptr 表示所有权
R.21: 优先采用 unique_ptr 而不是 shared_ptr,除非需要共享所有权
R.22: 使用 make_shared() 创建 shared_ptr
R.23: 使用 make_unique() 创建 unique_ptr
R.24: 使用 std::weak_ptr 来打断 shared_ptr 的循环引用
R.30: 以智能指针为参数,仅用于明确表达生存期语义
R.31: 非 std 的智能指针,应当遵循 std 的行为模式
R.32: unique_ptr 参数用以表达函数假定获得 widget 的所有权
R.33: unique_ptr& 参数用以表达函数对该 widget 重新置位
R.34: shared_ptr 参数用以表达函数是所有者的一份子
R.35: shared_ptr& 参数用以表达函数可能会对共享的指针重新置位
R.36: const shared_ptr& 参数用以表达它可能将保留一个对对象的引用
R.37: 不要把来自某个智能指针别名的指针或引用传递出去

翻译:
如果函数使用了unique_ptr& 或者shared_ptr& 参数,默认内部对智能指针使用了reset()

如果函数使用了裸指针参数,函数一定不持有所有权
如果函数使用了shared_ptr 参数,函数一定会发生持有所有权的操作(持有所有权=引用计数+1=需要拷贝智能指针)
如果函数使用了const shared_ptr& 参数,函数内部可能会发生持有所有权的操作(例如内部按条件发生容器的push_back()操作,进行了拷贝)

新手使用可以直接记忆以上条款,专业角度要记忆理由和反例。我认为规则的设计本身应该减少反例的情况,确保一句话能概括使用规则,存在反例是规则的缺陷,但是很可惜,现实世界总会存在反例,反例往往会变成考点。

简单的代码实现

简单版本 UniquePtr

```cpp
template <typename T>
class UniquePtr {
 public:
  explicit UniquePtr(T* p = nullptr) : ptr_(p) {}
  ~UniquePtr() { delete ptr_; }

  UniquePtr(UniquePtr&& other) : ptr_(other.release()) noexcept {}

  UniquePtr& operator=(UniquePtr&& other) noexcept {
    reset(other.release());
    return *this;
  }

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

  T* release() {
    auto tmp = ptr_;
    ptr_ = nullptr;
    return tmp;
  }

  void reset(T* ptr = nullptr) {
    if (ptr_ == ptr) {
      return;
    }
    auto temp = ptr_;
    ptr_ = ptr;
    delete temp;
  }

 private:
  T* ptr_;
};

简单版本的shared_ptr

即仅考虑引用计数的实现

  • std::shared_ptr的大小时原始指针的两倍:额外包含一个指向资源的引用计数值的原始指针
  • 引用计数的内存必须动态分配
template <typename T>

class SharedPtr {
 public:
  explicit SharedPtr(T* p = nullptr)
      : ptr_(p), count_(ptr_ ? new size_t(1) : nullptr) {}

  ~SharedPtr() {
    if (ptr_ && --(*count_) == 0) {
      delete ptr_;

      ptr_ = nullptr;

      delete count_;

      count_ = nullptr;
    }
  }

  SharedPtr(const SharedPtr& other) {
    ptr_ = other.get();
    count_ = other.count_;

    if (ptr_) {
      (*count_)++;
    }
  }

  SharedPtr(SharedPtr&& other) noexcept {
    ptr_ = other.ptr_;
    count_ = other.count_;

    other.ptr_ = nullptr;
    other.count_ = nullptr;
  }

  SharedPtr& operator=(const SharedPtr& other) {
    if (this != &other) {
      if (ptr_ && --(*count_) == 0) {
        delete ptr_;
        delete count_;
      }

      ptr_ = other.ptr_;
      count_ = other.count_;

      if (ptr_) {
        (*count_)++;
      }
    }
    return *this;
  }

  SharedPtr& operator=(SharedPtr&& other) noexcept {
    if (this != &other) {
      if (ptr_ && --(*count_) == 0) {
        delete ptr_;
        delete count_;
      }

      ptr_ = other.ptr_;
      count_ = other.count_;

      other.ptr_ = nullptr;
      other.count_ = nullptr;
    }

    return *this;
  }

  void reset(T* ptr = nullptr) {
    if (ptr_ == ptr) {
      return;
    }
    if (count_ && --(*count_) == 0) {
      delete ptr_;
      delete count_;
    }
    ptr_ = ptr;
    count_ = (ptr_ ? new size_t(1) : nullptr);
  }

  T* get() const{ return ptr_; }

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

 private:
  T* ptr_;
  size_t* count_;
};

考虑线程安全问题

  • 递增递减引用计数必须是原子性的,但这不代表shared_ptr作为对象也是原子的,原因是即使在简易做法下shared_ptr也有两个分开的成员(ptr_和cout_),被共享的shared_ptr读操作下仅引用计数会增加,这是原子操作,所以是安全的。但是写操作(资源释放和引用计数变化)要分两步进行,这里是有竞态的,参见https://www.cnblogs.com/Solstice/archive/2013/01/28/2879366.html
#include <atomic>

template <typename T>
class SharedPtr {
 public:
  explicit SharedPtr(T* p = nullptr)
      : ptr_(p), count_(ptr_ ? new std::atomic<size_t>(1) : nullptr) {}

  ~SharedPtr() {
    if (ptr_) {
      // 引用计数减一,如果变为0则释放资源
      if (count_->fetch_sub(1, std::memory_order_release) == 1) {
        std::atomic_thread_fence(std::memory_order_acquire);
        delete ptr_;
        delete count_;
      }
    }
  }

  SharedPtr(const SharedPtr& other) {
    ptr_ = other.ptr_;
    count_ = other.count_;

    if (ptr_) {
      count_->fetch_add(1, std::memory_order_relaxed);
    }
  }

  SharedPtr(SharedPtr&& other) noexcept {
    ptr_ = other.ptr_;
    count_ = other.count_;

    other.ptr_ = nullptr;
    other.count_ = nullptr;
  }

  SharedPtr& operator=(const SharedPtr& other) {
    if (this != &other) {
      // 先递减当前指针的引用计数
      decrease_ref_and_delete();

      // 再复制其他指针并增加引用计数
      ptr_ = other.ptr_;
      count_ = other.count_;

      if (ptr_) {
        count_->fetch_add(1, std::memory_order_relaxed);
      }
    }
    return *this;
  }

  SharedPtr& operator=(SharedPtr&& other) noexcept {
    if (this != &other) {
      decrease_ref_and_delete();

      ptr_ = other.ptr_;
      count_ = other.count_;

      other.ptr_ = nullptr;
      other.count_ = nullptr;
    }
    return *this;
  }

  void reset(T* ptr = nullptr) {
    decrease_ref_and_delete();

    ptr_ = ptr;
    count_ = ptr ? new std::atomic<size_t>(1) : nullptr;
  }

  T* get() const { return ptr_; }

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

 private:
  void decrease_ref_and_delete() {
    if (ptr_) {
      if (count_->fetch_sub(1, std::memory_order_release) == 1) {
        std::atomic_thread_fence(std::memory_order_acquire);
        delete ptr_;
        delete count_;
      }
      ptr_ = nullptr;
      count_ = nullptr;
    }
  }

  T* ptr_;
  std::atomic<size_t>* count_;
};

进阶

《effective modern C++》关于智能指针的条款有如下:

18.对于独占资源使用std::unique_ptr

std::unique_ptr 是轻量级、快速的、只可移动(move-only)的管理专有所有权语义资源的智能指针

默认资源销毁通过 delete 实现,但是支持自定义删除器。有状态的删除器和函数 指针会增加 std::unique_ptr 对象的大小

将 std::unique_ptr 转化为 std::shared_ptr 非常简单

19.对于共享资源使用std::shared_ptr

std::shared_ptr为有共享所有权的任意资源提供一种自动垃圾回收的便捷方式。

较之于std::unique_ptr,std::shared_ptr对象通常大两倍,控制块会产生开销,需要原子性的引用计数修改操作

默认资源销毁时通过delete,但也支持自定义删除器,删除器的类型是什么对于 std::shared_ptr 的类型没有影响

避免从原始指针变量上创建std::shared_ptr

20.当std::shared_ptr可能悬空时使用std::weak_ptr

用std::weak_ptr替代可能悬空的std::shared_ptr

std::weak_ptr 的潜在使用场景包括:缓存、观察者列表、打破 std::shared_ptr 环状 结构。

21.优先考虑使用std::make_unique和std::make_shared而非直接使用new

和直接使用new相比,make消除了代码重复,提高了异常安全性。对于 std::make_shared 和 std::allocate_shared ,生成的代码更小更快。

不适合使用 make 函数的情况包括需要指定自定义删除器和希望用花括号初始化。

对于 std::shared_ptr s,其他不建议使用 make 函数的情况包括(1)有自定义内存管理的类;(2)特别关注内存的系统,非常大的对象,以及 std::weak_ptr s比对应的 std::shared_ptr s活得更久。

22.当使用Pimpl惯用法,请在实现文件中定义特殊成员函数

Pimpl惯用法通过减少在类实现和类使用者之间的编译依赖来减少编译时间。

对于 std::unique_ptr 类型的 pImpl 指针,需要在头文件的类里声明特殊的成员函数, 但是在实现文件里面来实现他们。即使是编译器自动生成的代码可以工作,也要这么做。

以上的建议只适用于 std::unique_ptr ,不适用于 std::shared_ptr 。

对智能指针的深入主要从控制块,删除器,循环引用三个概念去理解。

删除器

std::unique_ptr的删除器

默认情况下,std::unique_ptr大小等于原始指针,而且对于大部分操作,执行的指令完全相同。如果阐述中出现一般,默认,通常,那么往往存在反例,考虑反例会有助于更全面的理解,这里的默认的反例一般是不存在自定义删除器,大部分的反例是涉及RAII的那些包装以及自定义删除器带来的额外操作,这里的一般的反例是如果自定义删除器是无状态函数(状态是指函数对象内部存储的数据),此时大小也不变。

释放资源前写入日志的删除器例子

auto delInvmt = [](){
  makeLogEntry(pInvestment);
  delete pInvestment;
};

template<typename... Ts>
std::unique_ptr<Investment,decltype(delInvmt)>
makeInvestment(Ts&&... params){
  std::unique_ptr<Investment,decltype(delInvmt)>
    pInv(nullptr,delInvmt);
  if(/*一个Stock对象应被创建*/){
    pInv.reset(new Stock(std::forward<Ts>(params)...));
  }
  else if ( /*一个Bond对象应被创建*/ )
  {  
    pInv.reset(new Bond(std::forward<Ts>(params)...)); 
  }
  else if ( /*一个RealEstate对象应被创建*/ )
  {  
    pInv.reset(new RealEstate(std::forward<Ts>(params)...)); 
  } 
  return pInv;
}

std::shared_ptr的删除器

区别于std::unique_ptr, shared_ptr的删除器类型不是自身类型的一部分

弱引用计数

大家都知道std::weak_ptr代表的含义(可能指向已经销毁的对象),并且可以打破智能指针循环引用。

  • 从效率角度来说,std::weak_ptr和std::shared_ptr基本相同。两者的大小是相同的,使用相同的控制块,构造,析构赋值操作涉及引用计数的原子操作。std::weak_ptr操作的是弱引用计数,弱引用计数一般记录多少个weak_ptr指向控制块。

控制块

从代码整体设计上,将引用计数,弱引用计数,删除器等模块放到单独一个类中显然更合理,shared_ptr的控制块包括了引用计数值,弱引用计数值,可能的自定义删除器拷贝,分配器拷贝

控制块遵循的原则:

  • std::make_shared总是创建一个控制块
  • 当从独占指针构造出std::shared_ptr时会创建控制块
  • 当从原始指针上构造出std::shared_ptr时会创建控制块

在使用时我们要确保对同一个资源不要产生两个控制块。

控制块的内存位置:

  • std::make_shared将控制块和对象内存合并到同一块内存分配,普通的构造方式是分开分配的,make函数所写内容更短,make函数可以确保一次性完成空间分配和构造函数,不会发生竞争
  • 一般优先使用make函数优于new。反面:①make函数不能使用自定义删除器

②make函数完美转发使用小括号而不是花括号 ③ weak_ptr存在延迟释放,只要std::weak_ptr引用一个控制块,该控制块必须继续存在。④make函数不适合重载了operator new和operator delete类的对象。

其他一些坑点和解决方法:

如果某个成员函数需要返回当前对象的shared_ptr(例如,用于回调、事件通知或链式调用),直接返回std::shared_ptr<T>(this)是错误的,因为它会创建新的控制块。此时应该使用shared_from_this()

posted @ 2025-05-12 20:09  七小丘人  阅读(31)  评论(0)    收藏  举报