一、异常处理
1.C语言
(1)终止程序
(2)返回错误码
2.C++
(1)C++的异常处理模式——抛异常
(2)try、catch结构的使用原则
(3)异常的重新抛出
(4)异常的安全和规范
二、两个异常体系
1.自定义异常体系
2.C++的标准异常体系
三、C++异常的优缺点
1.优点
2.缺点
当C语言程序遇到异常时,处理方式是:直接终止程序并返回对应错误码。
C语言程序遇到的异常有很多,比如对空指针解引用,除0错误等。当上述情况发生时,程序会直接终止,整个进程也会直接挂掉。
进程直接挂掉可能会使重要数据丢失,这对开发者而言是难以接受的。
再比如说打开文件,还有Linux中创建线程等系统调用接口,调用后会返回一个返回值,如果发生了错误会将对应的错误码返回并放入到全局的errno变量中。
出现错误后,程序员需要自己去获取错误码才能查找错误,非常不直观。
当一个函数内发生异常后,该函数就会将错误抛出,让该函数的直接或间接的调用者去处理这个错误。
要想实现抛异常C++新增了三个关键字:throw、try、catch。
代码示例:写一个函数用来执行两个数相除,当除0时抛异常。
#include
using namespace std;
int division(int a, int b)
{
if (b == 0)
throw "Divisor can not be zero.";//抛出一个字符串对象
else
return a / b;
}
void func()
{
int a = 4;
//int b = 2;
int b = 0;
cout << division(a, b) << endl;
}
int main()
{
try
{
func();
}
catch (const char* error_masage)//这个catch可接收const char*类型的对象
{
cout << error_masage << endl;
}
catch(...)//它可以接收任意类型异常对象
{
cout << "unknown error" << endl;
}
return 0;
}
第一,C++的抛异常是指用throw关键字抛出对象,抛出的对象类型可以是内置类型也可以是自定义类型。
第二,被抛出对象的类型会与catch小括号内的形参进行匹配,对象会被储存进对应的catch形参中然后再执行大括号内的代码。这里抛出的是一个字符串,就可以被字符串类型接收,打印error_masage.
第三,try和catch在同一个函数作用域内必定同时出现。在异常对象被接收之前都需要用try括起抛异常的那部分代码。
比如下面代码中,division函数抛出了一个字符串,就需要一个catch去接收该对象。
这个catch可能形参匹配直接接收该对象,也可能形参不匹配而不能接收对象,但必须有一个catch。如果不能接收,此时就需要第四条出场了。
第四,捕获catch是调用链中与抛出对象类型匹配且离抛出异常位置最近的那一个。
调用链是指函数栈帧建立的先后顺序,就比如下面代码中main函数优先建立栈帧,然后func函数建立栈帧,最后division函数建立栈帧,这样的顺序就叫调用链。
在下面代码中,division函数建立栈帧后由于除零错误会抛出一个字符串,该函数内的catch不能接收对象则执行流,就需要去找它的上一层函数栈帧也就是func中去寻找匹配的catch,但是在func中还是找不到,就还要接着到上一层也就是main函数栈帧中去寻找指定catch语句,此时异常对象被接收并处理。
如果到main函数内都找不到对应的catch,编译器会直接报错,程序不能正常运行。
正因为catch的匹配规则,所以一旦出现的异常对象没有在合适的位置匹配。那么执行流很可能就会到处乱跳,扰乱程序的正常运行。所以也可以引出第五条。
第五,catch(...)可以捕获任意一种类型的对象,但是你不能知晓这个对象的类型和内容。上面的代码也使用了这样的语句对异常对象进行接收。
第六,异常对象被接收的过程十分类似于函数的传值返回的过程。被抛出的对象实际是一个经过原数据拷贝的临时对象,当异常对象被接收时这个临时对象也被销毁。
比如说我们使用已经实现的string类,将抛出的类型改为string,会发现它执行了以下函数:
我们可以非常明显地看到有一个拷贝临时变量的拷贝构造函数,这也证明了临时对象的拷贝过程是确凿存在的。
由于临时对象具有常性,所以当抛出的对象是指针时一定注意在形参上加上const才能被接收。这也解释了上面的代码中为什么error_message的类型为什么是const char*而不是char*
查看下面代码:
int division(int a, int b)
{
if (b == 0)
throw "Divisor can not be zero.";
else
return a / b;
}
void func()
{
try
{
int a = 1;
int b = 0;
division(a, b);
}
catch (const char* error_message)
{
cout << error_message << endl;
throw;
}
}
int main()
{
try
{
func();
}
catch (...)
{
cout << "unknown error";
}
return 0;
}
division抛出异常后,func栈帧内可捕获该异常,mian栈帧中也可以捕获该异常,但是该异常已经优先被func捕捉了,相当于对该异常的处理在gunc中完成了。
而在我们对异常进行一部分操作时,我们更愿意让所有异常在main函数中进行统一处理,比如对异常进行记录日志这样的操作,此时需要重新抛出异常。
我们对代码做出一些改动:
int division(int a, int b)
{
if (b == 0)
throw "Divisor can not be zero.";
else
return a / b;
}
void func()
{
try
{
int a = 1;
int b = 0;
division(a, b);
}
catch (...)
{
cout << "捕获异常" << endl;
throw;
}
}
int main()
{
try
{
func();
}
catch (int e)
{
cout << e;
}
catch (const char* e)
{
cout << e;
}
catch (...)
{
cout << "unknown error";
}
return 0;
}
其中throw后什么也不加表示重新抛出当前捕获的异常。我们设置在func中的catch都不用可以去匹配异常类型,凡是异常都捕获,然后重新抛出,让main函数中的catch再去匹配并进行相应处理。
当然,如果你不想抛出原来的异常,你也可以抛出一个新的异常,只需要在throw后面加上你需要抛出的内容即可。
int division(int a, int b)
{
if (b == 0)
throw "Divisor can not be zero.";
else
return a / b;
}
void func()
{
try
{
int a = 1;
int b = 0;
division(a, b);
}
catch (...)
{
cout << "捕获异常" << endl;
throw 1;
}
}
int main()
{
try
{
func();
}
catch (int e)
{
cout << e;
}
catch (const char* e)
{
cout << e;
}
catch (...)
{
cout << "unknown error";
}
return 0;
}
接收字符串异常后,抛出一个错误码
异常的使用需要注意安全,有以下两个问题需要注意:
在C++中使用异常时经常会导致资源泄漏,如果在new和delete中抛出了异常,就很可能导致内存泄漏等等。
尤其是使用非常复杂函数的时候,比如说一个写程序的人使用了这种复杂的接口,但是由于写程序的人不知道这个接口会抛异常,或者是不知道接口抛什么异常。此时,程序编写者就无法处理抛出的异常,就很可能导致资源泄漏等问题。
所以,为了减少因为异常导致的资源泄漏等问题,C++委员会提出了一套建议性规范:
在函数的后面加上throw(类型),表示这个函数所有可能抛出的异常类型。如果throw的括号内什么都不加,就表示这个函数不抛异常。若无该异常声明,则此表明函数可能抛出任何类型的异常。
比如:void func() throw(int, char, char*)就表示这个函数会抛出int、char和char*中的某种异常。void* operator delete(size_t size, void* ptr) throw()表示这个函数不抛异常。void func()就表示会抛出所有可能的异常。
#include
using namespace std;
void func() throw(int, char, char*)
{
int i = 1;
throw i;
}
int main()
{
try
{
func();
}
catch (int e)
{
cout << e << endl;
}
return 0;
}
委员会制定这个规范出发点是好的,可以让我们知晓被抛出的异常类型并进行相应的处理。
但是有些异常类型是非常复杂的,为了写出可能发生的异常类型,代价会很大,而且写时太繁琐写出来也不美观。因此,这个建议性的规范很少有人用,也正因为它只是一个建议,所以不使用或者不按要求使用也不会报错。
你想到的委员会肯定也想到了,所以为了让异常声明更加简洁,C++11增加了新的关键字:noexcept。
在一个函数的后面加上noexcept就表明这个函数不会抛异常,这个关键字就不再是建议性的了,只要这样的函数抛异常就会报错。下面代码中的func就不会抛异常,只是这个意外抛出的异常类型就无从知晓了。
#include
using namespace std;
void func() noexcept
{
cout << "该函数不会抛出异常" << endl;
}
int main()
{
try
{
func();
}
catch (int e)
{
cout << e << endl;
}
catch (...)
{
cout << "未知错误" << endl;
}
return 0;
}
总而言之,throw声明苦了写程序的,造福了看程序的;noexcept苦了看程序的,造福了写程序的。
在实际中,很多互联网公司都会有一套自定义的异常体系用以规范本公司内各种项目的异常管理。如果一个项目的开发中大家都根据自己的理解抛异常,这边抛错误码,那边抛字符串描述,甚至那边直接抛个结构体。那么外层的程序开发者在设计程序根本无法处理各种不同类型的异常。
所以在实际开发中公司内部都会定义一套基于类继承的规范体系。所有人抛出的都是派生类对象,通过基类捕获就可以了。这也是继承和多态的一个重要应用。
我们先创建一个异常的基类,如下图所示,包含异常信息和异常编号两个成员。使用what函数打印异常信息。
class Exception
{
public:
//构造函数
Exception(int eid, string s)
:_eid(eid)
,_eword(s)
{}
//错误类型
virtual string what() const
{
return _eword;
}
protected:
int _eid;
string _eword;
};
比如说一个项目有三个模块会抛异常,分别是SQL数据库,网络服务器和高速缓存。
SQL数据库,网络服务器和高速缓存三个模块,都各自创建一个派生类用来保存异常信息,使用被重写的what打印错误信息。
SQL数据库异常对象,类型为SQL_Exception
class SQL_Exception : public Exception
{
public:
//构造函数
SQL_Exception(int eid, const string& s, const string& sql_id)
:Exception(eid, s)
,_sql_id(sql_id)
{}
//错误类型
virtual string what() const
{
string str = "SQL_Exception::";
str += _sql_id;
str += " -> ";
str += _eword;
return str;
}
protected:
string _sql_id;
};
网络服务器异常对象,类型为Cache_Exception
class Cache_Exception : public Exception
{
public:
//构造函数
Cache_Exception(int eid, const string& s, const string& cache_exception)
:Exception(eid, s)
, _cache_exception(cache_exception)
{}
//错误类型
virtual string what() const
{
string str = "Cache_Exception::";
str += _cache_exception;
str += " -> ";
str += _eword;
return str;
}
protected:
string _cache_exception;
};
高速缓存异常对象,类型为HS_Exception
class HS_Exception : public Exception
{
public:
//构造函数
HS_Exception(int eid, const string& s, const string& hs_exception)
:Exception(eid, s)
, _hs_exception(hs_exception)
{}
//错误类型
virtual string what() const
{
string str = "SQL_Exception::";
str += _hs_exception;
str += " -> ";
str += _eword;
return str;
}
protected:
string _hs_exception;
};
下面三个函数负责在main函数内传入的随机数符合一定条件时抛异常。
void SQL_error(int num)
{
if (num % 10 == 6)
{
SQL_Exception e(1, "权限不足", "SQL1");
throw e;
}
}
void Cache_error(int num)
{
if (num % 10 == 7)
{
Cache_Exception e(1, "权限不足", "高速缓存");
throw e;
}
}
void HS_error(int num)
{
if (num % 10 == 8)
{
HS_Exception e(1, "访问出错", "网络服务器");
throw e;
}
}
我们在main函数中,循环十次try和catch,每隔1s执行一次。catch捕捉的类型是基类的引用const Exception&。
int main()
{
srand((unsigned int)time(nullptr));
for (int i = 0; i < 100; ++i)
{
try
{
int num = rand();
SQL_error(num);
Cache_error(num);
HS_error(num);
Sleep(2);
}
catch (const Exception& e)
{
cout << e.what() << endl;
}
}
return 0;
}
运行效果如下:
此时就已经实现了多态,调用的虽然是基类的成员函数what(),但是执行的是重写的what()。
其实异常就存在在我们的日常生活中,就比方说微信在网络不好的时候,会出现一个感叹号告诉你消息发不出去,此时的程序就是在抛异常,告诉你当前网络状态不佳。
如果这个异常按照C语言对错误的处理方式进行操作,整个微信进程就会直接崩溃,强行退出。而采用C++的抛异常机制,将抛出的异常捕获,然后处理,比如当出现网络不好抛异常时,微信就会采取尝试多次发送这样的操作,整个微信程序也不会退出。
正式由于在实际中,很多情况下我们是不希望只要产生异常就直接终止的整个进程的,通过抛异常和捕获处理异常的手段便可让程序保持运行。
在C++的标准库中也定义了基于多态的异常体系。
它们有以下继承结构(箭头由派生类指向基类,exception是所有类型的基类):
部分异常的问题描述
exception |
所有标准C++异常的基类 |
bad_alloc |
通常由new抛出,表示内存申请失败 |
invaild_argument |
使用了无效的参数时会抛出该异常 |
out_of_range |
该异常有类方法抛出,如operator[],表示越界 |
range_error |
当存储超出范围的值时,会抛出该异常 |
由于C++提供的异常体系对项目开发中的异常帮助十分有限,所以这个标准几乎没人用。
(1)异常对象的内容更多,相比错误码可以更清晰地展示出错误信息,可以帮助程序开发者更好地定位程序bug。
(2)返回错误码的传统方式有个巨大的问题,在函数调用链中,如果深层的函数产生了错误码,那么这个错误码必须层层返回,最外层才能拿到错误,而C++异常会直接销毁当前栈帧,然后去调用链的上一层匹配catch后处理。
(3)很多的第三方库都包含异常,比如boost、gtest等等,那么我们使用它们也需要使用异常。
(4)部分函数抛异常处理更方便,比如构造函数,因为没有返回值,所以使用错误码会非常不方便。
(1)异常会导致程序的执行流跳来跳去,如果没有正常抛异常时执行流就会到处乱跳。这会大大增加我们调试时寻找bug的难度。
(2)抛异常会有一些性能小号,但是在现代硬件的处理速度下,这个影响可以忽略不计。
(3)因为C++没有垃圾回收机制,所有申请的资源都需要自己管理。异常的使用就非常容易导致内存泄漏、死锁等安全问题,需要使用RAII来处理资源的管理问题。
(4)C++标准库的异常体系定义得并不好,大家就各自定义自己的异常体系,非常混乱。