一文详解C++智能指针的原理、分类及使用

1. 智能指针介绍

为解决裸指针可能导致的内存泄漏问题。如:

a)忘记释放内存;

b)程序提前退出导致资源释放代码未执行到。

就出现了智能指针,能够做到资源的自动释放

2. 智能指针的原理和简单实现

2.1 智能指针的原理

将裸指针封装为一个智能指针类,需要使用该裸指针时,就创建该类的对象;利用栈区对象出作用域会自动析构的特性,保证资源的自动释放。

2.2 智能指针的简单实现

代码示例:

template<typename T>
class MySmartPtr {
public:
  MySmartPtr(T* ptr = nullptr):mptr(ptr) { // 创建该对象时,裸指针会传给对象
  }

  ~MySmartPtr() {  // 对象出作用域会自动析构,因此会释放裸指针指向的资源
      delete mptr;
  }
  
  // *运算符重载
  T& operator*() {  // 提供智能指针的解引用操作,即返回它包装的裸指针的解引用
      return *mptr; 
  }

  // ->运算符重载
  T* operator->() { // 即返回裸指针
      return mptr;
  }
private:
  T* mptr;
};

class Obj {
public:
  void func() {
      cout << "Obj::func" << endl;
  }
};

void test01() {

  /*创建一个int型的裸指针,
  使用MySmartPtr将其封装为智能指针对象ptr,ptr对象除了作用域就会自动调用析构函数。
  智能指针就是利用栈上对象出作用域自动析构这一特性。*/
  MySmartPtr<int> ptr0(new int);
  *ptr0 = 10;

  MySmartPtr<Obj> ptr1(new Obj);
  ptr1->func();
  (ptr1.operator->())->func(); // 等价于上面

  /*  中间异常退出,智能指针也会自动释放资源。
  if (xxx) {
      throw "....";
  }
  
  if (yyy) {
      return -1;
  }
  */
}

3. 智能指针分类

3.1 问题引入

接着使用上述自己实现的智能指针进行拷贝构造:

void test02() {
  MySmartPtr<int> p1(new int); // p1指向一块int型内存空间
  MySmartPtr<int> p2(p1);      // p2指向p1指向的内存空间
  
  *p1 = 10;   // 内存空间的值为10
  *p2 = 20;   // 内存空间的值被改为20
}

但运行时出错:

原因在于p1和p2指向同一块int型堆区内存空间,p2析构将该int型空间释放,p1再析构时释放同一块内存,则出错。

那可否使用如下深拷贝解决该问题?

MySmartPtr(cosnt MySmartPtr<T>& src) {
  mptr = new T(*src.mptr);
}

不可以。因为按照裸指针的使用方式,用户本意是想将p1和p2都指向该int型堆区内存,使用指针p1、p2都可改变该内存空间的值,显然深拷贝不符合此场景。

3.2 两类智能指针

不带引用计数的智能指针:只能有一个指针管理资源。

auto_ptr;

scoped_ptr;

unique_ptr;.

带引用计数的智能指针:可以有多个指针同时管理资源。

shared_ptr;强智能指针。

weak_ptr: 弱智能指针。这是特例,不能控制资源的生命周期,不能控制资源的自动释放!

3.3 不带引用计数的智能指针

只能有一个指针管理资源。

3.3.1 auto_ptr (不推荐使用)

void test03() {
	auto_ptr<int> ptr1(new int);
	auto_ptr<int> ptr2(ptr1);
	*ptr2 = 20;
	// cout << *ptr2 << endl; // 可访问*ptr2
	cout << *ptr1 << endl; //访问*ptr1却报错
}

如上代码,访问*ptr1为何报错?

因为调用auto_ptr的拷贝构造将ptr1的值赋值给ptr2后,底层会将ptr1指向nullptr;即将同一个指针拷贝构造多次时,只让最后一次拷贝的指针管理资源,前面的指针全指向nullptr。

不推荐将auto_ptr存入容器。

3.3.2 scoped_ptr (使用较少)

scoped_ptr已将拷贝构造函数赋值运算符重载delete了。

