从C到C++ | 智能指针

目录

RAII思想 

智能指针的原理

C++中的智能指针

std::auto_ptr

auto_ptr的主要实现方式:

std::unique_ptr

unique_ptr的主要实现方式:

std::shared_ptr

shared_ptr的原理:

shared_ptr的主要实现方式:

这里需要注意的是,引用计数需要放在堆区。

std::shared_ptr的线程安全问题

std::shared_ptr的定制删除器

删除器部分的主要实现方式:

附加说明: 

shared_ptr循环引用问题

循环引用的说明:

循环引用导致资源未被释放的原因:

std::weak_ptr


RAII思想 

在谈智能指针之前,先引入一个思想:RAII

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

什么意思?就是我们可以利用对象生命周期来释放程序申请资源,以下面的的代码为例:

// 以下是模拟实现一个简单的SmartPtr类
template<class T>
class SmartPtr
{
public:
    SmartPtr(T* ptr) :_ptr(ptr)
    {}
    ~SmartPtr()
    {
        delete _ptr;
    }
    T& operator*()
    {
        return *_ptr;
    }
    T* operator->()
    {
        return _ptr;
    }
private:
    T* _prt = nullptr;
};

以上是一个基于RAII思想实现的简单的SmartPtr类。

  • 在构造SmartPtr对象时,SmartPtr将传入的需要被管理的内存空间保存起来。
  • 在析构SmartPtr对象时,SmartPtr的析构函数中会自动将管理的内存空间进行释放。
  • 此外,为了让SmartPtr对象能够像原生指针一样使用,还需要对*->运算符进行重载

我们将需要管理的资源交付给SmartPtr对象,只要SmartPtr对象的生命周期结束就会调用其对应的析构函数,进而完成内存资源的释放。

为什么需要这个基于RAII思想实现的简单的SmartPtr类?程序员在写代码的时候手动delete不行?

因为在部分情况下,程序并不是按我们“以为”的情况下顺利运行的,因为疏忽或错误,造成程序未能释放已经不再使用的内存的情况。

就比如下面的情况:

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;
}

 执行上述代码时,如果用户输入的除数为0,那么div函数中就会抛出异常,这时程序的执行流会直接跳转到主函数中的catch块中执行,最终导致func函数中申请的内存资源没有得到释放,造成内存泄露。

智能指针的原理

在上述代码中,如果我们将申请到的资源交给SmartPtr管理,如果用户输入的除数为0,那么div函数中就会抛出异常,这时程序的执行流会直接跳转到主函数中的catch块中执行,而我们的SmartPtr对象因生命周期结束,因此会自动帮我们释放申请到的资源,成功地防止住了内存泄漏。

但是,实现智能指针时需要考虑以下三个方面的问题:

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

上述代码的SmartPtr类存在一个致命问题,就是类的对象拷贝的问题。如果用一个SmartPtr对象来拷贝构造另一个SmartPtr对象,或是将一个SmartPtr对象赋值给另一个SmartPtr对象,都会导致程序崩溃。

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析构时就会导致这块空间被释放两次,因此造成程序崩溃。

C++中的智能指针

std::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;
}

管理权转移是auto_ptr的亮点,但是 一个对象的管理权转移后也就意味着,该对象不能再用对原来管理的资源进行访问了,否则程序就会崩溃,因此很多公司也都明确规定了禁止使用auto_ptr。

auto_ptr的主要实现方式:

  1. 在构造函数中获取资源,在析构函数中释放资源,利用对象的生命周期来控制资源。
  2. 对*和->运算符进行重载,使auto_ptr对象具有指针一样的行为。
  3. 在拷贝构造函数中,用传入对象管理的资源来构造当前对象,并将传入对象管理资源的指针置空。
  4. 在拷贝赋值函数中,先将当前对象管理的资源释放,然后再接管传入对象管理的资源,最后将传入对象管理资源的指针置空。
