【C++】智能指针

avatar
作者
筋斗云
阅读量:1

文章目录


在这里插入图片描述

1. 智能指针简介

C++中的智能指针是一种用于管理动态分配的内存的机制,它们可以自动地处理内存的分配和释放,从而减少内存泄漏的风险。智能指针提供了一种安全而方便的方式来管理动态分配的内存,而不需要手动调用 newdelete 操作符。

C++标准库提供了两种主要的智能指针:std::unique_ptrstd::shared_ptr

  1. std::unique_ptr:它是独占所有权的智能指针,意味着同一时间只能有一个 std::unique_ptr 指向相同的对象。当 std::unique_ptr 被销毁时,它所管理的对象也会被销毁。这种智能指针通常用于在函数返回时传递动态分配的对象,或者作为容器的元素。

    #include <memory> #include <iostream>  int main() {     std::unique_ptr<int> ptr(new int(42));     std::cout << *ptr << std::endl;     // 离开作用域时,ptr会自动释放内存     return 0; } 
  2. std::shared_ptr:它是共享所有权的智能指针,允许多个 std::shared_ptr 共同拥有同一个对象。它使用引用计数来跟踪对象的引用数量,只有当引用计数变为零时,对象才会被销毁。因此,它允许在程序中共享资源,并且可以避免出现悬空指针的问题。

    #include <memory> #include <iostream>  int main() {     std::shared_ptr<int> ptr1 = std::make_shared<int>(42);     std::shared_ptr<int> ptr2 = ptr1; // 共享所有权     std::cout << *ptr1 << " " << *ptr2 << std::endl;     // 离开作用域时,只有当引用计数为0时,才会释放内存     return 0; } 

使用智能指针可以提高程序的安全性和可维护性,减少内存泄漏和悬空指针的风险。

2. 为什么需要智能指针?

我们先分析一下,下面这段程序有没有什么内存方面的问题?

