【C++进阶笔记】智能指针(2)

【C++进阶笔记】智能指针(2)

前言:以下内容是看了施磊老师的C++高级课程后整理的笔记,其中一部分内容来自施磊老师的博客。原博客地址

1. 基础知识

智能指针本身是一个类模板,原理利用栈上的对象出作用域会自动析构这么一个特点,把资源释放的代码全部放在这个析构函数中执行,就达到了所谓的智能指针。——施磊

template<typename T>
class CSmartPtr
{
public:
	CSmartPtr(T *ptr = nullptr) :mptr(ptr) {}
	~CSmartPtr() { delete mptr; }
private:
	T *mptr;
};

int main()
{
	CSmartPtr<int> ptr(new int);
	return 0;
}

1)智能指针体现在把裸指针进行了一次面向对象的封装,在构造函数中初始化资源地址,在析构函数中负责释放资源
2)利用栈上的对象出作用域自动析构这个特点,在智能指针的析构函数中保证释放资源 ——施磊

上面的代码实现的是一个简单的智能指针,能够实现内存的自动释放。CSmartPtr<int> ptr(new int); 这行代码中出现了new,可能会有朋友会误认为智能指针是定义在堆(heap)上的。其实不是,new int只是开辟了一块空间供对像进行初始化,其对象本身是存在于栈(stack)中。

思考:能不能在堆上定义智能指针?
例如:CSmartPtr *p = new CSmartPtr(new int),我们可以看到出现了*p,这就说明p依旧是一个普通指针或者说裸指针,只不过是一个类指针,在p结束作用域时,依旧需要delete p 将其释放。指针指针初始化的是一个对象,而不是指针。

2. 不带引用计数的智能指针

2.1 auto_ptr

实例1:

int main()
{
	auto_ptr<int> p1(new int);
	auto_ptr<int> p2 = p1;
	*p1 = 10;
	return 0;
}

上面这段代码最终会报错。p1指向了堆上开辟的一块内存,然后用p1拷贝构造p2。我们看了auto_ptr 的源码后得知,auto_ptr 在做拷贝构造函数时,做的是浅拷贝,并且会将p1置空。

int main()
{
	vector<auto_ptr<int>> vec;
	vec.push_back(auto_ptr<int>(new int(10)));
	vec.push_back(auto_ptr<int>(new int(20)));
	vec.push_back(auto_ptr<int>(new int(30)));
	cout << *vec[0] << endl;
	vector<auto_ptr<int>> vec2 = vec;
	cout << *vec[0] << endl;
	return 0;
}

上面的代码将auto_ptr运用在容器中,第一句cout << *vec[0] << endl;可以正常打印出10,但是第二句cout << *vec[0] << endl;无法正确打印。这是由于vector<auto_ptr<int>> vec2 = vec;调用拷贝构造函数,使得vec1中的内容全部被置为空。

C++建议最好不要使用auto_ptr,除非应用场景非常简单。auto_ptr智能指针不带引用计数,那么它处理浅拷贝的问题,是直接把前面的auto_ptr都置为nullptr,只让最后一个auto_ptr持有资源。——施磊

2.2 unique_ptr

unique_ptr规定一个智能指针独占一块内存资源。当两个智能指针同时指向一块内存,编译报错。

unique_ptr<int> p3 (new int (1));   
unique_ptr<int> p4;                
p4 = p3;//此时会报错!! 

编译器认为p4=p3非法,避免了p3不再指向有效数据的问题。尝试复制p3时会编译期出错,而auto_ptr能通过编译期从而在运行期埋下出错的隐患。因此,unique_ptr比auto_ptr更安全。
实现原理:将拷贝构造函数和赋值拷贝构造函数申明为private或delete。
虽然无法使用拷贝构造函数和赋值函数,但是unique_ptr支持移动构造函数,通过std:move把一个对象指针变成右值之后可以移动给另一个unique_ptr。

unique_ptr<int> pInt(new int(5));  
// 转移所有权  
unique_ptr<int> pInt2 = std::move(pInt); //运行成功

3. 带引用计数的智能指针

3.1 shared_ptr

