从零实现shared_ptr

本文结构

  • 基础构造函数
  • 复制语义
  • 移动语义
  • 一些工具函数
  • 析构函数

1.基础构造函数

面试中如果要求实现一个shared_ptr,我们可以从最简单的结构写起:

template<class T>
class my_shared_ptr{
private:
    T* m_ptr = nullptr;
    unsigned int * m_ref_count = nullptr;
    //默认构造函数
public:   
    my_shared_ptr(): m_ptr(nullptr), m_ref_count(nullptr){ }
    my_shared_ptr(T* ptr): m_ptr(ptr), m_ref_count(new unsigned int(1)){ }
};

1.为什么m_ref_count这个引用计数要用指针来表示?
因为shared_ptr会被复制来复制去的,对于同一个被引用的对象,其引用计数需要同时更新和共享,使用指针的话就会比较方便。
2.shared_ptr的引用计数是线程安全的吗?
在其源代码中,是线程安全的,因为最后使用了_Atomic_word类型来计数,表明其是一个原子操作,保证了线程安全。
3.shared_ptr储存的指针是线程安全的吗?
并不是,源代码中指针类型是element_type*,其为用户使用时自定义的指针类型,没有保证原子操作。

2.拷贝构造部分

//拷贝构造函数
my_shared_ptr(const my_shared_ptr & obj){
    m_ptr = obj.m_ptr;
    m_ref_count = obj.m_ref_count;
    if(m_ref_count != nullptr){
        (*m_ref_count)++;
    }
}

//拷贝重载=运算符
my_shared_ptr& operator=(const my_shared_ptr & obj){
    if(obj.m_ptr == m_ptr){
        return *this;
    }
    
    //先处理原有的指针和引用计数
    if(m_ref_count != nullptr){
        (*m_ref_count)--;
        if(*m_ref_count == 0){
            delete m_ptr;
            delete m_ref_count;
        }
    }
    
    m_ptr = obj.m_ptr;
    m_ref_count = obj.m_ref_count;
    
    if(m_ref_count != nullptr){
        (*m_ref_count)++;
    }
    return *this;
}

1.为什么拷贝构造的参数是const my_shared_ptr&,而不是const my_shared_ptr
因为我们写的是拷贝构造函数,如果参数为后者,相当于要调用一次拷贝构造,就会无限递归下去。
2.为什么重载=运算符的返回值是my_shared_ptr&,能不能返回void
理论上是可以的,但是为了支持连等a = b = c,还是要返回my_shared_ptr&

3.移动构造部分

//移动语义
//移动构造
my_shared_ptr(my_shared_ptr && dying_obj): m_ptr(nullptr), m_ref_count(nullptr){
    //初始化后交换指针和引用计数,相当于清除了dying_obj的内容
    dying_obj.swap(*this);
}
//移动赋值
my_shared_ptr & operator=(my_shared_ptr && dying_obj){

    //my_shared_ptr(std::move(dying_obj))用移动构造函数创建出一个新的shared_ptr(此时原shared_ptr的内容被清除了)
    //再和this交换指针和引用计数
    //因为this的内容被交换到了当前的临时创建的my_shared_ptr里,原this指向的引用计数-1

    my_shared_ptr(std::move(dying_obj)).swap(*this);
    return *this;
}

void swap(my_shared_ptr & other){
    std::swap(m_ptr, other.m_ptr);
    std::swap(m_ref_count, other.m_ref_count);
}

4.一些工具函数

指针相关的运算符重载和获取引用计数的函数

//重载指针运算符
T* operator->() const{
    return m_ptr;
}

//重载解引用运算符
T* operator*() const{
    return *m_ptr;
}

T* get() const{
    return m_ptr;
}

unsigned int use_count() const{
    return *m_ref_count;
}

5.析构函数

//析构函数
~my_shared_ptr(){
    if(m_ref_count == nullptr){
        return;
    }
    (*m_ref_count)--;
    if(*m_ref_count > 0){
        return;
    }
    
    if(m_ptr != nullptr){
        delete m_ptr;
    }
    delete m_ref_count;
}

6.make_shared函数

1.为什么会推荐使用std::make_shared,而不是直接构造std::shared_ptr
如果直接构造shared_ptr,那么是先分配一块内存给实例化对象,再分配一块内存给引用计数模块(引用计数,删除器等),但是std::make_shared可以一次性分配一整块内存给引用计数模块和实例化对象,这样有两部分优点:

  • 优点一:异常安全
    在C++17之前,在某种情况下构造一个std::shared_ptr不一定是安全的,看下面的案例:

    void F(const std::shared_ptr &lhs, const std::shared_ptr &rhs) { /* … */ }

    F(std::shared_ptr(new Lhs(“foo”)),
    std::shared_ptr(new Rhs(“bar”)));

一个可能的执行顺序是

1.new Lhs("foo")
2.new Rhs("bar")
3.std::shared_ptr<Lhs>
4.std::shared_ptr<Rhs>

假设在第2步出现了一个异常(比如内存耗尽或者构造函数的异常),那么第一步分配的内存地址就没有保存在任何地方,所以这块内存永远回收不了,导致内存泄露。使用std::make_shared就可以避免这种问题。

  • 优点二:减少开销
    一次性分配一整块内存来使用可以减少碎片化内存,减少使用临时变量,也减少了和内核的交流。

2.有没有什么情况下没法使用std::make_shared

  • 有时候我们把构造函数定义为私有,就可以强制用户使用工厂模式A::Create创建shared_ptr,这样可以避免用户直接创建实例或者使用生指针进而管理不善导致内存泄漏。

      #include <iostream>
      #include <memory>
    
      class A
      {
          public:
          static std::shared_ptr<A> Create(){ return std::shared_ptr<A>(new A(100)); }
    
          int GetId(){ return m_i; }
    
          private:
          int m_i;
          A(int i): m_i(i){std::cout<<"private ctor called"<<std::endl;};
      };
    
      int main()
      {
          std::shared_ptr<A> s_ptr = A::Create();
          std::cout<<s_ptr->GetId()<<std::endl;
    
          //A * p = new A(300);  //make_shared无法访问私有构造函数
          return 0;
      }
    

在这种情况下,std::make_shared进行placement new操作的时候,会直接调用构造函数,但因为我们的构造函数都是私有的,这时候就会报错。而我们在工厂模式里直接new出来的实例是通过成员函数new操作符operator new访问的私有构造函数,所以没有问题。

  • 还有一种情况,make_shared 只分配一次内存, 这看起来很好. 减少了内存分配的开销. 问题来了, weak_ptr 会保持控制块(强引用, 以及弱引用的信息)的生命周期, 而因此连带着保持了对象分配的内存, 只有最后一个 weak_ptr 离开作用域时, 内存才会被释放. 原本强引用减为 0 时就可以释放的内存, 现在变为了强引用, 弱引用都减为 0 时才能释放, 意外的延迟了内存释放的时间. 这对于内存要求高的场景来说, 是一个需要注意的问题。