C++相关概念和易错语法(30)(异常、智能指针)

1.异常

在C语言这样的面向过程编程的语言来说,处理错误一般有两种方式:终止程序,如assert;返回错误码,比如打开文件失败,errno就会改变,perror就会打印错误码对应的错误信息

面向对象的语言中,异常是更常见的处理错误的方式,如连接服务器我们不会一次就成功,需要多尝试几次,不能一失败就终止程序。errno也过于局限,错误码的分配有限,不能自定义,同时错误码还必须及时手动处理,否则会被覆盖

(1)处理方式

先执行try内部语句,内部可能有throw抛异常,catch捕获异常并处理

我们可以看到,先执行try里面的语句,在fun()里遇到了throw语句抛出异常,抛出异常后终止后续所有代码的执行,之后catch捕获后进行处理。只有抛了异常才会走catch,如果不抛异常就不会走catch。抛异常是传值返回,返回的是拷贝的对象,返回右值会调用移动构造简化。

下面是一个更直观的例子,可以更好理解使用规则

(2)详细规则

在try语句内部(try中调用函数也算作在try代码块中,如上面的例子)抛出异常后,会直接跳转到catch语句,跳转规则是会沿着函数栈帧(调用函数的顺序)层层往回找,先看抛异常的语句在不在try代码块中,再看抛出的异常的类型有没有匹配当前catch,如果匹配了就会执行当前catch语句以及后面的语句,返回上层栈帧后如果本身还在try内部,那么不会进入任何catch语句,就算有匹配的,可以理解为抛出的异常用一次就销毁了。

#include <iostream>
using namespace std;

void fun2()
{
	throw "fun2()";
}

void fun1()
{
	try
	{
		fun2();
	}
	catch(const char* msg)
	{
		cout << "fun1()" << endl;
	}

	cout << "fun1()" << endl;
}

int main()
{
	try
	{
		fun1();
	}
	catch (const char* msg)
	{
		cout << "main()" << endl;
	}

	return 0;
}

结果是

我们可以看到fun2抛出异常后,会层层往上找,fun2是在fun1的try语句内,所以会被fun1的catch捕获,catch后的语句正常执行,再往上走发现fun1和fun2其实都是在main函数的try语句内,但是总共就抛出一次异常并被解决了,所以后面就不会执行catch语句

注意层层向上匹配时需要严格匹配,抛出的常量字符串不会被char*捕获,也不能用string捕获,如果抛出int异常自然也不会转为size_t。所以fun1中的catch语句及之后的代码不会执行,而会匹配main函数中的catch语句。抛异常后的下一句执行代码一定是向上找第一次完美匹配的catch语句的第一行代码

如果到了main函数还是找不到对应的catch,即抛出的异常没有被处理,就会直接终止程序,编译器认为异常没有被处理一定是存在问题的。

所以我们要保证所有的异常能得到处理,我们可以使用catch(...)兜底,catch(...)能在其它catch语句匹配不上时派上用场,至少不会让程序直接终止

有个小细节,即catch(...)只能放在最后作为兜底,不过也几乎没人这么做,这里提一下

(3)子类异常用父类捕获

先看看下面的代码,顺便复习复习继承和多态


#include <iostream>
using namespace std;

class B;

class A
{
public:
	virtual B& CreateMessage(int id = 1, const string& errmsg = "error") = 0
	{}

	int _id;
	string _errmsg;
};

class B : public A 
{
public:
	B& CreateMessage(int id, const string& errmsg)
	{
		_id = id;
		_errmsg = errmsg;
		return *this;
	}
};

int main()
{
	try
	{
		A* throwmsg = new B;

		throw throwmsg->CreateMessage();
	}
	catch (A& msg)
	{
		cout << msg._id << ":" << msg._errmsg << endl;
	}

	return 0;
}

结果是

首先使用父类指针指向子类空间构成多态,虚函数表存的CreateMessage()是B中的,当以A指针调用函数时匹配的是B的内容,但是多态中,都是以父类声明+子类定义调用函数,所以不需要传参,用纯虚函数的缺省值就可以了,实际走的代码还是B中的。