scoped_ptr(const scoped_ptr<T>&) = delete; // 删除拷贝构造
scoped_ptr<T>& operator=(const scoped_ptr<T>&) = delete;  // 删除赋值重载

3.3.3 unique_ptr (推荐使用)

unique_ptr也已将拷贝构造函数赋值运算符重载delete。

unique_ptr(const unique_ptr<T>&) = delete; // 删除拷贝构造
unique_ptr<T>& operator=(const unique_ptr<T>&) = delete;  // 删除赋值重载

但unique_ptr提供了带右值引用参数的拷贝构造函数赋值运算符重载,如下:

void test04() {
	unique_ptr<int> ptr1(new int);
	// unique_ptr<int> ptr2(ptr1);  和scoped_ptr一样无法通过编译
	unique_ptr<int> ptr2(std::move(ptr1)); // 但可使用move得到ptr1的右值类型
  // *ptr1  也无法访问
}

3.4 带引用计数的智能指针

可以有多个指针同时管理资源。

原理:给智能指针添加其指向资源的引用计数属性,若引用计数 > 0,则不会释放资源,若引用计数 = 0就释放资源。

具体来说:额外创建资源引用计数类,在智能指针类中加入该资源引用计数类的指针作为其中的一个属性;当使用裸指针创建智能指针对象时,创建智能指针中的资源引用计数对象,并将其中的引用计数属性初始化为1,当后面对该智能指针对象进行拷贝(使用其他智能指针指向该资源时)或时,需要在其他智能指针对象类中将被拷贝的智能指针对象中的资源引用计数类的指针获取过来,然后将引用计数+1;当用该智能指针给其他智能指针进行赋值时,因为其他智能指针被赋值后,它们就不指向原先的资源了,原先资源的引用计数就-1,直至引用计数为0时delete掉资源;当智能指针对象析构时,会使用其中的资源引用计数指针将共享的引用计数-1,直至引用计数为0时delete掉资源。

shared_ptr:强智能指针;可改变资源的引用计数。

weak_ptr:弱智能指针;不可改变资源的引用计数。

带引用计数的智能指针的简单实现:

/*资源的引用计数类*/
template<typename T>
class RefCnt {
public:
  RefCnt(T* ptr=nullptr):mptr(ptr) {
      if (mptr != nullptr) {
          mcount = 1; // 刚创建指针指针时,引用计数初始化为1
      }
  }

  void addRef() {  // 增加引用计数
      mcount++;
  }

  int delRef() {   // 减少引用计数
      mcount--;
      return mcount;
  }
private:
  T* mptr;  // 资源地址
  int mcount; // 资源的引用计数
};

/*智能指针类*/
template<typename T>
class MySmartPtr {
public:
  MySmartPtr(T* ptr = nullptr) :mptr(ptr) { // 创建该对象时,裸指针会传给对象
      mpRefCnt = new RefCnt<T>(mptr);
  }

  ~MySmartPtr() {  // 对象出作用域会自动析构,因此会释放裸指针指向的资源
      if (0 == mpRefCnt->delRef()) {
          delete mptr;
          mptr = nullptr;
      }
  }

  // *运算符重载
  T& operator*() {  // 提供智能指针的解引用操作,即返回它包装的裸指针的解引用
      return *mptr;
  }

  // ->运算符重载
  T* operator->() { // 即返回裸指针
      return mptr;
  }

  // 拷贝构造
  MySmartPtr(const MySmartPtr<T>& src):mptr(src.mptr),mpRefCnt(src.mpRefCnt) {
      if (mptr != nullptr) {
          mpRefCnt->addRef();
      }
  }

  // 赋值重载
  MySmartPtr<T>& operator=(const MySmartPtr<T>& src) {
      if (this == &src) // 防止自赋值
          return *this;

      /*若本指针改为指向src管理的资源,则本指针原先指向的资源的引用计数-1,
      若原资源的引用计数为0,就释放资源*/
      if (0 == mpRefCnt->delRef()) {  
          delete mptr;
      }

      mptr = src.mptr;
      mpRefCnt = src.mpRefCnt;
      mpRefCnt->addRef();
      return *this;
  }
private:
  T* mptr;  // 指向资源的指针
  RefCnt<T>* mpRefCnt; // 资源的引用计数
};

