天天看点

C++ 智能指针深剖

目录

  • ​​传统艺能😎​​
  • ​​背景😋​​
  • ​​概念😋​​
  • ​​原理😋​​
  • ​​智能指针三将星😋​​
  • ​​auto_ptr 🤔​​
  • ​​unique_ptr 🤔​​
  • ​​shared_ptr 🤔​​
  • ​​定制删除器🤔​​
  • ​​weak_ptr 🤔​​
  • ​​boost 的智能指针😋​​

传统艺能😎

C++ 智能指针深剖

背景😋

内存泄露是造成程序未能释放已经不再使用的内存的情况,下面是一个典型的内存泄漏场景,如果我们输入的除数为 0,那么 div 中就会抛出异常,这时程序的执行流会直接跳转到主函数的 catch 块中执行,最终导致 func 中申请的内存资源不能释放:

int div()
{
  int a, b;
  cin >> a >> b;
  if (b == 0)
    throw invalid_argument("除0错误");
  return a / b;
}
void func()
{
  int* ptr = new int;
  //...
  cout << div() << endl;
  //...
  delete ptr;
}
int main()
{
  try
  {
    func();
  }
  catch (exception& e)
  {
    cout << e.what() << endl;
  }
  return 0;
}      

这种情况我们应该会去 func 中捕获抛出的异常,释放泄露的内存后再重新抛出异常:

int div()
{
  int a, b;
  cin >> a >> b;
  if (b == 0)
    throw invalid_argument("除0错误");
  return a / b;
}
void func()
{
  int* ptr = new int;
  try
  {
    cout << div() << endl;
  }
  catch (...)
  {
    delete ptr;
    throw;
  }
  delete ptr;
}
int main()
{
  try
  {
    func();
  }
  catch (exception& e)
  {
    cout << e.what() << endl;
  }
  return 0;
}      

对于内存泄漏这个项目中的麻烦鬼,为了高效且便捷的防范与检查, C++11 的智能指针就此应运而生。

概念😋

就上面内存泄漏的场景,我们就可以用智能指针解决:

template<class T>
class SmartPtr
{
public:
  SmartPtr(T* ptr)
    :_ptr(ptr)
  {}
  ~SmartPtr()
  {
    cout << "delete: " << _ptr << endl;
    delete _ptr;
  }
  T& operator*()
  {
    return *_ptr;
  }
  T* operator->()
  {
    return _ptr;
  }
private:
  T* _ptr;
};
int div()
{
  int a, b;
  cin >> a >> b;
  if (b == 0)
    throw invalid_argument("除0错误");
  return a / b;
}
void func()
{
  SmartPtr<int> sp(new int);
  //...
  cout << div() << endl;
  //...
}
int main()
{
  try
  {
    func();
  }
  catch (exception& e)
  {
    cout << e.what() << endl;
  }
  return 0;
}      

这里将申请到的内存空间交给了一个 SmartPtr 对象进行管理,构造 SmartPtr 时,他将需要被管理的内存空间保存起来,SmartPtr 析构时,他的析构函数中会自动进行释放。

此外,为了让 SmartPtr 对象能够像原生指针一样使用,还需要对 * 和 -> 运算符进行重载。这样一来,无论程序是正常执行完毕,还是因为某些原因中途返回了,或是抛异常返回了,只要 SmartPtr 对象的生命周期结束就会调用其对应的析构函数,进而完成内存资源的释放

原理😋

首先智能指针的思想是 ,RAII 是利用对象的生命周期来控制资源(内存,句柄,互斥量等)的一种技术思想。

智能指针需要实现三个方面:

  1. 在对象构造时获取资源,在对象析构的时候释放资源,利用对象的生命周期来控制程序资源,即RAII特性
  2. 对 * 和 -> 运算符进行重载,使得该对象具有像指针一样的行为
  3. 智能指针对象的拷贝问题

此时可能就会有铁子疑惑,为什么会要特意解决智能指针拷贝问题,我们不妨看一个场景:

