C++智能指针

C++智能指针

智能指针是一种用于管理动态分配内存的对象,可以在内存不再需要时自动释放。智能指针通过重载了指针操作符的类来实现,以模拟指针的行为,但具有自动资源管理的功能。

RAII思想

RAII 是资源获取即初始化(Resource Acquisition Is Initialization)的缩写,是一种 C++ 编程范式,它通过在对象的构造函数中获取资源,利用对象的生命周期来管理资源的释放,从而确保资源在不再需要时被正确释放。智能指针是RAII思想的一种产物,在多线程中,守卫锁Guard Lock也是一种常见的RAII风格的加锁方式。

RAII风格的Lock Guard(Linux下原生线程库)

arduino 复制代码
#pragma once
#include <iostream>
#include <pthread.h>
//RAII枷鎖
class Mutex
{
public:
 Mutex(pthread_mutex_t* mutex)
 :_mutex(mutex)
 {
     pthread_mutex_init(_mutex,nullptr);
 }
 void lock()
 {
     pthread_mutex_lock(_mutex);
 }
 void unlock()
 {
     pthread_mutex_unlock(_mutex);
 }
 ~Mutex()
 {
     pthread_mutex_destroy(_mutex);
 }
private:
 pthread_mutex_t* _mutex;
};

class LockGuard
{
public:
 LockGuard(pthread_mutex_t* pmtx)
 :_mtx(pmtx)
 {
     _mtx.lock();
 }
 ~LockGuard()
 {
     _mtx.unlock();
}
private:
 Mutex _mtx;
};

智能指针

C++标准库提供了两种主要的智能指针:std::unique_ptrstd::shared_ptr。此外,C++17还引入了 std::weak_ptr

std::auto_ptr

C++在C++98中就引入了auto_ptr,但是但在 C++11 标准中已经被废弃,并在 C++17 中被完全移除。这是因为 auto_ptr 存在一些严重的缺陷。

问题如下:

c 复制代码
int main()
{
    std::auto_ptr<int> p1(new int);
    std::auto_ptr<int> p2(p1);
    *p1 = 10;
    *p2 = 10;
    std::cout << *p1 << std::endl;
    std::cout << *p2 << std::endl;
    return 0;
}

上面的代码以指针的角度来看,就是让两个指针维护同一块地址空间,但是上面程序运行会奔溃。这是标准库中的auto_ptr

简单实现一份auto_ptr然后了解一下auto_ptr的问题。

arduino 复制代码
#pragma once
#include <iostream>
namespace ding
{
    template<class T>
    class auto_ptr
    {
    public:
        auto_ptr(T* ptr)
            :_ptr(ptr)
        {}
        auto_ptr(auto_ptr<T> & sp)
            :_ptr(sp._ptr)
        {
            sp._ptr = nullptr;
        }
        //赋值运算符(注意先释放原空间在赋值 以及自己给自己赋值的情况)
        auto_ptr<T>& operator=(const auto_ptr<T>& sp)
        {
            if (&sp != this)
            {
                if (_ptr != nullptr)
                {
                    delete _ptr;
                }
                _ptr = sp._ptr;
                sp._ptr = nullptr;
            }
            return *this;
        }
        //模拟指针行为
        T& operator*() const
        {
            return *_ptr;
        }
        T* operator->() const
        {
            return _ptr;
        }
        ~auto_ptr()
        {
            if (_ptr != nullptr)
            {
                delete _ptr;
            }
        }
    private:
        T* _ptr;
    };
}
​

使用auto_ptr

c 复制代码
#include "auto_ptr.h"
int main()
{
    ding::auto_ptr<int> p1(new int);
    ding::auto_ptr<int> p2(p1);
    *p1 = 10;
    *p2 = 10;
    std::cout << *p1 << std::endl;
    std::cout << *p2 << std::endl;
    return 0;
}