强智能指针原理图:

比如有如下创建强智能指针的语句:

shared_ptr<int> sp1(new int(10));

则如下所示:

(a)智能指针对象sp1中主要包括ptr指针指向其管理的资源,ref指针指向该资源的引用计数,则显然会开辟两次内存。

(b)uses为该资源的强智能指针的引用计数,weaks为该资源的弱智能指针的引用计数。

3.4.1 shared_ptr

强智能指针。可改变资源的引用计数。

(1)强智能指针的交叉引用问题

class B;

class A {
public:
  A() {
      cout << "A()" << endl;
  }
  ~A() {
      cout << "~A()" << endl;
  }
  shared_ptr<B> _ptrb;
};

class B {
public:
  B() {
      cout << "B()" << endl;
  }
  ~B() {
      cout << "~B()" << endl;
  }
  shared_ptr<A> _ptra;
};

void test06() {
  shared_ptr<A> pa(new A());
  shared_ptr<B> pb(new B());

  pa->_ptrb = pb;
  pb->_ptra = pa;

  /*打印pa、pb指向资源的引用计数*/
  cout << pa.use_count() << endl;
  cout << pb.use_count() << endl;
}

输出结果:

可见pa、pb指向的资源的引用计数都为2,因此出了作用域导致pa、pb指向的资源都无法释放,如下图所示:

解决:

建议定义对象时使用强智能指针,引用对象时使用弱智能指针,防止出现交叉引用的问题。

什么是定义对象?什么是引用对象?

定义对象:

使用new创建对象,并创建一个新的智能指针管理它。

引用对象:

使用一个已存在的智能指针来创建一个新的智能指针。

定义对象和引用对象的示例如下:

shared_ptr<int> p1(new int());              // 定义智能指针对象p1
shared_ptr<int> p2 = make_shared<int>(10);  // 定义智能指针对象p2

shared_ptr<int> p3 = p1;  // 引用智能指针p1,并使用p3来共享它
weak_ptr<int> p4 = p2;    // 引用智能指针p2,并使用p4来观察它

如上述代码,因为在test06函数中使用pa对象的_ptrb引用pb对象,使用pb对象的_ptra引用pa对象,因此需要将A类、B类中的_ptrb_ptra的类型改为弱智能指针weak_ptr即可,这样就不会改变资源的引用计数,能够正确释放资源。

3.4.2 weak_ptr

弱智能指针。不能改变资源的引用计数、不能管理对象生命周期、不能做到资源自动释放、不能创建对象,也不能访问资源(因为weak_ptr未提供operator->operator*运算符重载),即不能通过弱智能指针调用函数、不能将其解引用。只能从一个已有的shared_ptr或weak_ptr获得资源的弱引用。

弱智能指针weak_ptr若想用访问资源,则需要使用lock方法将其提升为一个强智能指针,提升失败则返回nullptr。(提升的情形常使用于多线程环境,避免无效的访问,提升程序安全性)

注意:弱智能指针weak_ptr只能观察资源的状态,但不能管理资源的生命周期,不会改变资源的引用计数,不能控制资源的释放。

weak_ptr示例:

void test07() {
  shared_ptr<Boy> boy_sptr(new Boy());
  weak_ptr<Boy> boy_wptr(boy_sptr);
  // boy_wptr->study(); 错误!无法使用弱智能指针访问资源
  cout << boy_sptr.use_count() << endl; // 引用计数为1,因为弱智能指针不改变引用计数

  shared_ptr<int> i_sptr(new int(99));
  weak_ptr<int> i_wptr(i_sptr);
  // cout << *i_wptr << endl; 错误!无法使用弱智能指针访问资源
  cout << i_sptr.use_count() << endl; // 引用计数为1,因为弱智能指针不改变引用计数

  /*弱智能指针提升为强智能指针*/
  shared_ptr<Boy> boy_sptr1 = boy_wptr.lock();
  if (boy_sptr1 != nullptr) {
      cout << boy_sptr1.use_count() << endl; // 提升成功,引用计数为2
      boy_sptr1->study(); // 可以调用
  }

  shared_ptr<int> i_sptr1 = i_wptr.lock();
  if (i_sptr1 != nullptr) {
      cout << i_sptr1.use_count() << endl; // 提升成功,引用计数为2
      cout << *i_sptr1 << endl; // 可以输出
  }  
}