int main()
{
  SmartPtr<int> sp1(new int);
  SmartPtr<int> sp2(sp1); //拷贝构造

  SmartPtr<int> sp3(new int);
  SmartPtr<int> sp4(new int);
  sp3 = sp4; //拷贝赋值
  
  return 0;
}      

编译器默认生成的拷贝构造对内置类型完成值拷贝,即浅拷贝,因此用 sp1 拷贝构造 sp2 后,相当于 sp1 和 sp2 管理了同一块内存空间,当 sp1 和 sp2 析构时就会导致这块空间被释放两次

拷贝赋值函也是同理,sp4 赋值给 sp3 后,相当于 sp3 和 sp4 管理的都是原来 sp3 管理的空间,析构时不仅会导致释放两次,还会导致 sp4 原来管理的空间无法释放

需要注意的是,智能指针就是要模拟原生指针的行为,当我们将一个指针赋值给另一个指针时,目的就是让这两个指针指向同一块内存空间,所以这里本就应该进行浅拷贝,但单纯的浅拷贝又会导致空间被多次释放,因此根据解决拷贝问题的方式不同,从而衍生出了各种针对性的智能指针

智能指针三将星😋

,其优点是会自动分配内存,不用担心潜在的内存泄露。 C++11 提供了 3 个不同类型的智能指针:

unique_ptr

weak_ptr

shared_ptr

std::auto_ptr (已被废弃)

auto_ptr 🤔

最后一个 auto_ptr 是 C++98 中引入的智能指针,auto_ptr 通过管理权转移的方式解决拷贝问题,保证资源在任何时刻都只有一个对象在对其进行管理,这时同一个资源就不会被多次释放了

int main()
{
  std::auto_ptr<int> ap1(new int(1));
  std::auto_ptr<int> ap2(ap1);
  *ap2 = 10;
  //*ap1 = 20; //error

  std::auto_ptr<int> ap3(new int(1));
  std::auto_ptr<int> ap4(new int(2));
  ap3 = ap4;
  return 0;
}      

但是他之所以会被 ban 就是因为他的管理权转移,如果还用原来的管理的资源进行访问,就会导致程序崩溃,因此不了解他就去使用很可能是你扣工资的原因。

简易版的auto_ptr的实现步骤如下:

在构造函数中获取资源,在析构函数中释放资源,利用对象生命周期来控制资源对 * 和 -> 运算符进行重载,使 auto_ptr 对象具有指针一样的行为

在拷贝构造函数中,用传入对象管理的资源来构造当前对象,并将传入对象管理资源的指针置空

在拷贝赋值函数中,先将当前对象管理的资源释放,然后再接管传入对象管理的资源,最后将传入对象管理资源的指针置空

namespace cl
{
  template<class T>
  class auto_ptr
  {
  public:
    //RAII
    auto_ptr(T* ptr = nullptr)
      :_ptr(ptr)
    {}
    ~auto_ptr()
    {
      if (_ptr != nullptr)
      {
        cout << "delete: " << _ptr << endl;
        delete _ptr;
        _ptr = nullptr;
      }
    }
    auto_ptr(auto_ptr<T>& ap)
      :_ptr(ap._ptr)
    {
      ap._ptr = nullptr; //管理权转移ap被置空
    }
    auto_ptr& operator=(auto_ptr<T>& ap)
    {
      if (this != &ap)
      {
        delete _ptr;       //释放管理的资源
        _ptr = ap._ptr;    //接管对象的资源
        ap._ptr = nullptr; //管理权转移ap被置空
      }
      return *this;
    }
    //模仿指针行为
    T& operator*()
    {
      return *_ptr;
    }
    T* operator->()
    {
      return _ptr;
    }
  private:
    T* _ptr; //管理的资源
  };
}      

unique_ptr 🤔

unique_ptr 是防止拷贝的智能指针,也就是简单粗暴的防止对象进行拷贝,这样也能保证资源不会被多次释放

int main()
{
  std::unique_ptr<int> up1(new int(0));
  //std::unique_ptr<int> up2(up1); //报错
  return 0;
}      

