RAII和智能指针

发布时间:2024年01月11日

RAII

Resource Acquisition Is Initialization,它是一种C++编程技术,它通过在对象的构造函数中获取资源,在对象的析构函数中释放资源,从而确保资源的正确获取和释放。

一般情况下,C++申请资源后都需要手动释放资源,一旦忘记资源的释放就会造成内存泄漏,为了解决内存泄漏问题,C++引入了RAII机制。

RAII 的用法是在构造函数中获取资源,在析构函数中释放资源,并使用栈上的对象或者智能指针来管理堆上的资源。C++ 标准库提供了许多 RAII 类型,如 std::fstream、std::unique_ptr、std::lock_guard 等,我们可以直接使用它们或者参考它们来实现自己的 RAII 类型。

遵循RAII设计原则的好处有以下几点:

  • 简化资源管理:RAII可以将资源的获取和释放封装在对象中,从而简化了资源管理的代码。使用RAII可以避免手动管理资源的复杂性和错误,减少了代码的出错率。
  • 避免资源泄漏:RAII可以确保资源在对象生命周期结束时被正确释放,从而避免了资源泄漏的问题。如果不使用RAII,可能会因为忘记释放资源或者异常情况导致资源泄漏。
  • 提高代码可读性:RAII可以将资源的获取和释放封装在对象中,从而使代码更加清晰和易于理解。使用RAII可以使代码更加模块化,从而提高代码的可读性和可维护性。
  • 支持异常安全:RAII可以确保资源在异常情况下也能够被正确释放,从而支持异常安全。如果不使用RAII,可能会因为异常情况导致资源没有被正确释放,从而导致程序出现错误。

智能指针

智能指针的特点:

  • 不用显式地写出析构函数。
  • 资源在智能指针的生命周期中始终有效。
  • 可以像指针一样的使用。
  • 最重要的是:具有RAII特性。

c++ 98提供的auto_prt

std::auto_ptr?的基本用法如下代码所示:

#include <memory>

int main()
{
    //初始化方式1
    std::auto_ptr<int> sp1(new int(8));
    //初始化方式2
    std::auto_ptr<int> sp2;
    sp2.reset(new int(8));
    return 0;
}
// sp 是 smart pointer(智能指针)的简写。

智能指针对象?sp1?和?sp2?均持有一个在堆上分配 int 对象,其值均是 8,这两块堆内存均可以在?sp1?和?sp2?释放时得到释放。这是?std::auto_ptr?的基本用法。

问题

std::auto_ptr?真正让人容易误用的地方是其不常用的复制语义,即当复制一个?std::auto_ptr?对象时(拷贝复制或 operator = 复制),原对象所持有的堆内存对象也会转移给复制出来的对象。示例代码如下:

#include <iostream>
#include <memory>

int main()
{
    //测试拷贝构造
    std::auto_ptr<int> sp1(new int(8));
    std::auto_ptr<int> sp2(sp1);
    if (sp1.get() != NULL)
    {
        std::cout << "sp1 is not empty." << std::endl;
    }
    else
    {
        std::cout << "sp1 is empty." << std::endl;
    }

    if (sp2.get() != NULL)
    {
        std::cout << "sp2 is not empty." << std::endl;
    }
    else
    {
        std::cout << "sp2 is empty." << std::endl;
    }

    //测试赋值构造
    std::auto_ptr<int> sp3(new int(8));
    std::auto_ptr<int> sp4;
    sp4 = sp3;
    if (sp3.get() != NULL)
    {
        std::cout << "sp3 is not empty." << std::endl;
    }
    else
    {
        std::cout << "sp3 is empty." << std::endl;
    }

    if (sp4.get() != NULL)
    {
        std::cout << "sp4 is not empty." << std::endl;
    }
    else
    {
        std::cout << "sp4 is empty." << std::endl;
    }

    return 0;
}
// 结果如下:
sp1 is empty.
sp2 is not empty.
sp3 is empty.
sp4 is not empty.