抛出的B可以被A&捕获,这其实也和赋值兼容转换结合起来了,同时也再次强调赋值兼容转换不是类型转换,因为catch是严格匹配的,不允许赋值兼容转换,因此这在逻辑上是合理的。

(4)异常规范

确定不会抛异常的在函数后面加noexcept,这样能很好规范异常的使用

写了noexcept后就算再抛异常也不会编译报错,但是noexcept会影响编译器逻辑,运行时不会捕获抛出的异常,就算看上去能匹配catch也会报错,这也相当于另一种规范

(5)标准库异常体系

当我们调用库中的函数出了问题时,就会抛出异常,我们可以用const exception& e接收异常,exception是一个类,我们可以用成员函数e.what()得到具体错误信息

下面是常见的用法

(6)C++异常缺点

异常使用频繁会导致代码执行位置乱跳,标准库的异常体系并不是太好用,一般来说都是自定义异常体系,同时noexcept也不是硬性规定。

但是最大的问题还是安全问题,即抛出异常后后续代码都会终止,这可能会导致已开辟的空间没有办法delete,出现内存泄漏,这极难控制,需要引入更复杂的解决办法,后续会讲到。

我为这个问题举个例子

如果arr1开辟失败要抛异常,如果arr2开辟失败也要开辟异常,arr3同理,而且还可能出现arr1和arr2都开辟好了,但arr3开辟失败,这个时候还要处理arr1和arr2的释放。我们发现要处理的异常极多,根本没有办法涵盖所有情况,所以我们用常规思维解决不了内存泄漏的问题。

2.智能指针(RAII思想)

RAII思想是实现智能指针的核心,它利用了C++局部对象自动销毁的特性(类的对象自动调用析构函数)来控制资源的生命周期,就能很好地防止内存泄漏,下面举个简单的例子

当调用函数时,创建了Ptr类的对象,资源获得立即初始化,这个对象掌管着堆区开辟的数组,当返回函数栈帧时,就会自动调用析构函数,把资源释放掉,因此我们堆区开辟的空间就不会泄露了

这本质上是借助对象的生命周期来控制程序资源,使用模板就可以管理任意类型的资源,智能指针就是按照这个思路来实现的,相当于在原本管理数据的int*外再包一层,这样就能解决异常乱跳导致的问题。

(1)unique_ptr

用法:unique_ptr<int> up(new int)

就和我们前面的使用一样,unique_ptr本质也是利用RAII思想实现的类,靠着类的生命周期来防止开辟的空间无法被delete,就算我们不知道unique_ptr具体实现,但是也能很快弄清具体的功能,直接用unique_ptr开辟空间也要安全得多

智能指针还能模仿原生指针的相关操作,如operator*和operator->(和迭代器的实现一样)

因此,堆区动态开辟空间应尽量交给智能指针管理。

智能指针难度在于拷贝,我们所需的拷贝是浅拷贝,而不是深拷贝,因为它是要模仿指针的拷贝,不同的智能指针可以指向同一块空间,但问题在于析构多次会导致越界访问。unique_ptr的特点就是禁止任何拷贝和赋值,一片空间只能交给一个unique_ptr管理。

拷贝构造和赋值重载都被delete掉,或是使用了private修饰,不能显式调用,也就实现了禁止拷贝赋值的操作,进而也就不会出现同一块空间用多个unique_ptr管理的情况。

在不需要同一块空间用多个unique_ptr管理时,可以多使用unique_ptr。

unique_ptr还需要处理自定义类型,如数组的开辟

第一种解决办法就是在模板参数类型后面加[ ]

第二种办法就是手动实现仿函数(删除器)

先看一下下面的代码,想一想是怎样使用的。


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

template<class T>
class DeleteArray
{
public:
	void operator()(T* t)
	{
		cout << "DeleteArray" << endl;
		delete[] t;
	}
};

class DeleteFile
{
public:
	void operator()(FILE* f)
	{
		cout << "DeleteFile" << endl;
		if (f)
			fclose(f);
	}
};

class A
{
public:
	~A()
	{
		cout << "~A()" << endl;
	}
	int _a = 10;
};

int main()
{

	unique_ptr<FILE, DeleteFile> up1(fopen("test.txt", "r"));
	unique_ptr<A, DeleteArray<A>> up2(new A[10]);

	return 0;
}