但防拷贝其实也不是万金油,因为总有一些场景需要进行拷贝,既然知道他的功能,我们也可以实现一个简易版的 unique_ptr 来看看他的底层原理:

在构造函数中获取资源,在析构函数中释放资源,利用对象的生命周期来控制资源

对 * 和 -> 运算符进行重载,使 unique_ptr 对象具有指针一样的行为

用 C++98 的方式将拷贝构造函数和拷贝赋值函数声明为私有,或者用 C++11 的方式在这两个函数后面加上=delete,防止外部调用

namespace cl
{
  template<class T>
  class unique_ptr
  {
  public:
    //RAII
    unique_ptr(T* ptr = nullptr)
      :_ptr(ptr)
    {}
    ~unique_ptr()
    {
      if (_ptr != nullptr)
      {
        cout << "delete: " << _ptr << endl;
        delete _ptr;
        _ptr = nullptr;
      }
    }
    //可以像指针一样使用
    T& operator*()
    {
      return *_ptr;
    }
    T* operator->()
    {
      return _ptr;
    }
    //防拷贝
    unique_ptr(unique_ptr<T>& up) = delete;
    unique_ptr& operator=(unique_ptr<T>& up) = delete;
  private:
    T* _ptr; //管理的资源
  };
}      

shared_ptr 🤔

自然会有必须要拷贝的场景,这种场景又如何考虑拷贝问题呢?这就诞生了 shared_ptr 。shared_ptr 是 C++11 中引入的智能指针,shared_ptr 允许拷贝且通过 的方式解决拷贝问题。

每一个被管理的资源都有一个对应的引用计数,记录当前有多少个对象在管理这块资源,每新增一个管理者引用计数就 ++ 一次,当不再管理或该对象被析构时则将引用计数 --,当一个资源的引用计数减为 0 时已经没有对象在管理了,这时就可以释放资源了

int main()
{
  cl::shared_ptr<int> sp1(new int(1));
  cl::shared_ptr<int> sp2(sp1);
  *sp1 = 10;
  *sp2 = 20;
  // use_count函数用于获取当前管理资源的引用计数
  cout << sp1.use_count() << endl; //2

  cl::shared_ptr<int> sp3(new int(1));
  cl::shared_ptr<int> sp4(new int(2));
  sp3 = sp4;
  cout << sp3.use_count() << endl; //2
  return 0;
}      

同样也来模拟一下简易版的 shared_ptr :

对 * 和 -> 运算符进行重载,使shared_ptr对象具有指针一样的行为

在 shared_ptr 类中增加成员变量 count,表示资源对应的引用计数。

在构造函数中获取资源,并将该资源对应的引用计数设置为 1,表示当前只有一个对象在管理这个资源

在拷贝构造函数中,与传入对象一起管理它管理的资源,同时将该资源对应的引用计数++

在拷贝赋值函数中,先将当前资源对应的引用计数–(减为0则释放),然后再与传入对象一起管理它管理的资源,同时将该资源引用计数++

在析构函数中,将管理资源对应的引用计数–,如果减为0则需要将该资源释放

namespace cl
{
  template<class T>
  class shared_ptr
  {
  public:
    //RAII
    shared_ptr(T* ptr = nullptr)
      :_ptr(ptr)
      , _pcount(new int(1))
    {}
    ~shared_ptr()
    {
      if (--(*_pcount) == 0)
      {
        if (_ptr != nullptr)
        {
          cout << "delete: " << _ptr << endl;
          delete _ptr;
          _ptr = nullptr;
        }
        delete _pcount;
        _pcount = nullptr;
      }
    }
    shared_ptr(shared_ptr<T>& sp)
      :_ptr(sp._ptr)
      , _pcount(sp._pcount)
    {
      (*_pcount)++;
    }
    shared_ptr& operator=(shared_ptr<T>& sp)
    {
      if (_ptr != sp._ptr) //管理同一块空间的对象之间无需进行赋值操作
      {
        if (--(*_pcount) == 0) //将管理的资源对应的引用计数--
        {
          cout << "delete: " << _ptr << endl;
          delete _ptr;
          delete _pcount;
        }
        _ptr = sp._ptr;       //与sp对象一同管理它的资源
        _pcount = sp._pcount; //获取sp对象管理的资源对应的引用计数
        (*_pcount)++;         //新增一个对象来管理该资源,引用计数++
      }
      return *this;
    }
    //获取引用计数
    int use_count()
    {
      return *_pcount;
    }
    //可以像指针一样使用
    T& operator*()
    {
      return *_ptr;
    }
    T* operator->()
    {
      return _ptr;
    }
  private:
    T* _ptr;      //管理的资源
    int* _pcount; //管理的资源对应的引用计数
  };
}      