上述代码中分别利用拷贝构造(sp1 => sp2)和 赋值构造(sp3 => sp4)来创建新的 std::auto_ptr 对象,因此 sp1 持有的堆对象被转移给 sp2,sp3 持有的堆对象被转移给 sp4。

导致的问题是:管理权的转移,比如将sp1拷贝给sp2,也就是将sp1对于资源的管理权转移给了sp2,即将sp1的指针置空,带来的问题是:后面的代码不能使用sp1对象。由于这个问题的存在auto_ptr指针被很多人诟病,并且许多公司明确要求不能使用auto_ptr指针,因为管理权转移导致了原指针不能使用,相比后续的智能指针,auto_ptr确实是个失败的设计。

std::unique_ptr

之后c++11引入了三种指针:std::unique_ptrstd::shared_ptr?和?std::weak_ptr。

std::unique_ptr解决智能指针拷贝问题的方案是:禁止拷贝
std::unique_ptr?对其持有的堆内存具有唯一拥有权,也就是说引用计数永远是 1。std::unique_ptr?对象销毁时会释放其持有的堆内存。可以使用以下方式初始化一个std::unique_ptr?对象:

//初始化方式1
std::unique_ptr<int> sp1(new int(123));

//初始化方式2
std::unique_ptr<int> sp2;
sp2.reset(new int(123));

//初始化方式3
std::unique_ptr<int> sp3 = std::make_unique<int>(123);

应该尽量使用初始化方式 3 的方式去创建一个 std::unique_ptr 而不是方式 1 和 2,因为形式 3 更安全,原因 Scott Meyers 在其《Effective Modern C++》中已经解释过了

令很多人对 C++11 规范不满的地方是,C++11 新增了 std::make_shared() 方法创建一个 std::shared_ptr 对象,却没有提供相应的 std::make_unique() 方法创建一个 std::unique_ptr 对象,这个方法直到 C++14 才被添加进来。当然,在 C++11 中你很容易实现出这样一个方法来:?

template<typename T, typename... Ts>
std::unique_ptr<T> make_unique(Ts&& ...params)
{
    return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
}

鉴于?std::auto_ptr?的前车之鉴,std::unique_ptr?禁止复制语义,为了达到这个效果,std::unique_ptr?类的拷贝构造函数和赋值运算符(operator =)被标记为?delete。

template <class T>
class unique_ptr
{
public:
	unique_ptr(T* ptr)
		:_ptr(ptr)
	{}
	unique_ptr(const unique_ptr<T>& p) = delete;
	unique_ptr<T>& operator=(const unique_ptr<T>& p) = delete;

	~unique_ptr()
	{
		cout << "delete:" << _ptr << endl;
		delete _ptr;
		_ptr = nullptr;
	}


	T& operator*() { return *_ptr; }
	T* operator->() { return _ptr; }

private:
	T* _ptr;
};

在拷贝构造函数和赋值重载函数后加上delete,表示不能调用该函数,并且不能定义该函数。unique用这种简单粗暴的方式解决智能指针的拷贝问题,带来的缺陷是:unique独自占有资源,即不能使用拷贝构造和赋值,每一个unique都指向不同的资源。

其它

禁止复制语义也存在特例,即可以通过一个函数返回一个 std::unique_ptr:

#include <memory>

std::unique_ptr<int> func(int val)
{
    std::unique_ptr<int> up(new int(val));
    return up;
}

int main()
{
    std::unique_ptr<int> sp1 = func(123);

    return 0;
}

上述代码从 func 函数中得到一个?std::unique_ptr?对象,然后返回给 sp1。

既然?std::unique_ptr?不能复制,那么如何将一个?std::unique_ptr?对象持有的堆内存转移给另外一个呢?答案是使用移动构造,示例代码如下:

#include <memory>

int main()
{
    std::unique_ptr<int> sp1(std::make_unique<int>(123));

    std::unique_ptr<int> sp2(std::move(sp1));

    std::unique_ptr<int> sp3;
    sp3 = std::move(sp2);

    return 0;
}