当允许多个智能指针指向同一个资源的时候,每一个智能指针都会给资源的引用计数加1,当一个智能指针析构时,同样会使资源的引用计数减1,这样最后一个智能指针把资源的引用计数从1减到0时,就说明该资源可以释放了

实现一个自己的shared_ptr:
主要框架

template <typename T>
class MySharedPtr {
public:
    MySharedPtr(T* p = nullptr);
    ~MySharedPtr();
    MySharedPtr(const MySharedPtr<T>& src);
    MySharedPtr<T>& operator=(const MySharedPtr<T>& src);
    // 重载*操作符,使得可以通过SmartPtr对象解引用来获取其管理的对象
    T& operator*(){ 
        return *m_ptr; 
    }
    // 重载->操作符,使得可以通过SmartPtr对象访问其管理的对象的成员
    T* operator->(){ 
        return m_ptr; 
    }
private:
    T* m_ptr;
    int* m_cnt;
};

为什么要将引用数定义为指针?
这是因为在智能指针类中,引用数需要在多个智能指针对象之间共享。当你将一个智能指针对象赋值给另一个智能指针对象时,它们会共享同一个引用计数。为了实现这种共享,智能指针将引用数定义为指针,这样就可以让不同的智能指针通过自己管理的引用数指针去获取同一个引用数变量。这样,当其中一个智能指针对象被销毁时,它会递减引用计数,而不会直接删除所管理的资源。

构造函数:构造对象,由于是第一个对象,因此引用计数为1。

template <typename T>
MySharedPtr<T>::MySharedPtr(T* p) : m_ptr(p), m_cnt(new int(1)) {
    cout << "调用构造函数" << endl;
}

拷贝构造函数:引用计数加1。

template <typename T>
MySharedPtr<T>::MySharedPtr(const MySharedPtr<T>& src) : m_ptr(src.m_ptr), m_cnt(src.m_cnt) {
    ++(*m_cnt);
    cout << "调用拷贝构造" << endl;
}

赋值构造函数:首先判断是否自我赋值,之后需要对左操作对象的引用计数减1,如果该引用计数变为0,那就释放左操作对象的内存,然后再进行赋值。至于为什么需要先将左操作对象的引用计数减1,再赋值呢?我个人认为:左操作对象,暂且称为左共享指针,在赋值前必然指向了于右共享指针不同的空间。赋值相当于覆盖左指针,因此需要判断左指针是否是最后一个指针,如果是,需要将其空间释放后再做赋值操作;如果不是,左共享指针的引用计数减1,就代表左共享指针不再指向原来的空间。

template <typename T>
MySharedPtr<T>& MySharedPtr<T>::operator=(const MySharedPtr<T>& src) {
    if (this == &src) {
        return *this;
    }
    --(*m_cnt);
    if (*m_ptr == 0) {
        delete m_ptr;
        m_ptr = nullptr;
        delete m_cnt;
        m_cnt = nullptr;
    }
    ++(*src.m_cnt);
    m_ptr = src.m_ptr;
    m_cnt = src.m_cnt;

    cout << "调用赋值构造" << endl;
}

析构函数:每当有一个智能指针离开作用域或者被释放,就调用析构函数,并且将引用计数减1,但并不释放该空间,因为仍然可能有别的指针指向该空间。只有当引用计数减到0时,说明当前指针是最后一个指针,至此才会释放内存空间。

template <typename T>
MySharedPtr<T>::~MySharedPtr() {
    --(*m_cnt);
    cout << *m_cnt << endl;
    if (*m_cnt == 0) {
        delete m_ptr;
        m_ptr = nullptr;
        delete m_cnt;
        m_cnt = nullptr;
        cout << "调用析构函数,现在已无指针指向" << endl;
    } else {
        cout << "调用析构函数,仍有指针指向" << endl;
    }
}

调用:

int main() {

    MySharedPtr<int> p1(new int(1));
    MySharedPtr<int> p2(p1); 
    MySharedPtr<int> p3(new int(2));  
 	p3 = p1;
    cout << *p3 << endl;
    return 0;
}