结果是

第二个模板参数删除器是一个仿函数,unique_ptr会将里面存放的指针以仿函数的形式传过去,以此来进行自定义处理。

unique_ptr删除器对象不能作为参数传递

通过删除器和类型 + [ ]的使用,我们能处理任何指针类型了,只不过这种使用形式要多记忆一下,容易混淆。

(2)shared_ptr基本使用

unique_ptr的功能几乎完美,唯独缺失了拷贝和赋值的操作。

shared_ptr支持拷贝,采用了引用计数,每当新增一个shared_ptr管理一块空间,就为它计数++,每析构一次就计数--,最后一个析构的释放空间。这类似于最后一个人关灯的操作。

我们可以看见,shared_ptr能够精准避免越界访问的情况。

下面看一看自定义类型如何处理,用法几乎一致,但有区别


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

template<class T>
class DeleteArray
{
public:
	void operator()(T* t)
	{
		cout << "DeleteArray" << endl;
		delete[] t;
	}
};

class DeleteFile
{
public:
	void operator()(FILE* f)
	{
		cout << "DeleteFile" << endl;
		if (f)
			fclose(f);
	}
};


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


int main()
{
	shared_ptr<A> sp1(new A[10], DeleteArray<A>());//开辟了一块新的空间
	shared_ptr<A> sp2(sp1);	
	
	shared_ptr<FILE> sp3(fopen("test.txt", "r"), DeleteFile());//开辟了一块新的空间
	shared_ptr<FILE> sp4(sp3);

	shared_ptr<A[]> sp5(new A[5]);//开辟了一块新的空间


	return 0;
}

结果是

shared_ptr删除器对象只能作为函数参数传递

(3)shared_ptr模拟实现

shared_ptr很重要,下面通过其具体实现来加深印象,并且找出shared_ptr的漏洞

这是模拟实现shared_ptr的成员变量,_ptr用于存储开辟的空间,_del用于接收删除器对象,_pcount是用于计数。

在这个模板类中,构造函数的第一个参数用于接收开辟空间的指针对象,如new int返回的int*;第二个参数利用了包装器,用lambda表达式做缺省值(匿名函数对象拷贝出临时对象,被包装器接收),默认以delete来释放空间,我们也可以手动传匹配的函数形式的对象(如仿函数,函数指针,仿函数),它们都能被包装器接收,这也体现包装器的优势。

通过构造和拷贝构造我们就能知道,shared_ptr再调用构造时(开辟新空间)就开辟一个存计数的空间,如果后续调用拷贝构造就会++计数,同理调用析构会--,如果为计数为0就delete开辟的空间。

这里需要理解为什么要用这种方式计数,为什么不用static?

static变量的特点就是一个类就只有一份,这就意味着当我们用同一个类实例化出多个对象时,计数就完全不可控了。假设同一个类有2个对象,4个指针管理,其中3个指针指向其一对象,另一个指针指向另一个对象。但static的计数始终为4,static完全没办法区分这两个对象,所以不可用。

最后还剩下赋值重载,需要注意两点,第一点即瞻前顾后,第二点则是处理自己给自己赋值

下面是所有代码实现,应该很快就能理解了

	template<class T>
	class shared_ptr
	{
	public:
		shared_ptr(T* ptr = nullptr, const function<void(T*)>& del = [](T* t)
			{
				cout << "默认删除器" << endl;
				delete t;
				return;
			})
			:_ptr(ptr)
			, _del(del)
			, _pcount(new int(1))
		{}

		shared_ptr(const shared_ptr& sp)
		{
			(*(sp._pcount))++;
			_ptr = sp._ptr;
			_del = sp._del;
			_pcount = sp._pcount;
		}

		~shared_ptr()
		{
			release();
		}

		void release()
		{
			if (--*(_pcount) == 0)
			{
				_del(_ptr);
				delete _pcount;
			}
		}

		T& operator*() const
		{
			return *_ptr;
		}

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

		shared_ptr& operator=(const shared_ptr& sp)
		{
			if (_ptr == sp._ptr)
				return *this;

			release();

			_ptr = sp._ptr;
			_del = sp._del;
			_pcount = sp._pcount;
			(*_pcount)++;

			return *this;
		}

		size_t use_count() const
		{
			return *_pcount;
		}

		T* get() const
		{
			return _ptr;
		}

	private:
		T* _ptr;
		function<void(T*)> _del;
		int* _pcount;
	};