以上代码利用 std::move 将 sp1 持有的堆内存(值为 123)转移给 sp2,再把 sp2 转移给 sp3。最后,sp1 和 sp2 不再持有堆内存的引用,变成一个空的智能指针对象。并不是所有的对象的 std::move 操作都有意义,只有实现了移动构造函数(Move Constructor)或移动赋值运算符(operator =)的类才行,而?std::unique_ptr?正好实现了这二者,以下是实现伪码:

template<typename T, typename Deletor>
class unique_ptr
{
    //其他函数省略...
public:
    unique_ptr(unique_ptr&& rhs)
    {
        this->m_pT = rhs.m_pT;
        //源对象释放
        rhs.m_pT = nullptr;
    }

    unique_ptr& operator=(unique_ptr&& rhs)
    {
        this->m_pT = rhs.m_pT;
        //源对象释放
        rhs.m_pT = nullptr;
        return *this;
    }

private:
    T*    m_pT;
};

std::unique_ptr?不仅可以持有一个堆对象,也可以持有一组堆对象,示例如下:

#include <iostream>
#include <memory>

int main()
{
    //创建10个int类型的堆对象
    //形式1
    std::unique_ptr<int[]> sp1(new int[10]);

    //形式2
    std::unique_ptr<int[]> sp2;
    sp2.reset(new int[10]);
    //形式3
    std::unique_ptr<int[]> sp3(std::make_unique<int[]>(10));

    for (int i = 0; i < 10; ++i)
    {
        sp1[i] = i;
        sp2[i] = i;
        sp3[i] = i;
    }

    for (int i = 0; i < 10; ++i)
    {
        std::cout << sp1[i] << ", " << sp2[i] << ", " << sp3[i] << std::endl;
    }

    return 0;
}
// 结果如下:
0, 0, 0
1, 1, 1
2, 2, 2
3, 3, 3
4, 4, 4
5, 5, 5
6, 6, 6
7, 7, 7
8, 8, 8
9, 9, 9

std::shared_ptr?和?std::weak_ptr?也可以持有一组堆对象,用法与?std::unique_ptr?相同。

std::shared_ptr

shared_ptr允许拷贝智能指针,其采用了计数的方式进行拷贝,只有一个指针指向资源时,计数为1,两个指针指向,计数为2,以此类推。对象析构时,只要将计数减一,如果减一后的计数为0,说明该对象是指向资源的最后一个对象,需要完成资源的释放。

注意:shared_ptr中又存储了一个指向_count的指针,在拷贝构造后,两对象的_ptr指向同一块空间,需要将_count的值+1,达到计数增加的效果。
?

template <class T>
class shared_ptr
{
public:
	shared_ptr(T* ptr)
		: _ptr(ptr)
		, _pCount(new int(1))
	{}
	shared_ptr(const shared_ptr<T>& p)
	{
		_ptr = p._ptr;
		_pCount = p._pCount;
		(*_pCount)++;
	}
	void Release()	
	{
		if (--(*_pCount) == 0) // 当计数为0,需要释放pCount
		{
			if (_ptr) // 如果_ptr为空,只要释放pCount
			{
				delete _ptr;
				_ptr = nullptr;
			}
			cout << "~shared_ptr()" << endl;
			delete _pCount;
			_pCount = nullptr;
		}
	}

	shared_ptr<T>& operator=(const shared_ptr<T>& p)
	{
		// 指向资源不同时,使两指针指向同一资源,并且计数增加
		if (_ptr != p._ptr) // 当指向资源相同时,没有必要进行赋值
		{
			Release(); // 先释放该指针之前指向的空间

			_ptr = p._ptr;
			_pCount = p._pCount;
			* _pCount++;
		}
	}

	~shared_ptr()
	{
		Release();
	}

	T& operator*() { return *_ptr; }
	T* operator->() { return _ptr; }

private:
	T* _ptr;
	int* _pCount;
};