template<class T>
class auto_prt
{
public:
    auto_ptr(T* ptr = nullptr) :_ptr(ptr)
    {}
    auto_ptr(auto_ptr<T>& ap) :_ptr(ap._ptr)
    {
        ap._ptr = nullptr;
    }
    ~auto_ptr()
    {
        if (_ptr != nullptr)
        {
            delete _ptr;
            _ptr = nullptr;  // 由于管理权转让
        }
    }
    auto_ptr& operator=(auto_ptr<T>& ap)
    {
        if (this != &ap)
        {
            delete _ptr;
            _ptr = ap._ptr;
            ap._ptr = nullptr;
        }
        return *this;
    }
    //可以像指针一样使用
	T& operator*()
	{
		return *_ptr;
	}
	T* operator->()
	{
		return _ptr;
	}
private:

    T* _ptr;
};

由于auto_ptr的设计缺陷,导致很多公司也都明确规定了禁止使用auto_ptr,因此在C++11中,引入了新的智能指针。

std::unique_ptr

unique_ptr是C++11中引入的智能指针,unique_ptr通过防拷贝的方式解决智能指针的拷贝问题,也就是简单粗暴的防止对智能指针对象进行拷贝,这样也能保证资源不会被多次释放。

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

unique_ptr的主要实现方式:

  1. 在构造函数中获取资源,在析构函数中释放资源,利用对象的生命周期来控制资源。
  2. 对*和->运算符进行重载,使unique_ptr对象具有指针一样的行为。
  3. 用C++98的方式将拷贝构造函数和拷贝赋值函数声明为私有,或者用C++11的方式在这两个函数后面加上=delete,防止外部调用。