4. 智能指针与多线程访问共享资源的安全问题

现要实现主线程创建子线程,让子线程执行打印Hello的函数,有如下两种方式:

方式1:主线程调用test08函数,在test08函数中启动子线程执行线程函数,如下:

void handler() {
	cout << "Hello" << endl;
}

void func() {
	thread t1(handler);
}

int main(int argc, char** argv) {
	func();
	this_thread::sleep_for(chrono::seconds(1));
	system("pause");
	return 0;
}

运行报错:

方式2:主线程中直接创建子线程来执行线程函数,如下:

void handler() {
	cout << "Hello" << endl;
}

int main(int argc, char** argv) {
  thread t1(handler);
	this_thread::sleep_for(chrono::seconds(1));
	system("pause");
	return 0;
}

运行结果:无报错

上面两种方式都旨在通过子线程调用函数输出Hello,但为什么方式1报错?很简单,不再赘述。

回归本节标题的正题,有如下程序:

class C {
public:
  C() {
      cout << "C()" << endl;
  }

  ~C() {
      cout << "~C()" << endl;
  }

  void funcC() {
      cout << "C::funcC()" << endl;
  }
private:

};

/*子线程执行函数*/
void threadHandler(C* c) {
  this_thread::sleep_for(chrono::seconds(1));
  c->funcC();
}

/* 主线程 */
int main(int argc, char** argv) {
  C* c = new C();
  thread t1(threadHandler, c);
  delete c;
	t1.join();
	return 0;
}

运行结果:

结果显示c指向的对象被析构了,但是仍然使用该被析构的对象调用了其中的funcC函数,显然不合理。

因此在线程函数中,使用c指针访问A对象时,需要观察A对象是否存活。

使用弱智能指针weak_ptr接收对象,访问对象之前尝试提升为强智能指针shared_ptr,提升成功则访问,否则对象被析构。

情形1:对象被访问之前就被析构了:

class C {
public:
  C() {
      cout << "C()" << endl;
  }

  ~C() {
      cout << "~C()" << endl;
  }

  void funcC() {
      cout << "C::funcC()" << endl;
  }
private:

};

/*子线程执行函数*/
void threadHandler(weak_ptr<C> pw) {  // 引用时使用弱智能指针
  this_thread::sleep_for(chrono::seconds(1));
  shared_ptr<C> ps = pw.lock();  // 尝试提升
  if (ps != nullptr) {
      ps->funcC();
  } else {
      cout << "对象已经析构!" << endl;
  }
}

/* 主线程 */
int main(int argc, char** argv) {
  {
      shared_ptr<C> p(new C());
      thread t1(threadHandler, weak_ptr<C>(p));
      t1.detach();
  }
  this_thread::sleep_for(chrono::seconds(5));
	return 0;
}

运行结果:

情形2: 对象访问完才被析构:

class C {
public:
  C() {
      cout << "C()" << endl;
  }

  ~C() {
      cout << "~C()" << endl;
  }

  void funcC() {
      cout << "C::funcC()" << endl;
  }
private:

};

/*子线程执行函数*/
void threadHandler(weak_ptr<C> pw) {  // 引用时使用弱智能指针
  this_thread::sleep_for(chrono::seconds(1));
  shared_ptr<C> ps = pw.lock();  // 尝试提升
  if (ps != nullptr) {
      ps->funcC();
  } else {
      cout << "对象已经析构!" << endl;
  }
}