当执行完ding::auto_ptr<int> p2(p1);后,p1对象的指针已经被置空了,在对其解引用,就会出现对空指针解用的问题。空指针是不能解引用的。这种情况编译器应该要出警告的,但是我的编译器还是能运行的,只是退出码不正常。这应该是vs2022的bug(我用的是2022测试版的)。

只能通过监视窗口来看!

C++98提供的auto_ptr最大的问题就是这个了,称之为管理权转移,将p1的管理权转移给p2然后自己悬空。导致了auto_ptr直接被禁用,甚至在17中被移除了。

std::unique_ptr

经过十几年后,在C++11中,出现了比auto_ptr更靠谱的unique_ptrunique_ptr主要解决auto_ptr带来的问题,在unique_ptr中直接禁用了拷贝和赋值。

std::unique_ptr的使用

c 复制代码
int main()
{
    std::unique_ptr<int> p1(new int);
    std::unique_ptr<int> p2(p1);//error
    p2 = p1;//error
}

unique_ptr不能再使用拷贝和赋值了,所以他的使用场景就被限制了,对于有些地址空间需要更多的指针来维护是不能实现的。

std::unique_ptr简易模拟实现

unique_ptr就很简单了,对比auto_ptr直接把拷贝构造和赋值用C++11的语法用delete禁用就行了。

arduino 复制代码
namespace ding
{
    template<class T>
    class unique_ptr
    {
    public:
        unique_ptr(T* ptr)
            :_ptr(ptr)
        {}
        unique_ptr(unique_ptr<T>& sp) = delete;
        //赋值运算符
        unique_ptr<T>& operator=(const unique_ptr<T>& sp) = delete;
        
        //模拟指针行为
        T& operator*() const
        {
            return *_ptr;
        }
        T* operator->() const
        {
            return _ptr;
        }
        ~unique_ptr()
        {
            if (_ptr != nullptr)
            {
                delete _ptr;
            }
        }
    private:
        T* _ptr;
    };
}

std::shared_ptr

C++11还提供了更靠谱的智能指针shared_ptr。解决了auto_ptr的悬空问题和unique_ptr的防拷贝问题。

std::shared_ptr 使用引用计数来跟踪有多少个智能指针指向相同的资源,当最后一个 std::shared_ptr 被销毁时,它所管理的资源也会被释放。

  1. shared_ptr在其内部,给每个资源都维护了着一份计数,用来记录该份资源被几个对象共享。
  2. 在对象被销毁时(也就是析构函数调用),就说明自己不使用该资源了,对象的引用计数减 一。
  3. 如果引用计数是0,就说明自己是最后一个使用该资源的对象,必须释放该资源;
  4. 如果不是0,就说明除了自己还有其他对象在使用该份资源,不能释放该资源,否则其他对象就成野指针了。

shared_ptr 简易模拟实现

引用计数是shared_ptr对象的公共资源,也就是说在实现shared_prt时,不能简单的将引用计数_ref_count设计为一个普通的成员变量,设计成为普通的成员变量,就意味着每个shared_ptr对象都有一个引用计数。普通的成员变量导致无法正确记录内存块的使用情况。

图解:

使用static也不能解决,因为static是所有类对象共享的。这就会导致只要使用shared_ptr不论管理的是哪一块地址空间,用的都是同一个引用计数。 图解:

将引用计数定义成为一个指针,当一块地址空间第一次被shared_ptr对象维护时,在堆区开辟一块空间用于存储其对应得引用计数,如果其他shared_ptr对象也要维护这块地址空间,除了将地址给他还要把引用计数的地址也给他。 图解:

arduino 复制代码
namespace ding
{
    template<class T>
    class shared_ptr
    {
    public:
            shared_ptr(T* ptr = nullptr)
                    :_ptr(ptr)
                    ,_ref_count(new size_t(1))
            {}
            shared_ptr(shared_ptr<T>& sp)
                    :_ptr(sp._ptr)
                    ,_ref_count(sp._ref_count)
            {
                    ++(*_ref_count);
            }
            shared_ptr<T>& operator=(shared_ptr<T>& sp)
            {
                    if (this != &sp)
                    {
                            if (--(*_ref_count) == 0)
                            {
                                    delete _ref_count;
                                    delete _ptr;
                            }
                            _ptr = sp._ptr;
                            _ref_count = sp._ref_count;
                            ++(*_ref_count);
                    }
                    return *this;
            }
            T& operator*() const 
            {
                    return *_ptr;
            }
            T* operator->()const
            {
                    return _ptr;
            }
            ~shared_ptr()
            {
                    if (--(*_ref_count) == 0 && _ptr != nullptr)
                    {
                            delete _ptr;
                            delete _ref_count;
                    }
            }
    private:
            T* _ptr;
            size_t* _ref_count;//引用计数
    };
}

通过监视窗口可以看出当前代码得sp1,sp2,sp3都指向得是同一块地址空间。引用计数都是3。没有问题。 执行完sp3 = sp4 后,sp1和sp2得引用计数应该变为2,sp3和sp4应该指向同一块地址空间,引用计数也是同一个。 监视窗口观看也没有问题。

如果是普通得成员变量得话,上面同样得代码,监视窗口结果如下:

引用计数得结果完全不符合要求。

shared_ptr线程安全问题

std::sharer_ptr本身是线程安全得,当多个线程同时访问同一个 std::shared_ptr 对象时,std::shared_ptr 本身能够确保引用计数的操作是原子的,从而保证了线程安全性。 比如下面得场景:

创建两个线程,让他们疯狂的拷贝当前智能指针对象,引用计数就会一直增加。 线程执行完后,引用计数应该还是1。因为copy对象是一个局部对象,出了作用域就释放了。

cpp 复制代码
void fun(std::shared_ptr<int> sp, size_t n)
{
	for (size_t i = 0; i < n; ++i)
	{
		std::shared_ptr<int> copy(sp);
	}
}
int main()
{
	std::shared_ptr<int> sp1(new int);
	const size_t n = 10000;
	std::thread t1(fun, sp1, n);
	std::thread t2(fun, sp1, n);


	t1.join();
	t2.join();
	cout << sp1.use_count() << endl;
	return 0;
}

运行结果:

没问题是线程安全的。

但是上面模拟实现的,存在线程安全问题。因为引用计数是共享得,多线程对同一个引用计数进行自增或自减操作,而自增和自减操作都不是原子操作,因此需要通过加锁来对引用计数进行保护,否则就会导致线程安全问题。

线程安全版本的shared_ptr

  • 对引用计数操作的地方封装成为一个函数,方便加锁和解锁。
  • 锁也是所有对象共享的,也需要创建在堆区。
  • 在调用拷贝和赋值时,也需要把锁交给当前对象。
  • 释放的时候也需要释放互斥锁,先解锁在释放,释放完直接return即可,不用执行后面的解锁逻辑。
cpp 复制代码
namespace ding
{
    template<class T>
	class unique_ptr
	{
	public:
		unique_ptr(T* ptr)
			:_ptr(ptr)
		{}
		unique_ptr(unique_ptr<T>& sp) = delete;
		//赋值运算符(注意先释放原空间在赋值 以及自己给自己赋值的情况)
		unique_ptr<T>& operator=(const unique_ptr<T>& sp) = delete;
		//模拟指针行为
		T& operator*() const
		{
			return *_ptr;
		}
		T* operator->() const
		{
			return _ptr;
		}
		~unique_ptr()
		{
			if (_ptr != nullptr)
			{
				delete _ptr;
			}
		}
	private:
		T* _ptr;
	};