template<class T>
class unique_ptr
{
	unique_ptr(T* ptr = nullptr) :_ptr(ptr)
	{}
	~unique_ptr()
	{
		if (_ptr != nullptr)
		{
			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;
};

但防拷贝其实也不是一个很好的办法,因为总有一些场景需要进行拷贝。因此,在C++11中引入了shared_ptr

std::shared_ptr

shared_ptr是C++11中引入的智能指针,shared_ptr通过引用计数的方式解决智能指针的拷贝问题。

shared_ptr的原理:

  1. 每一个被管理的资源都有一个对应的引用计数,通过这个引用计数记录着当前有多少个对象在管理着这块资源。
  2. 当新增一个对象管理这块资源时则将该资源对应的引用计数进行++,当一个对象不再管理这块资源或该对象被析构时则将该资源对应的引用计数进行--。
  3. 当一个资源的引用计数减为0时说明已经没有对象在管理这块资源了,这时就可以将该资源进行释放了。
  4. 通过这种引用计数的方式就能支持多个对象一起管理某一个资源,也就是支持了智能指针的拷贝,并且只有当一个资源对应的引用计数减为0时才会释放资源,因此保证了同一个资源不会被释放多次。
int main()
{
	cl::shared_ptr<int> sp1(new int(1));
	cl::shared_ptr<int> sp2(sp1);
	*sp1 = 10;
	*sp2 = 20;
	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的主要实现方式:

  1. 在shared_ptr类中增加一个成员变量count,表示智能指针对象管理的资源对应的引用计数。
  2. 在构造函数中获取资源,并将该资源对应的引用计数设置为1,表示当前只有一个对象在管理这个资源。
  3. 在拷贝构造函数中,与传入对象一起管理它管理的资源,同时将该资源对应的引用计数++。
  4. 在拷贝赋值函数中,先将当前对象管理的资源对应的引用计数--(如果减为0则需要释放),然后再与传入对象一起管理它管理的资源,同时需要将该资源对应的引用计数++。
  5. 在析构函数中,将管理资源对应的引用计数--,如果减为0则需要将该资源释放。
  6. 对*和->运算符进行重载,使shared_ptr对象具有指针一样的行为。
template<class T>
class shared_prt
{
public:
    shared_ptr(T* ptr = nullptr) :_ptr(ptr), _pcount(new int(1))
    {}
    shared_ptr(shared_ptr<T> &sp) :_ptr(sp._ptr), _pcount(sp._pcount)
    {
        (*_pcount)++;
    }
    ~shared_ptr()
    {
        if (--(*_pcount) == 0)
        {
            if (_ptr != nullptr)
            {
                delete _ptr;
                _ptr = nullptr;
            }
            delete _pcount;
            _pcount = nullptr;
        }
    }
    shared_ptr& operator=(shared_ptr<T>& sp)
    {
        if (_ptr != sp._ptr)
        {
            if (--(*_pcount) == 0)
            {
                if (_ptr != nullptr)
                {
                    delete _ptr;
                    delete _pcount;
                }
                _ptr = sp._ptr;
                _pcount = sp._pcount;
                (*_pcount)++;
            }
        }
        return *this;
    }
    int use_count()
    {
        return *_pcount;
    }
    //可以像指针一样使用
	T& operator*()
	{
		return *_ptr;
	}
	T* operator->()
	{
		return _ptr;
	}
private:
    T* _ptr;
    int* _pcount;  // 引用计数
};

这里需要注意的是,引用计数需要放在堆区。

为什么?

  1. shared_ptr中的引用计数count不能单纯的定义成一个int类型的成员变量,因为这就意味着每个shared_ptr对象都有一个自己的count成员变量。
  2. shared_ptr中的引用计数count也不能定义成一个静态的成员变量,因为静态成员变量是所有类型对象共享的,这会导致管理相同资源的对象和管理不同资源的对象用到的都是同一个引用计数,如下图:

将shared_ptr中的引用计数count定义成一个指针,当一个资源第一次被管理时就在堆区开辟一块空间用于存储其对应的引用计数,如果有其他对象也想要管理这个资源,那么除了将这个资源给它之外,还需要把这个引用计数也给它。

这时管理同一个资源的多个对象访问到的就是同一个引用计数,而管理不同资源的对象访问到的就是不同的引用计数了,相当于将各个资源与其对应的引用计数进行了绑定。

 

需要注意的是,在实现shared_ptr的过程中,由于引用计数的内存空间也是在堆上开辟的,因此当一个资源对应的引用计数减为0时,除了需要将该资源释放,还需要将该资源对应的引用计数的内存空间进行释放。

std::shared_ptr的线程安全问题

上述模拟实现的shared_ptr还存在线程安全的问题,由于管理同一个资源的多个对象的引用计数是共享的,因此多个线程可能会同时对同一个引用计数进行自增或自减操作,而自增和自减操作都不是原子操作,因此需要通过加锁来对引用计数进行保护,否则就会导致线程安全问题。

解决方式:

要解决引用计数的线程安全问题,本质就是要让对引用计数的自增和自减操作变成一个原子操作,因此可以对引用计数的操作进行加锁保护,也可以用原子类atomic对引用计数进行封装,这里以加锁为例。

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

std::shared_ptr的定制删除器

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

struct testObj
{
    testObj* _next;
};

int main()
{
    std::shared_ptr<testObj> sp1(new testObj[10]);
    std::shared_ptr<FLIE> sp2(fopen("main.cpp", "r"));
}

上述代码在析构时,会发生错误,因为我们实现的shared_ptr的析构函数,对于所有类型的对象执行的操作都是delete,如果我们用shared_ptr来管理new出来的10个struct testObj(这里得是自定义类型),或者是文件指针,就会发生报错。

template <class T, class D>
shared_ptr (T* p, D del);

参数说明:

  1. p:需要让智能指针管理的资源。
  2. 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<testObj> sp1(new testObj[10], DelArr<testObj>());
    // 以lambda的方式传入
	std::shared_ptr<FILE> sp2(fopen("main.cpp", "r"), [](FILE* ptr){
		cout << "fclose: " << ptr << endl;
		fclose(ptr);
	});

	return 0;
}

此时就能正常析构需要管理的对象了

删除器部分的主要实现方式:

//默认的删除器
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;         //管理的资源对应的删除器
};

附加说明: 