下面是一个初始化?std::shared_ptr?的示例:

//初始化方式1
std::shared_ptr<int> sp1(new int(123));

//初始化方式2
std::shared_ptr<int> sp2;
sp2.reset(new int(123));

//初始化方式3
std::shared_ptr<int> sp3;
sp3 = std::make_shared<int>(123);

例子:

#include <iostream>
#include <memory>

class A
{
public:
    A()
    {
        std::cout << "A constructor" << std::endl;
    }

    ~A()
    {
        std::cout << "A destructor" << std::endl;
    }
};

int main()
{
    {
        //初始化方式1
        std::shared_ptr<A> sp1(new A());

        std::cout << "use count: " << sp1.use_count() << std::endl;

        //初始化方式2
        std::shared_ptr<A> sp2(sp1);
        std::cout << "use count: " << sp1.use_count() << std::endl;

        sp2.reset();
        std::cout << "use count: " << sp1.use_count() << std::endl;

        {
            std::shared_ptr<A> sp3 = sp1;
            std::cout << "use count: " << sp1.use_count() << std::endl;
        }

        std::cout << "use count: " << sp1.use_count() << std::endl;
    }

    return 0;
}
// 结果如下:
A constructor
use count: 1
use count: 2
use count: 1
use count: 2
use count: 1
A destructor

执行过程如下:

  • 上述代码?22?行 sp1 构造时,同时触发对象 A 的构造,因此 A 的构造函数会执行;
  • 此时只有一个 sp1 对象引用?22?行 new 出来的 A 对象(为了叙述方便,下文统一称之为资源对象 A),因此代码?24?行打印出来的引用计数值为?1
  • 代码?27?行,利用 sp1 拷贝一份 sp2,导致代码?28?行打印出来的引用计数为?2
  • 代码?30?行调用 sp2 的 reset() 方法,sp2 释放对资源对象 A 的引用,因此代码?31?行打印的引用计数值再次变为?1
  • 代码?34?行 利用 sp1 再次 创建 sp3,因此代码?35?行打印的引用计数变为?2
  • 程序执行到?36?行以后,sp3 出了其作用域被析构,资源 A 的引用计数递减 1,因此 代码?38?行打印的引用计数为?1
  • 程序执行到?39?行以后,sp1 出了其作用域被析构,在其析构时递减资源 A 的引用计数至?0,并析构资源 A 对象,因此类 A 的析构函数被调用。

问题

虽然shared_ptr比其他智能指针完美(因为支持了拷贝构造和赋值),但其存在一个问题:循环引用。假设现在有Node这样一个类,有三个成员,两个分别指向前后节点的智能指针,一个保存数据的成员变量_val。当申请了Node类型的资源后,用shared_ptr托管,此时的智能指针可以正常的释放资源。

struct Node
{
	Node()
		: _prev(nullptr)
		, _next(nullptr)
		, _val(0)
	{}
	myPtr::shared_ptr<Node> _prev;
	myPtr::shared_ptr<Node> _next;
	int _val;
};

int main()
{
	myPtr::shared_ptr<Node> p1(new Node);
	myPtr::shared_ptr<Node> p2(new Node);

	// p1->_next = p2;
	// p2->_prev = p1;
	return 0;
}

当两智能指针没有产生连接时,p1和p2的计数都为1,释放资源正常。

但是当p1指针的_next指向p2,p2的_prev指向p1后,资源就不能正常释放,产生了内存泄漏。

当它们产生连接后,p1的_next也是一个智能指针,将_next指向p2,相当于shared_ptr的一次赋值,此时的p2计数为2,p2的_prev指向p1后,p1的计数也为2。当main函数指向完,p1和p2的生命周期结束,自动调用其析构函数,但其析构函数是根据计数决定是否析构,两指针的计数都为2,析构后只是将计数减小为1,并没有释放资源。