	template<class T>
	class shared_ptr
	{
	public:
		shared_ptr(T* ptr = nullptr)
			:_ptr(ptr)
			,_ref_count(new size_t(1))
			,_sp_mtx(new std::mutex)
		{}
		void AddRef()
		{
			_sp_mtx->lock();
			++(*_ref_count);
			_sp_mtx->unlock();
		}
		void DeleteRef()
		{
			_sp_mtx->lock();
			if (--(*_ref_count) == 0 && _ptr != nullptr)
			{
				delete _ptr;
				delete _ref_count;

				_sp_mtx->unlock();
				delete _sp_mtx;
				return;
			}
			_sp_mtx->unlock();
		}
		shared_ptr(shared_ptr<T>& sp)
			:_ptr(sp._ptr)
			,_ref_count(sp._ref_count)
			,_sp_mtx(sp._sp_mtx)
		{
			AddRef();
		}
		shared_ptr<T>& operator=(shared_ptr<T>& sp)
		{
			//if (this != &sp) 可以 但是使用地址比较更好一点 比如sp1和sp2维护一块地址空间,sp1 = sp2 里面得逻辑就不用再走一遍了
			if(_ptr != sp._ptr)
			{
				DeleteRef();

				_ptr = sp._ptr;
				_ref_count = sp._ref_count;
				_sp_mtx = sp._sp_mtx;
				AddRef();
			}
			return *this;
		}
		T& operator*() const 
		{
			return *_ptr;
		}
		T* operator->()const
		{
			return _ptr;
		}
		~shared_ptr()
		{
			DeleteRef();
		}
		size_t RefCount()
		{
			return *_ref_count;
		}
	private:
		T* _ptr;
		size_t* _ref_count;//引用计数
		std::mutex* _sp_mtx;
	};
}

shared_ptr本身是线程安全的,但是shared_ptr对象维护的对象并不是线程安全的。也就是说多线程访问同一个shared_ptr对象管理的资源,这种行为不是线程安全的。需要自己进行处理。

sharer_ptr的问题

shared_ptr并不是完美的,第一个问题就是线程安全问题。 虽然 std::shared_ptr 本身是线程安全的,但对其引用计数的访问需要原子操作。在高并发的多线程环境中,引用计数的原子操作可能会成为性能瓶颈。此外,在使用 std::shared_ptr 进行多线程编程时,仍然需要注意并发访问共享资源的问题。 还有一个问题是比较严重的,就是循环引用问题。

循环引用问题 :如果两个或多个对象彼此持有对彼此的 std::shared_ptr 引用,就会导致循环引用。这会导致对象永远无法被释放,从而导致内存泄漏。 比如下面这种情况:

cpp 复制代码
struct ListNode
{
	std::shared_ptr<ListNode> _prev = nullptr;
	std::shared_ptr<ListNode> _next = nullptr;
	int _data;

	~ListNode()
	{
		cout << "~ListNode()" << endl;
	}
	
};
void TestLoopRef()
{
	std::shared_ptr<ListNode> node1(new ListNode);
	std::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;
}
int main()
{
	TestLoopRef();
	return 0;
}

运行结果: 不会调用ListNode的析构释放资源。 图示如下:

  1. node1和node2两个智能指针对象分别维护两块地址空间,引用计数都为1。
  2. node1的_next 指向node2,node2的_prev指向node1。引用计数变为2.
  3. TestLoopRef函数执行完毕,局部对象node1和node2生命周期结束,调用析构释放内存。引用计数减到1,但是_next和_prev还分别维护着对方。
  4. 只有当_next和_prev析构了,node2和node1才会释放,否则就会造成内存泄漏问题。
  5. 但是_next属于node1的成员,node1释放了,_next才会析构,而node1又由_prev维护,同理,_prev属于node2的成员,node2释放了,_prev才会析构,但是node2又由_next维护。
  6. 这种情况就叫做循环引用,谁也不会释放。

解决这个问题,引入了weak_ptr

std::weak_ptr

std::weak_ptr 是 C++ 标准库提供的另一个智能指针类型,它用于解决 std::shared_ptr 循环引用问题和弱引用场景。相比于 std::shared_ptrstd::weak_ptr 并不增加对象的引用计数,因此不会影响对象的生命周期。

使用weak_ptr解决循环引用:

cpp 复制代码
struct ListNode
{
	std::weak_ptr<ListNode> _prev;//使用weak_ptr
	std::weak_ptr<ListNode> _next;
	int _data;

	~ListNode()
	{
		cout << "~ListNode()" << endl;
	}
	
};
void TestLoopRef()
{
	std::shared_ptr<ListNode> node1(new ListNode);
	std::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;
}
int main()
{
	TestLoopRef();
	return 0;
}

运行结果:

成功调用析构,并且不会增加引用计数

weak_ptr的原理

  • weak_ptr并不符合RAII思想,只是辅助解决shared_ptr的循环引用问题。他是一种弱智指针,支持像指针一样使用就行。
  • weak_ptr要支持用shared_ptr对象拷贝构造和拷贝赋值weak_ptr对象,构造时获取shared_ptr对象管理的资源。
cpp 复制代码
template<class T>
class weak_ptr
{
public:
        weak_ptr()
                :_ptr(nullptr)
        {}
        weak_ptr(const ding::shared_ptr<T>& sp)
                :_ptr(sp.get())
        {
        }
        weak_ptr<T>& operator=(const ding::shared_ptr<T>& sp)
        {
                _ptr = sp.get();
                return *this;
        }
        T& operator*()
        {
                return *_ptr;
        }
        T* operator->()
        {
                return _ptr;
        }

private:
        T* _ptr;
};

定制删除器

shared_ptr对象在析构的时候都使用的是delete进行释放资源,这不是很合理的,如果智能指针管理的是new[]或者不是new申请的。管理的是一个文件指针。使用delete就会有问题。 std提供了定制删除器。

  • p 就是智能指管理的资源。
  • del 就是定制的删除器,可以是发仿函数,函数指针,lambda表达式。

比如shared_ptr对象管理的是一个文件指针。

cpp 复制代码
int main()
{
	std::shared_ptr<FILE> fp1(fopen("main.cpp", "w"));
	return 0;
}

这样是会出错的。因为默认析构是delete。这里采用仿函数进行解决:

cpp 复制代码
template<class T>
struct DelArr
{
	void operator()(T* ptr)
	{
		fclose(ptr);
	}
};
int main()
{
	std::shared_ptr<FILE> fp1(fopen("main.cpp", "w"), DelArr<FILE>());
	return 0;
}

将定制的删除器传给智能指针对象即可,就不会执行delete进行析构了。 除了使用仿函数,使用lambda更方便,函数指针就不推荐了,使用太麻烦。

cpp 复制代码
int main()
{
	std::shared_ptr<FILE> fp1(fopen("main.cpp", "w"), [](FILE* ptr) {
		fclose(ptr);
		});
	return 0;
}
相关推荐
捕鲸叉3 小时前
怎样在软件设计中选择使用GOF设计模式
c++·设计模式
捕鲸叉3 小时前
C++设计模式和编程框架两种设计元素的比较与相互关系
开发语言·c++·设计模式
未知陨落4 小时前
数据结构——二叉搜索树
开发语言·数据结构·c++·二叉搜索树
一丝晨光4 小时前
gcc 1.c和g++ 1.c编译阶段有什么区别?如何知道g++编译默认会定义_GNU_SOURCE?
c语言·开发语言·c++·gnu·clang·gcc·g++
汉克老师5 小时前
GESP4级考试语法知识(贪心算法(四))
开发语言·c++·算法·贪心算法·图论·1024程序员节
姆路6 小时前
QT中使用图表之QChart绘制动态折线图
c++·qt
秋说7 小时前
【数据结构 | C++】整型关键字的平方探测法散列
数据结构·c++·算法
槿花Hibiscus9 小时前
C++基础:Pimpl设计模式的实现
c++·设计模式
黑不拉几的小白兔10 小时前
PTA部分题目C++重练
开发语言·c++·算法
写bug的小屁孩10 小时前
websocket身份验证
开发语言·网络·c++·qt·websocket·网络协议·qt6.3