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都有各自的内存泄漏检测工具。我们写代码时,如果管理好资源,就能防止内存泄漏。