在这里插入图片描述
p3最后指向了和p1、p2相同的空间。可以看到再赋值操作前,p3进行了一次内存释放,因为p3是指向new int(2)这块内存的最后一个指针,所以赋值前将其释放。

3.2 weak_ptr

3.2.1 智能指针的交叉引用(循环引用)问题

#include <iostream>
#include <memory>
using namespace std;

class B; // 前置声明类B
class A
{
public:
	A() { cout << "A()" << endl; }
	~A() { cout << "~A()" << endl; }
	shared_ptr<B> _ptrb; // 指向B对象的智能指针
};
class B
{
public:
	B() { cout << "B()" << endl; }
	~B() { cout << "~B()" << endl; }
	shared_ptr<A> _ptra; // 指向A对象的智能指针
};
int main()
{
	shared_ptr<A> ptra(new A());// ptra指向A对象,A的引用计数为1
	shared_ptr<B> ptrb(new B());// ptrb指向B对象,B的引用计数为1
	ptra->_ptrb = ptrb;// A对象的成员变量_ptrb也指向B对象,B的引用计数为2
	ptrb->_ptra = ptra;// B对象的成员变量_ptra也指向A对象,A的引用计数为2

	cout << ptra.use_count() << endl; // 打印A的引用计数结果:2
	cout << ptrb.use_count() << endl; // 打印B的引用计数结果:2
	return 0;
}

上面这段代码的运行结果:
A()
B()
2
2
可以看到:出了main函数作用域后,并没有调用析构函数去释放内存。

出main函数作用域,ptra和ptrb两个局部对象析构,分别给A对象和B对象的引用计数从2减到1,达不到释放A和B的条件(释放的条件是A和B的引用计数为0),因此造成两个new出来的A和B对象无法释放,导致内存泄露,这个问题就是“强智能指针的交叉引用(循环引用)问题。

3.2.2 解决方法

注意强弱智能指针的一个重要应用规则:定义对象时,用强智能指针shared_ptr,在其它地方引用对象时,使用弱智能指针weak_ptr。weak_ptr不会改变资源的引用计数,只是一个观察者的角色

弱智能指针weak_ptr区别于shared_ptr之处在于:

  1. weak_ptr不会改变资源的引用计数,只是一个观察者的角色,通过观察shared_ptr来判定资源是否存在
  2. weak_ptr持有的引用计数,不是资源的引用计数,而是同一个资源的观察者的计数
  3. weak_ptr没有提供常用的指针操作,无法直接访问资源,需要先通过lock方法提升为shared_ptr强智能指针,才能访问资源
#include <iostream>
#include <memory>
using namespace std;

class B; // 前置声明类B
class A
{
public:
	A() { cout << "A()" << endl; }
	~A() { cout << "~A()" << endl; }
	weak_ptr<B> _ptrb; // 指向B对象的弱智能指针。引用对象时,用弱智能指针
};
class B
{
public:
	B() { cout << "B()" << endl; }
	~B() { cout << "~B()" << endl; }
	weak_ptr<A> _ptra; // 指向A对象的弱智能指针。引用对象时,用弱智能指针
};
int main()
{
    // 定义对象时,用强智能指针
	shared_ptr<A> ptra(new A());// ptra指向A对象,A的引用计数为1
	shared_ptr<B> ptrb(new B());// ptrb指向B对象,B的引用计数为1
	// A对象的成员变量_ptrb也指向B对象,B的引用计数为1,因为是弱智能指针,引用计数没有改变
	ptra->_ptrb = ptrb;
	// B对象的成员变量_ptra也指向A对象,A的引用计数为1,因为是弱智能指针,引用计数没有改变
	ptrb->_ptra = ptra;

	cout << ptra.use_count() << endl; // 打印结果:1
	cout << ptrb.use_count() << endl; // 打印结果:1

	/*
	出main函数作用域,ptra和ptrb两个局部对象析构,分别给A对象和
	B对象的引用计数从1减到0,达到释放A和B的条件,因此new出来的A和B对象
	被析构掉,解决了“强智能指针的交叉引用(循环引用)问题”
	*/
	return 0;
}