首先要明白引用计数的数是放在堆区的,那为什么引用计数要存放在堆区?

因为 shared_ptr 对象都有一个自己的count成员变量,会面临着多个对象要管理同一个资源,这时引用计数 count 绝不是单纯的定义成一个 int 类型,这几个对象应该用到的是同一个引用计数!

C++ 智能指针深剖

当然也不可以定义成一个静态变量,如果所有类型对量都共享的话,是不是管理这块资源的对象都会用同一个引用计数!

C++ 智能指针深剖

而如果将 shared_ptr 中的引用计数 count 定义成一个指针,一开始就在堆区开辟一块空间用于存储其对应的引用计数,如果有其他对象也想要管理这个资源,那么除了将这块资源给他,还要将引用计数也给他,这时管理同一个资源的多个对象访问到的就是同一个引用计数,而管理不同资源的对象访问到的就是不同的引用计数了,相当于将各个资源与其对应的引用计数进行了绑定

C++ 智能指针深剖

定制删除器🤔

当智能指针对象的生命周期结束时,以 delete 的方式将所有的智能指针释放,这是不太合适的,因为智能指针并不只管理以 new 到的空间,也可能是以 new[] 申请到的空间,或管理的是一个文件指针!

struct ListNode
{
  ListNode* _next;
  ListNode* _prev;
  int _val;
  ~ListNode()
  {
    cout << "~ListNode()" << endl;
  }
};
int main()
{
  std::shared_ptr<ListNode> sp1(new ListNode[10]);   //报错,new[]类型
  std::shared_ptr<FILE> sp2(fopen("test.cpp", "r")); //报错,文件指针

  return 0;
}      

因为我们都知道必须以 delete[] 的方式释放 new[],而文件指针必须通过 fclose() 进行释放。这时就横空出世了:

//C++标准库提供的构造函数
template <class U, class D>
shared_ptr (U* p, D del);      

p:需要管理的资源

del:删除器,可以是一个可调用对象,比如函数指针、仿函数、lambda表达式以及被包装器包装后的可调用对象

当 shared_ptr 对象的生命周期结束时就会调用传入的删除器完成资源的释放,调用该删除器时会将 shared_ptr 管理的资源作为参数进行传入:

template<class T>
struct DelArr
{
  void operator()(const T* ptr)
  {
    cout << "delete[]: " << ptr << endl;
    delete[] ptr;
  }
};
int main()
{
  std::shared_ptr<ListNode> sp1(new ListNode[10], DelArr<ListNode>());
  std::shared_ptr<FILE> sp2(fopen("test.cpp", "r"), [](FILE* ptr));
  {
    cout << "fclose: " << ptr << endl;
    fclose(ptr);
  }
  return 0;
}      

C++ 标准库中实现shared_ptr时是分成了很多个类的,因此标准库中可以将删除器的类型设置为构造函数的模板参数,然后将删除器的类型在各个类之间进行传递

但我们是直接用一个类来模拟实现的,因此不能将删除器类型设置为模板参数。又因为删除器不是在构造函数中调用的,而是需要在析构函数中调用,因此必须用一个成员变量将删除器保存下来,而在定义时就需要指定删除器类型,因此这里模拟实现的时候不能将删除器类型设置为构造函数的模板参数