/* 主线程 */
int main(int argc, char** argv) {
  {
      shared_ptr<C> p(new C());
      thread t1(threadHandler, weak_ptr<C>(p));
      t1.detach();
      this_thread::sleep_for(chrono::seconds(5));
  }  
	return 0;
}

运行结果:

可见shared_ptr与weak_ptr结合使用,能够较好地保证多线程访问共享资源的安全。

5.智能指针的删除器deleter

删除器是智能指针释放资源的方式,默认使用操作符delete来释放资源。

但并非所有智能指针管理的资源都可通过delete释放,如数组、文件资源、数据库连接资源等。

有如下智能指针对象管理一个数组资源:

unique_ptr<int> ptr1(new int[100]);

此时再用默认的删除器则会造成资源泄露,因此需要自定义删除器。

一些为部分自定义删除器的示例:

/* 方式1:类模板 */
template<typename T>
class MyDeleter {
public:
  void operator()(T* ptr) const {
      cout << "数组自定义删除器1." << endl;
      delete[] ptr;
  }
};

/* 方式2:函数 */
void myDeleter(int* p) {
  cout << "数组自定义删除器2." << endl;
  delete[] p;
}

void test09() {
  unique_ptr<int, MyDeleter<int>> ptr1(new int[100]);
  unique_ptr<int, void(*)(int*)> ptr2(new int[100], myDeleter);

  /* 方式3:Lambda表达式 */
  unique_ptr<int, void(*)(int*)> ptr3(new int[100], [](int* p) {
      cout << "数组自定义删除器3." << endl;
      delete[] p;
      });
}

void test10() {
  unique_ptr<FILE, void(*)(FILE*)> ptr2(fopen("1.txt", "w"), [](FILE* f) {
      cout << "文件自定义删除器." << endl;
      fclose(f);
      });
}

运行结果:

6. make_shared和make_unique

6.1 make_shared

下面这种方式创建强智能指针存在缺陷。

shared_ptr<int> sp1(new int(10));

如下图,假设为资源开辟内存成功,但为引用计数结构开辟内存失败,则shared_ptr创建sp1失败,则不会去释放new int(10)的资源,导致内存泄漏。

因此,建议使用make_shared的方式创建强智能指针:

shared_ptr<int> sp1 = make_shared<int>(10);
// 或者
auto sp1 = make_shared<int>(10);
// 或者
auto sp1(make_shared<int>(10));

/*若有Test类,其中有两个int型成员变量,则make_shared创建该类的智能指针的方式如下*/
shared_ptr<Test> sp2 = make_shared<Test>(1, 2);
// 或者
auto sp2 = make_shared<Test>(1, 2);
// 或者
auto sp2(make_shared<Test>(1, 2));

如下图,make_shared创建智能指针时,将资源的内存和引用计数的内存开辟在一起,因此只开辟一次内存,要么开辟成功,要么开辟失败,不存在像上面开辟两次时可能导致内存泄漏的问题。

因此make_shared的优点有:

(a)内存分配效率高;

(b)降低内存泄漏风险。

但也存在缺点:

(a)目前无法自定义删除器;

(b)管理的内存延迟释放;

具体来说,原先的方式,无论弱引用计数weaks为多少,只要强引用计数uses为0,那块int内存就被释放;注意:当weaks和uses都为0时,引用计数内存才被释放;

但由于make_shared的方式为资源内存和引用计数开辟一整块内存,只要weaks和uses有不为0的,这一整块内存就不会被释放。

6.2 make_unique

同样的,建议使用make_unique。

优点:

(a)内存分配效率高;

(b)降低内存泄漏风险。

缺点:

(a)目前无法自定义删除器;

以上就是一文详解C++ 智能指针的原理、分类及使用的详细内容,更多关于C++ 智能指针的资料请关注编程教程其它相关文章!

下一章:C++学习之智能指针中的unique_ptr与shared_ptr

 为什么需要智能指针在上一讲《01 C++如何进行内存资源管理》中,提到了对于堆上的内存资源,需要我们手动分配和释放。管理这些资源是个技术活,一不小心,就会导致内 ...