注意const修饰函数可以防止使用该函数时发生权限放大的情况

下面对拷贝操作进行讲解

一定要体会右值的处理,这样才能理解为什么不需要写移动构造。

移动构造的底层就是将有用的指针和无用的指针做交换,将无用的指针析构。如果实现移动构造当然也没问题,不实现是因为析构时有一个条件判断阻止了delete,总体上和值拷贝没任何区别。

(4)循环引用、weak_ptr

看一下下面这段代码,为什么会出现这种状况

class A类似于双向链表,A* _next和A* _prev指向后一个和前一个A。但是由于异常的处理麻烦,我们使用智能指针来包装指针,实现同样的功能并且能自动析构。像上述代码如果觉得难以理解的话,先将它们看作A*,再替换成智能指针,结合operator*和operator->仔细体会。

看懂代码后,我们就能明白sp1和sp2互指

我们可以很好理解sp2和sp1的计数都是2,当析构时,它们都只会计数--,不会delete。最后变成use_count() == 1,这个时候析构操作已经完成了,但空间没有释放,造成了内存泄漏。这就叫循环引用。

处理循环引用,我们需要使用另一种智能指针weak_ptr

下面是一个粗略的实现,帮我们简单看看weak_ptr的结构

weak_ptr不同于其它智能指针,它不支持直接管理资源,它配合解决shared_ptr的一个缺陷,即循环引用导致的泄漏。

weak_ptr不支持RAII,不支持管理资源,也不能用operator*和operator->访问。

在用法上,weak_ptr<int> wp(new int) 这种操作是不可行的(不支持RAII),但是weak_ptr可以用shared_ptr构造和赋值,而shared_ptr可以用weak_ptr构造

当在循环引用出现时使用weak_ptr避免时,使用sp1->_next = sp2就调用了weak_ptr<T>& operator=(const shared_ptr<T>& sp)这个赋值重载。

我们还可以借助make_shared<T>(new T)来让weak_ptr指向数据空间

wp只有在构造的那行才有用,过了之后shared_ptr就析构了(临时对象),这个时候wp就失效了,也叫悬空。这又如何处理?

weak_ptr中use_count()记录了管理的数据有多少次计数,当计数为0时就标记为已失效。expired()就是这个功能,为真时就表示失效

我们还可以在悬空前将数据进行转移,lock()就能实现这项功能

转移前后count计数会++(weak_ptr不会增加计数,转移到shared_ptr,shared_ptr会增加计数),只不过上面的代码是先++,后析构--,整体不变

下面的代码可以说明这一点

我们要把weak_ptr理解为一个单独存储数据的类,不会增加计数。存储的数据很完备,有_ptr、_pcount、_del,但是由于weak_ptr不会--计数,即不会析构,所以当原数据被释放后,就有可能出现悬空的情况。我们可以用expired()检查是否悬空,也可以在悬空前用lock()把数据转移出去。

(5)智能指针总结

C++98推出了auto_ptr(失败的设计,拷贝是管理权转移,拷贝后原来的自动指针为空,调用原来的指针会直接报错),在C++11又推出了unique_ptr、shared_ptr、weak_ptr,用于解决绝大多数内存泄露的场景。当不涉及拷贝时可用unique_ptr,涉及拷贝、传值返回用shared_ptr,weak_ptr用于解决循环引用。

(6)内存泄漏

智能指针要处理的就是内存泄漏问题,即占据着内存却不使用,内存不断被消耗,会导致最终程序被卡死。一般来说,短期快速出现的内存泄漏更容易被发现,而长期运行的慢速内存泄漏的程序影响很大,如服务器几乎不停服,就算每次内存泄漏一点,但时间一长,就会造成服务器崩溃。当今的windows、linux都有各自的内存泄漏检测工具。我们写代码时,如果管理好资源,就能防止内存泄漏。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值