要在当前模拟实现 shared_ptr 基础上支持定制删除器,就只能给 shared_ptr 类再增加一个模板参数,在构造 shared_ptr 对象时就需要指定删除器的类型,然后增加一个支持传入的构造函数,在构造时将删除器保存下来,在需要释放时调用该删除器进行释放即可,最好在设置一个默认的删除器,如果用户定义shared_ptr对象时不传入删除器,则默认以delete的方式释放资源

搞清楚这些问题后我们就可以模拟一个定制删除器了:

namespace cl
{
  //默认的删除器
  template<class T>
  struct Delete
  {
    void operator()(const T* ptr)
    {
      delete ptr;
    }
  };
  template<class T, class D = Delete<T>>
  class shared_ptr
  {
  private:
    void ReleaseRef()
    {
      _pmutex->lock();
      bool flag = false;
      if (--(*_pcount) == 0) //引用计数--
      {
        if (_ptr != nullptr)
        {
          cout << "delete: " << _ptr << endl;
          _del(_ptr); //定制删除器释放资源
          _ptr = nullptr;
        }
        delete _pcount;
        _pcount = nullptr;
        flag = true;
      }
      _pmutex->unlock();
      if (flag == true)
      {
        delete _pmutex;
      }
    }
    //...
  public:
    shared_ptr(T* ptr, D del)
      : _ptr(ptr)
      , _pcount(new int(1))
      , _pmutex(new mutex)
      , _del(del)
    {}
    //...
  private:
    T* _ptr;        //管理的资源
    int* _pcount;   //引用计数
    mutex* _pmutex; //互斥锁
    D _del;         //删除器
  };
}      

这时我们模拟实现的 shared_ptr 就支持定制删除器了,注意如果传入的一个仿函数,那么需要在构造时指明仿函数的类型。如果传入的是一个 lambda 表达式就更麻烦了,因为 lambda 表达式的类型不太容易获取,建议将 lambda 表达式的类型指明为一个包装器类型,让编译器传参时自行进行推演,也可以先用 auto 接收 lambda 表达式,然后再用 decltype 来声明删除器的类型

template<class T>
struct DelArr
{
  void operator()(const T* ptr)
  {
    cout << "delete[]: " << ptr << endl;
    delete[] ptr;
  }
};
int main()
{
  //仿函数示例
  cl::shared_ptr<ListNode, DelArr<ListNode>> sp1(new ListNode[10], DelArr<ListNode>());

  //lambda表达式指明为一个包装器类型
  cl::shared_ptr<FILE, function<void(FILE*)>> sp2(fopen("test.cpp", "r"), [](FILE* ptr){
    cout << "fclose: " << ptr << endl;
    fclose(ptr);
  });

  // auto 接收lambda表达式再用 decltype 声明
  auto f = [](FILE* ptr){
    cout << "fclose: " << ptr << endl;
    fclose(ptr);
  };
  cl::shared_ptr<FILE, decltype(f)> sp3(fopen("test.cpp", "r"), f);

  return 0;
}      

weak_ptr 🤔

shared_ptr 在某些场景下也会有问题,他会引起循环引用,比如定义如下的结点类,在结点类的析构函数中打印一句提示语句,便于判断结点是否正确释放:

struct ListNode
{
    ListNode* _next;
    ListNode* _prev;
    int _val;
    ~ListNode()
    {
        cout << "~ListNode()" << endl;
    }
};
int main()
{
    ListNode* node1 = new ListNode;
    ListNode* node2 = new ListNode;
    //关系两个节点
    node1->_next = node2;
    node2->_prev = node1;
    //...
    delete node1;
    delete node2;
    return 0;
}      

这个程序本身是没有问题的,为了防止程序中途返回或抛异常等原因导致结点未被释放,我们还应该将这两个结点分别交给两个 shared_ptr 对象进行管理,这时为了让关系节点时的赋值操作能够执行,就需要把 ListNode 类中的 next 和 prev 成员变量的类型也改为 shared_ptr 类型:

struct ListNode
{
  std::shared_ptr<ListNode> _next;
  std::shared_ptr<ListNode> _prev;
  int _val;
  ~ListNode()
  {
    cout << "~ListNode()" << endl;
  }
};
int main()
{
  std::shared_ptr<ListNode> node1(new ListNode);
  std::shared_ptr<ListNode> node2(new ListNode);

  node1->_next = node2;
  node2->_prev = node1;
  //...

  return 0;
}      

程序运行结束后两个结点都没有被释放,但如果去掉关系结点时的两句代码中的任意一句,就能保证正确释放,根本原因就是因为这两句关系结点的代码导致了

刚开始 new 出两个节点借给智能指针管理时,计数器会 ++ 到 1:

C++ 智能指针深剖

进入到关系语句,两个节点建立关系,next 与 node2 一同管理资源 2,prev 与 node1 一同管理资源 1,计数++:

C++ 智能指针深剖

出了 main 函数后 node1 和 node2 的生命周期就完了,此时的计数就会减到 1:

C++ 智能指针深剖

因此导致资源无法释放的原因就是:

  1. 当引用计数减为 0 时对应的资源才会被释放,但资源1的释放取决于资源2当中的 prev 成员,而资源2的释放取决于资源1当中的 next 成员
  2. 而资源1当中的 next 成员的释放又取决于资源1,资源2当中的 prev 成员的释放又取决于资源2,于是这就变成了一个死循环,最终导致资源无法释放

那为什么只进行一个关系操作时就能正确释放?node1 和 node2 生命周期结束时,就会有一个资源计数会减到 0,此时这个资源就会先释放掉,紧接着另一个也会减到 0,随之释放,最后两个资源都可以得到释放

要解决这个问题, 就横空出世了!weak_ptr 是C++11中引入的智能指针,weak_ptr不是用来管理资源的释放的,它主要是用来解决 shared_ptr 的循环引用问题的。

weak_ptr 支持用 shared_ptr 对象来构造对象,构造出的对象与 shared_ptr 对象管理同一个资源,但不会增加资源的引用计数

struct ListNode
{
  std::weak_ptr<ListNode> _next;
  std::weak_ptr<ListNode> _prev;
  int _val;
  ~ListNode()
  {
    cout << "~ListNode()" << endl;
  }
};
int main()
{
  std::shared_ptr<ListNode> node1(new ListNode);
  std::shared_ptr<ListNode> node2(new ListNode);
   
  cout << node1.use_count() << endl;//1
  cout << node2.use_count() << endl;//1
  node1->_next = node2;
  node2->_prev = node1;
  //weak_ptr不会增加资源的引用计数
  cout << node1.use_count() << endl;//1
  cout << node2.use_count() << endl;//1
  return 0;
}      

同理,我们也可以实现一个简易的 weak_ptr :

提供一个无参构造函数,比如 new ListNode 时就会调用 weak_ptr 的无参构造函数, 支持用 shared_ptr

对象拷贝构造 weak_ptr 对象,构造时获取 shared_ptr 管理的资源

支持用 shared_ptr 对象拷贝赋值给 weak_ptr 对象,赋值时获取 shared_ptr 管理的资源

对 * 和 -> 运算符进行重载,使weak_ptr对象具有指针一样的行为

namespace cl
{
  template<class T>
  class weak_ptr
  {
  public:
    weak_ptr()
      :_ptr(nullptr)
    {}
    weak_ptr(const shared_ptr<T>& sp)
      :_ptr(sp.get())//get函数用于获取其管理的资源
    {}
    weak_ptr& operator=(const shared_ptr<T>& sp)
    {
      _ptr = sp.get();
      return *this;
    }
    //可以像指针一样使用
    T& operator*()
    {
      return *_ptr;
    }
    T* operator->()
    {
      return _ptr;
    }
  private:
    T* _ptr; //管理的资源
  };
}      

boost 的智能指针😋

boost 库是为 C++ 标准库提供扩展的一些 C++ 程序库的总称,boost 库社区建立的初衷之一就是为 C++ 的标准化工作提供可供参考的实现,比如在送审 C++ 标准库TR1中,就有十个 boost 库成为标准库的候选方案

继续阅读