此时要释放p1的资源,就要释放p2的_prev,_prev作为Node类的成员,只有Node类释放时才会被释放,所以现在要释放p2,而要释放p2就要释放p1的_next,要释放_next就要释放p1,这就回到了开头的问题。对于循环引用的问题,需要使用weak_ptr来解决。

std::weak_ptr

weak_ptr是解决shared_ptr循环引用问题的关键,用shared_ptr作为Node的前后指针类型,使得Node的前后指针也参与了资源的管理,weak_ptr作为弱指针,即不参与资源的管理,只是用来指向资源,类似于容器里的迭代器。

template <class T>
class weak_ptr
{
public:
	weak_ptr(T* ptr)
		:_ptr(ptr)
	{}
	weak_ptr(weak_ptr<T>& p)
	{
		_ptr = p._ptr;
	}
	weak_ptr<T>& operator=(weak_ptr<T>& p)
	{
		_ptr = p._ptr;
		return *this;
	}
	weak_ptr<T>& operator=(shared_ptr<T>& p)
	{
		_ptr = p.get();
		return *this;
	}

	T& operator*() { return *_ptr; }
	T* operator->() { return _ptr; }

private:
	T* _ptr;
};

// Node的前后指针类型改为weak_ptr	
struct Node
{
	Node()
		: _prev(nullptr)
		, _next(nullptr)
		, _val(0)
	{}
	myPtr::weak_ptr<Node> _prev;
	myPtr::weak_ptr<Node> _next;
	int _val;
};

std::weak_ptr?可以从一个?std::shared_ptr?或另一个?std::weak_ptr?对象构造,std::shared_ptr?可以直接赋值给?std::weak_ptr?,也可以通过?std::weak_ptr?的?lock()?函数来获得?std::shared_ptr。它的构造和析构不会引起引用计数的增加或减少。std::weak_ptr?可用来解决?std::shared_ptr?相互引用时的死锁问题(即两个std::shared_ptr?相互引用,那么这两个指针的引用计数永远不可能下降为 0, 资源永远不会释放)。

#include <iostream>
#include <memory>

int main()
{
    //创建一个std::shared_ptr对象
    std::shared_ptr<int> sp1(new int(123));
    std::cout << "use count: " << sp1.use_count() << std::endl;

    //通过构造函数得到一个std::weak_ptr对象
    std::weak_ptr<int> sp2(sp1);
    std::cout << "use count: " << sp1.use_count() << std::endl;

    //通过赋值运算符得到一个std::weak_ptr对象
    std::weak_ptr<int> sp3 = sp1;
    std::cout << "use count: " << sp1.use_count() << std::endl;

    //通过一个std::weak_ptr对象得到另外一个std::weak_ptr对象
    std::weak_ptr<int> sp4 = sp2;
    std::cout << "use count: " << sp1.use_count() << std::endl;

    return 0;
}
// 结果如下:
use count: 1
use count: 1
use count: 1
use count: 1

无论通过何种方式创建?std::weak_ptr?都不会增加资源的引用计数,因此每次输出引用计数的值都是 1。

既然,std::weak_ptr?不管理对象的生命周期,那么其引用的对象可能在某个时刻被销毁了,如何得知呢?std::weak_ptr?提供了一个?expired()?方法来做这一项检测,返回 true,说明其引用的资源已经不存在了;返回 false,说明该资源仍然存在,这个时候可以使用?std::weak_ptr?的?lock()?方法得到一个?std::shared_ptr?对象然后继续操作资源。

参考:

https://github.com/balloonwj/CppGuide/blob/master/articles/C%2B%2B%E5%BF%85%E7%9F%A5%E5%BF%85%E4%BC%9A%E7%9A%84%E7%9F%A5%E8%AF%86%E7%82%B9/%E8%AF%A6%E8%A7%A3C%2B%2B11%E4%B8%AD%E7%9A%84%E6%99%BA%E8%83%BD%E6%8C%87%E9%92%88.md

C++的RAII思想以及在智能指针上的应用-CSDN博客

文章来源:https://blog.csdn.net/qq_45443879/article/details/135534180
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。