  1. 如果传入的删除器是一个仿函数,那么需要在构造shared_ptr对象时指明仿函数的类型。
  2. 如果传入的删除器是一个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<testObj, DelArr<testObj>> sp1(new testObj[10], DelArr<testObj>());

	//lambda示例1
	cl::shared_ptr<FILE, function<void(FILE*)>> sp2(fopen("main.cpp", "r"), [](FILE* ptr){
		cout << "fclose: " << ptr << endl;
		fclose(ptr);
	});

	//lambda示例2
	auto f = [](FILE* ptr){
		cout << "fclose: " << ptr << endl;
		fclose(ptr);
	};
	cl::shared_ptr<FILE, decltype(f)> sp3(fopen("test.cpp", "r"), f);

	return 0;
}

shared_ptr循环引用问题

以以下类为例

struct testObj
{
    testObj* _prev;
    testObj* _next;
};

上述类在下面的代码中是没有问题的 ,在程序结束的时候,node1和node2申请的资源都得到了正常的释放 

struct testObj
{
    testObj* _prev;
    testObj* _next;
};

int main()
{
	testObj* node1 = new testObj;
	testObj* node2 = new testObj;

	node1->_next = node2;
	node2->_prev = node1;
	//...
	delete node1;
	delete node2;
	return 0;
}

而在引入了shared_ptr后,却存在循环引用的问题

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

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

	return 0;
}

程序运行结束后两个结点都没有被释放,但如果去掉连接结点时的两句代码中的任意一句,那么这两个结点就都能够正确释放,根本原因就是因为这两句连接结点的代码导致了循环引用。

循环引用的说明:

当以new的方式申请到两个testObj结点并交给两个智能指针管理后,这两个资源对应的引用计数都被加到了1。

将这两个结点连接起来后,node1当中的next成员与node2一同管理node2,node2中的prev成员与node1一同管理node1,此时这两个资源对应的引用计数都被加到了2。 

当出了main函数的作用域后,node1和node2的生命周期也就结束了,因此这两个资源对应的引用计数最终都减到了1。

最终导致资源未被释放

循环引用导致资源未被释放的原因:

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

前面说了如果去掉连接结点时的两句代码中的任意一句,那么这两个结点就都能够正确释放。如果连接结点时只进行一个连接操作,那么当node1和node2的生命周期结束时,就会有一个资源对应的引用计数被减为0,此时这个资源就会被释放,这个释放后另一个资源的引用计数也会被减为0,最终两个资源就都被释放了,这就是为什么只进行一个连接操作时这两个结点就都能够正确释放的原因。

为了解决shared_ptr的循环引用问题,C++11引入了weak_ptr

std::weak_ptr

weak_ptr是C++11中引入的智能指针,weak_ptr不是用来管理资源的释放的,它主要是用来解决shared_ptr的循环引用问题的。

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

将testObj中的next和prev成员的类型换成weak_ptr就不会导致循环引用问题了,此时当node1和node2生命周期结束时两个资源对应的引用计数就都会被减为0,进而释放这两个结点的资源。

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

	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;
}

weak_ptr的主要实现方式如下:

  1. 提供一个无参的构造函数,比如刚才new testObj时就会调用weak_ptr的无参的构造函数。
  2. 支持用shared_ptr对象拷贝构造weak_ptr对象,构造时获取shared_ptr对象管理的资源。
  3. 支持用shared_ptr对象拷贝赋值给weak_ptr对象,赋值时获取shared_ptr对象管理的资源。
  4. 对*和->运算符进行重载,使weak_ptr对象具有指针一样的行为。
template<class T>
class weak_ptr
{
public:
	weak_ptr()
		:_ptr(nullptr)
	{}
    // 说明: shared_ptr提供一个get函数,用于获取其管理的资源。
	weak_ptr(const shared_ptr<T>& sp)
		:_ptr(sp.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; //管理的资源
};

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

福楠

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值