int div() { 	int a, b; 	cin >> a >> b; 	if (b == 0) 		throw invalid_argument("除0错误"); 		 	return a / b; }  void Func() { 	// 1、如果p1这里new 抛异常会如何? 	// 2、如果p2这里new 抛异常会如何? 	// 3、如果div调用这里又会抛异常会如何? 	int* p1 = new int; 	int* p2 = new int; 	cout << div() << endl; 	delete p1; 	delete p2; }  int main() { 	try 	{ 		Func(); 	} 	catch (exception& e) 	{ 		cout << e.what() << endl; 	} 	 	return 0; } 

我们发现,如果在 delete 之前出异常,程序就会跳转到 main 函数中被 catch 捕获,导致 delete 未执行,申请的空间未释放,出现内存泄漏。

3. 内存泄漏

3.1 什么是内存泄漏,内存泄漏的危害

什么是内存泄漏:内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。

内存泄漏的危害:长期运行的程序出现内存泄漏,影响很大,如:操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死。

void MemoryLeaks() { 	// 1.内存申请了忘记释放 	int* p1 = (int*)malloc(sizeof(int)); 	int* p2 = new int; 	 	// 2.异常安全问题 	int* p3 = new int[10]; 	Func(); // 这里Func函数抛异常导致delete[] p3未执行,p3没被释放. 	delete[] p3; } 

3.2 内存泄漏分类

C / C++ 程序中,一般我们关心两种方面的内存泄漏:

  • 堆内存泄漏(Heap Leak)

    堆内存指的是程序执行中依据需要 通过 malloc / calloc / realloc / new 等从堆中分配的一块内存,用完后必须通过调用相应的 free 或者 delete 删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生 Heap Leak。

  • 系统资源泄漏

    指程序使用系统分配的资源,比如:套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定。

3.3 如何检测内存泄漏

在C++中检测内存泄漏通常需要借助专门的工具和技术。以下是一些常用的方法:

  1. Valgrind:Valgrind是一个强大的开源内存检测工具,它可以检测内存泄漏、使用未初始化的内存、访问已释放的内存等问题。你可以使用Valgrind运行你的程序,然后它会生成详细的报告,指出内存泄漏的位置和原因。

  2. 内存分析工具:有一些商业和开源的内存分析工具,如Dr. Memory、Memcheck等,它们可以帮助检测内存泄漏和其他内存相关的问题。这些工具通常提供了更直观和详细的报告,帮助开发者定位和解决问题。

  3. 重载new和delete操作符:你可以重载C++中的newdelete操作符,来跟踪内存的分配和释放情况。通过记录每次分配和释放的内存块,并在程序退出时输出未被释放的内存块,你可以发现内存泄漏的位置。

  4. 编写测试用例:编写针对内存管理的测试用例,模拟各种情况下的内存分配和释放操作,并检查程序的行为是否符合预期。虽然这种方法相对简单,但需要覆盖到足够多的情况,才能有效地检测内存泄漏。

无论使用哪种方法,及早发现和解决内存泄漏问题都是非常重要的,因为它们可能导致程序性能下降、崩溃甚至安全漏洞。

3.4 如何避免内存泄漏

  1. 工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。PS:这是理想状态,但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要智能指针来管理才有保证。
  2. 采用 RAII 思想或者智能指针来管理资源。
  3. 有些公司内部规范使用内部实现的私有内存管理库。这套库自带内存泄漏检测的功能选项。
  4. 出问题了使用内存泄漏工具检测。PS:不过很多工具都不够靠谱,或者收费昂贵。

总结一下:

内存泄漏非常常见,解决方案分为两种:

  • 事前预防型,如智能指针等。
  • 事后查错型,如泄漏检测工具。

4. 智能指针的使用及原理

4.1 RAII

RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。

在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:

  • 不需要显式的释放资源。
  • 采用这种方式,对象所需的资源在其生命期内始终保持有效。
// 使用RAII思想设计的SmartPtr类 template<class T> class SmartPtr { public: 	SmartPtr(T* ptr = nullptr) 		: _ptr(ptr) 	{} 	 	~SmartPtr() 	{ 		if (_ptr) 			delete _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> sp1(new int); 	SmartPtr<int> sp2(new int); 	cout << div() << endl; }  int main() { 	try 	{ 		Func(); 	} 	catch (const exception& e) 	{ 		cout << e.what() << endl; 	} 	 	return 0; } 

4.2 智能指针的原理

上述的 SmartPtr 还不能将其称为智能指针,因为他还不具有指针的行为。指针可以解引用,也可以通过 -> 去访问所指空间中的内容,因此:AutoPtr 模板类中还需要将 *-> 重载下,才可让其像指针一样去使用

template<class T> class SmartPtr { public: 	// RAII 	SmartPtr(T* ptr) 		: _ptr(ptr) 	{}  	~SmartPtr() 	{ 		cout << "delete: " << _ptr << endl; 		delete _ptr; 	}  	// 像指针一样 	T& operator*() 	{ 		return *_ptr; 	}  	T* operator->() 	{ 		return _ptr; 	}  private: 	T* _ptr; }; 

总结一下智能指针的原理:

  1. RAII 特性。
  2. 重载 operator* 和 operator->,具有指针一样的行为。

4.3 std::auto_ptr

std::auto_ptr 文档介绍

C++98 版本的库中就提供了 auto_ptr 的智能指针。下面演示 auto_ptr 的使用及问题

auto_ptr 的实现原理:管理权转移的思想,下面简化模拟实现了一份 auto_ptr 来了解它的原理

// C++98 管理权转移 auto_ptr namespace tjq { 	template<class T> 	class auto_ptr 	{ 	public: 		// RAII 		auto_ptr(T* ptr) 			: _ptr(ptr) 		{}  		// ap2(ap1) 		auto_ptr(auto_ptr<T>& ap) 		{ 			// 管理权转移 			_ptr = ap._ptr; 			ap._ptr = nullptr; 		}  		~auto_ptr() 		{ 			cout << "delete: " << _ptr << endl; 			delete _ptr; 		}  		T& operator*() 		{ 			return *_ptr; 		}  		T* operator->() 		{ 			return _ptr; 		}  	private: 		T* _ptr; 	}; }  int main() { 	std::auto_ptr<int> sp1(new int); 	std::auto_ptr<int> sp2(sp1); // 管理权转移  	// sp1悬空 	*sp2 = 10; 	cout << *sp2 << endl; 	cout << *sp1 << endl; 	return 0; } 

std::auto_ptr 是 C++11 之前的标准库中的一个智能指针,用于管理动态分配的对象。虽然 std::auto_ptr 提供了自动内存管理的功能,但它存在一些严重的缺陷,因此在 C++11 中被弃用,推荐使用 std::unique_ptr 作为替代。

以下是 std::auto_ptr 的主要缺陷:

  1. 所有权转移的不透明性std::auto_ptr 拥有独占所有权的特性,即当一个 std::auto_ptr 赋值给另一个时,所有权会从源指针转移到目标指针。这种所有权转移的不透明性可能导致意外的行为和内存泄漏,特别是在涉及到函数调用、容器存储等情况下。

  2. 不适用于标准容器:由于所有权转移的特性,std::auto_ptr 不适合在标准容器中使用。因为标准容器在执行拷贝或移动操作时,会对元素进行值的复制或移动,而不是简单地转移所有权。这会导致 std::auto_ptr 在容器中的行为不符合预期,可能导致程序崩溃或内存泄漏。

  3. 删除语义模糊std::auto_ptr 的删除语义(析构时释放指针所指向的内存)不够清晰,可能导致程序的行为不稳定。例如,当一个 std::auto_ptr 被复制时,原始指针可能会在意料之外地被释放,导致悬空指针的问题。

  4. 没有移动语义std::auto_ptr 缺乏移动语义,这意味着无法将它用于支持现代 C++ 中的移动语义和右值引用,从而限制了其在性能和代码简洁性方面的优势。

由于上述缺陷,C++11 引入了更安全、更清晰的智能指针 std::unique_ptrstd::shared_ptr,分别用于独占所有权和共享所有权的场景。这些智能指针具有更好的语义,更好地支持现代 C++ 特性,并且避免了 std::auto_ptr 存在的问题。因此,建议在新的 C++ 代码中使用 std::unique_ptrstd::shared_ptr,而避免使用已经被弃用的 std::auto_ptr

结论:auto_ptr 是一个失败的设计,很多公司明确要求不能使用 auto_ptr

4.4 std::unique_ptr

C++11 中开始提供更靠谱的 unique_ptr

unique_ptr 文档介绍

unique_ptr 的实现原理:简单粗暴的防拷贝,下面简化模拟实现了一份 unique_ptr 来了解它的原理

// C++11 库才更新智能指针实现 // C++11 出来之前,boost 搞出了更好用的 scoped_ptr/shared_ptr/weak_ptr // C++11 将 boost 库中智能指针精华部分吸收了过来 // C++11 -> unique_ptr/shared_ptr/weak_ptr  // unique_ptr/scoped_ptr // 原理:简单粗暴 -- 防止拷贝 namespace tjq { 	template<class T> 	class unique_ptr 	{ 	public: 		unique_ptr(T* ptr) 			: _ptr(ptr) 		{}  		unique_ptr(const unique_ptr<T>& ap) = delete; 		unique_ptr<T>& operator=(const unique_ptr<T>& ap) = delete;  		~unique_ptr() 		{ 			cout << "delete: " << _ptr << endl; 			delete _ptr; 		}  		T& operator*() 		{ 			return *_ptr; 		}  		T* operator->() 		{ 			return _ptr; 		}  	private: 		T* _ptr; 	}; }  int main() { 	tjq::unique_ptr<int> sp1(new int); 	tjq::unique_ptr<int> sp2(sp1);  	std::unique_ptr<int> sp1(new int); 	std::unique_ptr<int> sp2(sp1);  	return 0; } 

虽然 std::unique_ptr 是 C++11 引入的一种智能指针,用于独占所有权的动态分配对象,但它也有一些局限性和缺陷。以下是 std::unique_ptr 的一些缺点:

  1. 无法在容器中进行复制std::unique_ptr 不能像 std::shared_ptr 那样在容器中进行复制。因为 std::unique_ptr 不能被复制,它只能通过移动(move)语义来转移所有权。这意味着如果你想在容器中存储 std::unique_ptr 对象,你必须使用 std::move 来转移所有权,这可能使代码看起来不够直观。

  2. 所有权的单一性:与 std::shared_ptr 不同,std::unique_ptr 一次只能有一个指针拥有动态分配的对象。这意味着 std::unique_ptr 不适合在多个地方共享对象所有权的情况下使用。如果你需要在不同地方共享对象所有权,则需要使用 std::shared_ptr

  3. 不适合循环引用场景:由于 std::unique_ptr 的所有权模式,它并不适合处理可能导致循环引用的情况。循环引用可能会导致内存泄漏,因为 std::unique_ptr 不提供像 std::weak_ptr 那样的弱引用机制来解决循环引用问题。

  4. 不支持空指针的复制std::unique_ptr 不能像 std::shared_ptr 那样在空指针之间进行复制。这意味着无法像下面这样进行操作:std::unique_ptr<int> ptr1 = nullptr; std::unique_ptr<int> ptr2 = ptr1;,因为 std::unique_ptr 不能被复制。

尽管 std::unique_ptr 存在一些缺点,但它仍然是一种非常有用的智能指针,特别适用于独占所有权的情况。在使用 std::unique_ptr 时,要考虑其局限性,并根据具体情况选择最适合的智能指针类型。

4.5 std::shared_ptr

C++ 中开始提供更靠谱的并且支持拷贝的 shared_ptr

shared_ptr 文档介绍

shared_ptr 的原理:是通过引用计数的方式来实现多个 shared_ptr 对象之间共享资源

  1. shared_ptr 在其内部,给每个资源都维护着一份计数,用来记录该份资源被几个对象共享
  2. 对象被销毁时(也就是析构函数调用),就说明自己不使用该资源了,对象的引用计数 -1。
  3. 如果引用计数是 0,就说明自己是最后一个使用该资源的对象,必须释放资源
  4. 如果不是 0,就说明除了自己还有其他对象在使用该份资源,不能释放该资源,否则其他对象就成野指针了。
// 引用计数支持多个拷贝管理同一个资源,最后一个析构对象释放资源 namespace tjq { 	template<class T> 	class shared_ptr 	{ 	public: 		template<class D> 		shared_ptr(T* ptr, D del) 			: _ptr(ptr) 			, _pcount(new int(1)) 			, _del(del) 		{}  		shared_ptr(T* ptr = nullptr) 			: _ptr(ptr) 			, _pcount(new int(1)) 		{}  		shared_ptr(const shared_ptr<T>& sp) 		{ 			_ptr = sp._ptr; 			_pcount = sp._pcount;  			// 拷贝时++计数 			++(*_pcount); 		}  		void release() 		{ 			// 说明最后一个管理对象析构了,可以释放资源了 			if (--(*_pcount) == 0) 			{ 				cout << "delete: " _ptr << endl; 				_del(_ptr); 				delete _pcount; 			} 		}  		shared_ptr<T>& operator=(const shared_ptr<T>& sp) 		{ 			if (_ptr != sp._ptr) 			{ 				release();  				_ptr = sp._ptr; 				_pcount = sp._pcount;  				// 拷贝时++引用计数 				++(*_pcount); 			}  			return *this; 		}  		~shared_ptr() 		{ 			release(); 		}  		int use_count() 		{ 			return *_pcount; 		}  		T& operator*() 		{ 			return *_ptr; 		}  		T* operator->() 		{ 			return _ptr; 		}  		T* get() const 		{ 			return _ptr; 		}  	private: 		T* _ptr; 		int* _pcount; 		function<void(T*)> _del = [](T* ptr) { delete ptr; }; 	};   	// 简化版本的weak_ptr实现 	// 不支持RAII,不参与资源管理 	template<class T> 	class weak_ptr 	{ 	public: 		weak_ptr() 			: _ptr(nullptr) 		{}  		weak_ptr(const shared_ptr<T>& sp) 		{ 			_ptr = sp.get(); 		}  		weak_ptr<T>& operator=(const shared_ptr<T>& sp) 		{ 			_ptr = sp.get(); 			return *this; 		}  		T& operator*() 		{ 			return *_ptr; 		}  		T* operator->() 		{ 			return _ptr; 		}  	private: 		T* _ptr; 	}; }  // shared_ptr智能指针是线程安全的吗? // 是的,引用计数的加减是加锁保护的。但是指向资源不是线程安全的。  // 指向堆上资源的线程安全问题是访问的人处理的,智能指针不管,也管不了; // 引用计数的线程安全问题,是智能指针要处理的。  int main() { 	tjq::shared_ptr<int> sp1(new int); 	tjq::shared_ptr<int> sp2(sp1); 	tjq::shared_ptr<int> sp3(sp1);  	tjq::shared_ptr<int> sp4(new int); 	tjq::shared_ptr<int> sp5(sp4);  	//sp1 = sp1; 	//sp1 = sp2;  	//sp1 = sp4; 	//sp2 = sp4; 	//sp3 = sp4;  	*sp1 = 2; 	*sp2 = 3;  	return 0; } 

std::shared_ptr 的线程安全问题

std::shared_ptr 的循环引用

struct ListNode { 	int _data; 	shared_ptr<ListNode> _prev; 	shared_ptr<ListNode> _next; 	 	~ListNode() { cout << "~ListNode()" << endl; } };  int main() { 	shared_ptr<ListNode> node1(new ListNode); 	shared_ptr<ListNode> node2(new ListNode); 	cout << node1.use_count() << endl; 	cout << node2.use_count() << endl; 	 	node1->_next = node2; 	node2->_prev = node1; 	 	cout << node1.use_count() << endl; 	cout << node2.use_count() << endl; 	 	return 0; } 

循环引用分析

  1. node1 和 node2 两个智能指针对象指向两个节点,引用计数变成 1,我们不需要手动 delete。
  2. node1 的 _next 指向 node2,node2 的 _prev 指向 node1,引用计数变成 2。
  3. node1 和 node2 析构,引用计数减到 1,但是 _next 还指向下一个节点,_prev 还指向上一个节点。
  4. 也就是说 _next 析构了,node2 就释放了;_prev 析构了,node1 就释放了。
  5. 但是 _next 属于 node 的成员,node1 释放了,_next 才会析构,而 node1 由 _prev 管理,_prev 属于 node2 成员,所以这就叫循环引用,谁也不会释放。

在这里插入图片描述

解决方案:在引用计数的场景下,把节点中的 _prev 和 _next 改成 weak_ptr 就可以了。

原理就是:node1->_next = node2 和 node2->_prev = node1 时,weak_ptr 的 _next 和 _prev 不会增加 node1 和 node2 的引用计数。

struct ListNode { 	int _data; 	weak_ptr<ListNode> _prev; 	weak_ptr<ListNode> _next; 	 	~ListNode() { cout << "~ListNode()" << endl; } };  int main() { 	shared_ptr<ListNode> node1(new ListNode); 	shared_ptr<ListNode> node2(new ListNode); 	cout << node1.use_count() << endl; 	cout << node2.use_count() << endl; 	 	node1->_next = node2; 	node2->_prev = node1; 	 	cout << node1.use_count() << endl; 	cout << node2.use_count() << endl; 	 	return 0; } 

如果不是 new 出来的对象,如何通过智能指针管理呢?其实 shared_ptr 设计了一个删除器来解决这个问题

// 仿函数的删除器 template<class T> struct FreeFunc { 	void operator()(T* ptr) 	{ 		cout << "free:" << ptr << endl; 		free(ptr); 	} };  template<class T> struct DeleteArrayFunc { 	void operator()(T* ptr) 	{ 		cout << "delete[]" << ptr << endl; 		delete[] ptr; 	} };  int main() { 	FreeFunc<int> freeFunc; 	std::shared_ptr<int> sp1((int*)malloc(4), freeFunc); 	 	DeleteArrayFunc<int> deleteArrayFunc; 	std::shared_ptr<int> sp2((int*)malloc(4), deleteArrayFunc); 	 	std::shared_ptr<A> sp4(new A[10], [](A* p) { delete[] p; }); 	 	std::shared_ptr<FILE> sp5(fopen("test.txt", "w"), [](FILE* p) { fclose(p); }); 		 	return 0; } 

尽管 std::shared_ptr 是 C++ 中强大的智能指针之一,但它也有一些缺陷和限制。以下是一些 std::shared_ptr 的缺点:

  1. 性能开销std::shared_ptr 使用引用计数来跟踪对象的共享所有权,这意味着每次创建、复制、销毁 std::shared_ptr 都需要对引用计数进行操作,这可能导致一定的性能开销。特别是在高并发或频繁创建销毁对象的场景下,引用计数的更新可能成为性能瓶颈。

  2. 线程安全性std::shared_ptr 的引用计数机制不是线程安全的。当多个线程同时访问和修改同一个 std::shared_ptr 时,可能会导致竞态条件和数据竞争,从而引发未定义的行为。因此,在多线程环境中使用 std::shared_ptr 需要额外的同步措施,例如使用互斥锁或原子操作,以确保线程安全性。

  3. 循环引用:虽然 std::shared_ptr 通过引用计数可以有效地管理动态分配的对象,但它无法解决循环引用问题。当两个或多个 std::shared_ptr 相互持有对方的强引用时,可能会导致循环引用,从而导致对象无法正确释放,产生内存泄漏。为了避免循环引用,通常需要使用 std::weak_ptr 或其他手段来打破循环引用。

  4. 堆内存开销:由于 std::shared_ptr 使用堆内存来存储引用计数,因此每个 std::shared_ptr 实例都会带有一定的额外内存开销。在大量使用 std::shared_ptr 的情况下,这些额外的开销可能会导致内存碎片化和性能下降。

尽管 std::shared_ptr 存在这些缺点,但它仍然是一种非常有用的智能指针,特别适用于需要共享对象所有权的场景。在使用 std::shared_ptr 时,需要注意这些缺陷,并根据具体情况进行权衡和选择。

4.6 std::weak_ptr

std::weak_ptr 是 C++ 标准库中的一个类模板,用于解决 C++ 中的循环引用问题。在 C++ 中,循环引用可能导致对象无法被正确地释放,从而造成内存泄漏。std::weak_ptr 允许观察由 std::shared_ptr 管理的对象,但不会增加其引用计数,也不会延长对象的生命周期。这样可以避免循环引用,从而更安全地管理内存。

当存在循环引用时,可以将其中一个或多个 std::shared_ptr 替换为 std::weak_ptr,从而打破循环。这样,即使对象之间互相持有对方的 std::weak_ptr,也不会导致循环引用,因为 std::weak_ptr 不会增加对象的引用计数。当没有任何 std::shared_ptr 持有对象时,对象会被正确地销毁,避免内存泄漏。

5. C++11 和 boost 中智能指针的关系

  1. C++98 中产生了第一个智能指针 auto_ptr。
  2. C++ boost 给出了更实用的 scoped_ptr、shared_ptr 和 weak_ptr。
  3. C++TR1,引入了 shared_ptr 等。不过需要注意的是 TR1 并不是标准版。
  4. C++11 引入了 unique_ptr、shared_ptr 和 weak_ptr。需要注意的是 unique_ptr 对应 boost 的 scoped_ptr,并且这些智能指针的实现原理是参考 boost 中实现的。

END

广告一刻

为您即时展示最新活动产品广告消息,让您随时掌握产品活动新动态!