4. 多线程访问共享对象问题

一个线程安全问题:线程A和线程B访问一个共享的对象,如果线程A正在析构这个对象的时候,线程B又要调用该共享对象的成员方法,此时可能线程A已经把对象析构完了,线程B再去访问该对象,就会发生不可预期的错误。

#include <iostream>
#include <thread>
using namespace std;

class Test
{
public:
	// 构造Test对象,_ptr指向一块int堆内存,初始值是20
	Test() :_ptr(new int(20)) 
	{
		cout << "Test()" << endl;
	}
	// 析构Test对象,释放_ptr指向的堆内存
	~Test()
	{
		delete _ptr;
		_ptr = nullptr;
		cout << "~Test()" << endl;
	}
	// 该show会在另外一个线程中被执行
	void show()
	{
		cout << *_ptr << endl;
	}
private:
	int *volatile _ptr;
};
void threadProc(Test *p)
{
	// 睡眠两秒,此时main主线程已经把Test对象给delete析构掉了
	std::this_thread::sleep_for(std::chrono::seconds(2));
	/* 
	此时当前线程访问了main线程已经析构的共享对象,结果未知,隐含bug。
	此时通过p指针想访问Test对象,需要判断Test对象是否存活,如果Test对象
	存活,调用show方法没有问题;如果Test对象已经析构,调用show有问题!
	*/
	p->show();
}
int main()
{
	// 在堆上定义共享对象
	Test *p = new Test();
	// 使用C++11的线程类,开启一个新线程,并传入共享对象的地址p
	std::thread t1(threadProc, p);
	// 在main线程中析构Test共享对象
	delete p;
	// 等待子线程运行结束
	t1.join();
	return 0;
}

主线程delete p;将共享对象释放,但在子线程t1中依然调用了p,无法打印出*_ptr的值20。可以用shared_ptr和weak_ptr来解决多线程访问共享对象的线程安全问题。

#include <iostream>
#include <thread>
#include <memory>
using namespace std;

class Test
{
public:
	// 构造Test对象,_ptr指向一块int堆内存,初始值是20
	Test() :_ptr(new int(20)) 
	{
		cout << "Test()" << endl;
	}
	// 析构Test对象,释放_ptr指向的堆内存
	~Test()
	{
		delete _ptr;
		_ptr = nullptr;
		cout << "~Test()" << endl;
	}
	// 该show会在另外一个线程中被执行
	void show()
	{
		cout << *_ptr << endl;
	}
private:
	int *volatile _ptr;
};
void threadProc(weak_ptr<Test> pw) // 通过弱智能指针观察强智能指针
{
	// 睡眠两秒
	std::this_thread::sleep_for(std::chrono::seconds(2));
	/* 
	如果想访问对象的方法,先通过pw的lock方法进行提升操作,把weak_ptr提升
	为shared_ptr强智能指针,提升过程中,是通过检测它所观察的强智能指针保存
	的Test对象的引用计数,来判定Test对象是否存活,ps如果为nullptr,说明Test对象
	已经析构,不能再访问;如果ps!=nullptr,则可以正常访问Test对象的方法。
	*/
	shared_ptr<Test> ps = pw.lock();
	if (ps != nullptr)
	{
		ps->show();
	}
}
int main()
{
	// 在堆上定义共享对象
	shared_ptr<Test> p(new Test);
	// 使用C++11的线程,开启一个新线程,并传入共享对象的弱智能指针
	std::thread t1(threadProc, weak_ptr<Test>(p));
	// 在main线程中析构Test共享对象
	// 等待子线程运行结束
	t1.join();
	return 0;
}

运行上面的代码,show方法可以打印出20,因为main线程调用了t1.join()方法等待子线程结束,此时pw通过lock提升为ps成功。

5. 结语

C++11中主要有三个智能指针unique_ptr、shared_ptr和weak_ptr。前者是独占式指针,没有引用计数;后两者有引用计数,可在多线程问题中解决线程安全的问题,同时其引用计数为原子量,由此shared_ptr和weak_ptr本身是线程安全的。

  • 6
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值