在介绍异常之前,我觉得很有必要带大家了解一下运行时错误和c++异常出现之前的处理运行时错误的方式。这样子能更深入的了解异常的作用和工作原理
运行阶段错误
我们知道,程序有时候会遇到运行阶段错误,导致程序无法正常运行下去
C++在运行时可能会出现多种错误,这些错误被称为运行时错误或异常。
以下是一些常见的C++运行时错误:
-
数组越界:当程序尝试访问数组的索引超过数组范围时,会发生数组越界错误。这可能导致程序崩溃或产生未定义行为。
-
空指针引用:当程序试图访问一个空指针指向的内存位置时,会发生空指针引用错误。这通常是因为没有正确初始化指针或者指针被删除后仍然被引用。
-
除以零:当程序试图执行除以零的操作时,会发生除以零错误。这通常会导致程序崩溃或产生未定义行为。
-
内存泄漏:当程序分配了内存但没有正确释放时,会发生内存泄漏。如果内存泄漏问题严重,会导致程序在运行过程中耗尽可用内存而崩溃。
-
类型转换错误:当程序试图执行非法或不兼容的类型转换时,会发生类型转换错误。这可能导致程序产生不正确的结果或崩溃。
-
文件操作错误:当程序试图打开一个不存在的文件、读取写入超过文件范围的数据或者在不允许的情况下对文件进行操作时,会发生文件操作错误。
为了处理这些运行时错误,可以使用C++的异常机制。通过捕获和处理异常,可以使程序在出现错误时能够优雅地处理,并在需要时进行错误恢复或退出。
异常出现之前的处理错误的方式
讨论异常之前,先来看看程序员可使用的一些处理运行时错误的方法
先来看一个除0错误
cout<<7/x;
如果x=0,这条语句就变成了7/0,但是我们知道0是不能做被除数的
如果我们不做任何检查和处理,看看编译器会怎么处置这么一种情况
对于被0除的情况,很多新式编译器通过生成一个表示无穷大的特殊浮点值来处理,cout将这种值显示为Inf,inf,INF或者类似的东西;
而其他编译器可能生成在发生被0除时崩溃的程序。
那我们怎么来防止这种错误的发生呢?有两种最常用的方法
调用abort()
我们先来了解一下abort函数吧!
abort()简介
在C++中,abort()
函数用于终止程序的执行。
abort()
函数位于头文件<cstdlib>中,函数原型如下:
void abort (void);
调用abort()
函数会导致程序立即终止,并生成一个异常终止信号并将其发送到标准错误流。
程序的终止是非正常的,它不会执行任何的析构函数、清理操作等。
通常情况下,abort()
函数被用于处理严重错误或异常情况,强制终止程序以避免进一步的损害。
示例使用abort()
函数:
#include <cstdlib>
int main() {
int a = 0;
if (a == 0) {
abort(); // 如果 a 等于 0,强制终止程序
}
return 0;
}
在上面的示例中,如果a
的值等于0,则调用abort()
函数终止程序的执行。
运行结果如下
解决问题
对于上面那个问题,处理的方式之一就是,如果x==0,就调用abort函数
#include<iostream>
using namespace std;
#include <cstdlib>
int main() {
int a;
scanf("%d", &a);
if (a == 0) {
abort(); // 如果 a 等于 0,强制终止程序
}
cout<< 7 / a;
return 0;
}
我们输入1,运行结果是
我们输入0,运行结果是
返回错误码
一种比异常终止更灵活的方法是,使用函数的返回值来指出问题。
比如ostream类的get()成员通常返回下一个输入字符的ASCII码,但到文件尾时,将返回特殊值EOF.
我们还可以看个更通俗易懂的例子
#include <iostream>
int divide(int a, int b) {
if (b == 0) {
// 返回错误码 1,表示除数为零错误
return 1;
}
// 执行除法操作并返回结果
return a / b;
}
int main() {
int a = 10;
int b = 0;
int result = divide(a, b);
if (result == 0) {
// 处理除法操作成功的情况
std::cout << "除法操作结果:" << result << std::endl;
} else {
// 处理除数为零错误
std::cout << "除数为零错误!" << std::endl;
}
return 0;
}
在上面的示例中,divide()
函数用于执行两数相除操作,并检查除数是否为零。如果除数为零,则返回错误码1,否则返回相除的结果。在main()
函数中,首先将10除以0,然后根据返回的错误码来判断函数执行的状态。如果返回的错误码为0,则说明除法操作成功,可以打印结果。否则,说明除数为零错误,可以相应地处理错误情况。
需要注意的是,上面的示例只是简单示例,实际应用中可能有更复杂的错误码及错误处理机制。
解决问题
对于上面那个问题,我们直接定义一个判断函数即可
#include<iostream>
using namespace std;
int A(int a)
{
if (a == 0)
return 0;
else
return 1;
}
int main() {
int a;
scanf("%d", &a);
int b = A(a);
if(b==1)
cout<< 7 / a;
else
cout << "a不能为0" << endl;
}
这样子就解决了
C++异常概念
相信看完上面的例子,你已经知道异常的作用大概是什么了
异常是相对较新的C++功能,有些老式编译器可能没有实现。另外有些编译器默认关闭这种特性,你可能需要使用编译器选项来启用它
异常是面向对象语言常用的一种处理错误的方式,当一个函数发现自己无法处理的错误时就可以抛出异常,让函数直接或间接的调用者处理这个错误。
对异常的处理有3部分组成
- 引发异常(throw)
- 使用处理程序捕获异常(catch)
- 使用try块(try)
throw
当程序出现问题时,可以通过throw关键字抛出一个异常。
throw关键字表示引发异常,紧随其后的值指出了异常的特征,它的类型也叫异常类型
异常类型可以是任何类型,但是通常是类类型
throw语句实际上是跳转,即命令程序跳到另一条语句(跳到catch块)。
throw通常会被放在一个函数里,而这个函数通常被放在try块里面,执行throw语句类似于执行返回语句,因为它也会终止函数的执行,
但throw不是将控制权返回给调用程序,而是导致程序沿函数调用序列后退,直到找到包含try块的函数,把控制权交回给这个函数。然后再在有控制权的这个函数里寻找与引发异常类型匹配的异常处理程序(即catch块)
throw异常对象;
异常对象
异常对象是一种特殊的对象,编译器使用异常抛出表达式来对异常对象进行拷贝初始化。因此,throw语句中的表达式必须拥有完全类型。
而且如果该表达式是类类型的话,则相应的类必须含有一个可访问的析构函数和一个可访问的拷贝或移动构造函数。如果该表达式是数组类型或函数类型,则表达式将被转换成与之对应的指针类型。
异常对象位于由编译器管理的空间中,编译器确保无论最终调用的是哪个 catch 子句都能访问该空间。当异常处理完毕后,异常对象被销毁。
如我们所知,当一个异常被抛出时,沿着调用链的块将依次退出直至找到与异常匹配的处理代码。如果退出了某个块,则同时释放块中局部对象使用的内存。因此,抛出一个指向局部对象的指针几乎肯定是一种错误的行为。
出于同样的原因,从函数中返回指向局部对象的指针也是错误的。如果指针所指的对象位于某个块中,而该块在catch语句之前就已经退出了,则意味着在执行catch语句之前局部对象已经被销毁了。
当我们抛出一条表达式时,该表达式的静态编译时类型决定了异常对象的类型。读者必须牢记这一点,因为很多情况下程序抛出的表达式类型来自于某个继承体系。如果一条throw表达式解引用一个基类指针,而该指针实际指向的是派生类对象,则抛出的对象将被切掉一部分,只有基类部分被抛出。
抛出指针要求在任何对应的处理代码存在的地方,指针所指的对象都必须存在。
catch
如果try块中发生错误,则可以在catch块中定义对应要执行的代码块。
程序使用异常处理程序(也叫catch块)来捕获异常,异常处理程序位于要处理问题的程序中。
异常处理程序以关键字catch开头,随后是位于括号的类型声明,它指出了这个catch块要响应的异常类型;然后是一个用花括号括起的代码块,指出要采取的措施。
catch(这个catch块对应的异常类型)
{
处理异常的措施
}
这样子感觉有点像函数定义啊,但是它不是函数定义
catch关键字和异常类型用作标签,指出当异常被引发时(即执行了throw语句后),程序应该跳到这个位置执行。
try
try块是由关键字try指示的,关键字try后面是一个由花括号括起的代码块,表明需要注意这些代码引发的异常(即表明这个代码块里有throw语句)
try
{
//里面含有throw语句
}
该代码块在执行时将进行异常错误检测,try块里面通常含有throw语句,后面通常跟着一个或多个catch块。
如下所示:
try
{
含throw语句
//被保护的代码
}
catch (ExceptionName e1)
{
//catch块
}
catch (ExceptionName e2)
{
//catch块
}
catch (ExceptionName eN)
{
//catch块
}
使用介绍
我们还是以上面那个除0错误为例来展示异常机制
#include<iostream>
using namespace std;
int A(int a)
{
if (a == 0)
throw"a不能为0";
else
return a;
}
int main() {
int a;
scanf("%d", &a);
try
{
int b = A(a);
}
catch (const char* s)
{
cout << s << endl;
}
cout << 7 / a;
}
我们来看看这里面的机制是怎么样的呢?
如果输入0,a被传进入A函数,触发了throw语句,throw将其后面的字符串抛出,回到try块下面开始寻找与字符串类型相符合的catch块,然后执行其中的内容
我们如果输入1,a被传进A函数,并没有触发throw语句,所以程序将跳过try块后面的catch块,直接处理程序后面的第一条语句
执行throw语句类似于执行返回语句,因为它也会终止函数的执行,
但throw不是将控制权返回给调用程序,而是导致程序沿函数调用序列后退,直到找到包含try块的函数,把控制权交回给这个函数。然后再在有控制权的这个函数里寻找与引发异常类型匹配的异常处理程序(即catch块)
在这个例子中throw将程序控制权返回给main函数,程序将在main函数寻找与引发异常类型匹配的异常处理程序(即catch块)
抛出异常
在C++语言中,我们通过抛出一条表达式来引发一个异常。
被抛出的表达式的类型以及当前的调用链共同决定了哪段处理代码将被用来处理该异常。
被选中的处理代码是在调用链中与抛出对象类型匹配的最近的处理代码。其中,根据抛出对象的类型和内容,程序的异常抛出部分将会告知异常处理部分到底发生了什么错误。
当执行一个throw时,跟在throw后面的语句将不再被执行。
相反,程序的控制权从throw转移到它所在try块后面跟随的catch块中异常类型和抛出的异常相匹配的那个catch模块。
该catch可能是同一个函数中的局部catch,也可能位于直接或间接调用了发生异常的函数的另一个函数中。
控制权从一处转移到另处,这有两个重要的含义:
- 沿着调用链的函数可能会提早退出。
- 一旦程序开始执行异常处理代码,则沿着调用链创建的对象将被销毁。
因为跟在throw后面的语句将不再被执行,所以throw语句的用法有点类似于return语句:它通常作为条件语句的一部分或者作为某个函数的最后(或者唯一)一条语句。
栈展开
当抛出一个异常后,程序暂停当前函数的执行过程并立即开始寻找与异常匹配的catch 子句。
- 当throw出现在一个try语句块内时,检查与该try块关联的catch子句。如果找到了匹配的catch,就使用该catch处理异常。
- 如果这一步没找到匹配的 catch 且该 try语句嵌套在其他try块中,则继续检查与外层 try匹配的catch 子句。
- 如果还是找不到匹配的catch,则退出当前的函数,在调用当前函数的外层函数中继续寻找。
- 如果对抛出异常的函数的调用语句位于一个try语句块内,则检查与该try块关联的catch子句。
- 如果找到了匹配的 catch,就使用该 catch处理异常。
- 否则,如果该try块中,则继续检查与外层try匹配的catch子句。
- 如果仍然没有找到匹配的catch 子句,则退出当前这个主调函数,继续在调用了刚刚退出的这个函数的其他函数中寻找,以此类推。
上述过程被称为栈展开(stack unwinding)过程。 栈展开过程沿着嵌套函数的调用链不断查找,直到找到了与异常匹配的catch 子句为止:或者也可能一直没找到匹配的catch, 则退出主函数后查找过程终止。
- 假设找到了一个匹配的catch子句,则程序进入该子句并执行其中的代码,当执行完这个catch子句后,找到与trv块关联的最后一个catch子句之后的点,并从这里继续执行。
- 如果没找到匹配的catch子句,程序将退出,因为异常通常被认为是妨碍程序正常执行的事件,所以一旦引发了某个异常,就不能对它置之不理。当找不到匹配的 catch时,程序将调用标准库函数 terminate,顾名思义,terminate 负责终止程序的执行过程。
一个异常如果没有被捕获,则它将终止当前的程序。
栈展开过程中对象被自动销毁
在栈展开过程中,位于调用链上的语句块可能会提前退出。通常情况下,程序在这些块中创建了一些局部对象。我们已经知道,块退出后它的局部对象也将随之销毁,这条规则对于栈展开过程同样适用。
如果在栈展开过程中退出了某个块,编译器将负责确保在这个块中创建的对象能被正确地销毁。如果某个局部对象的类型是类类型,则该对象的析构函数将被自动调用。与往常一样,编译器在销毁内置类型的对象时不需要做任何事情。
如果异常发生在构造函数中,则当前的对象可能只构造了一部分。有的成员已经初始化了,而另外一些成员在异常发生前也许还没有初始化。即使某个对象只构造了一部分,我们也要确保已构造的成员能被正确地销毁。
类似的,异常也可能发生在数组或标准库容器的元素初始化过程中。与之前类似,如果在异常发生前已经构造了一部分元素,则我们应该确保这部分元素被正确地销毁。
析构函数与异常
析构函数总是会被执行的,但是函数中负责释放资源的代码却可能被跳过,这一特点
对于我们如何组织程序结构有重要影响。
如果一个块分配了资源,并且在负责释放这些资源的代码前面发生了异常,则释放资源的代码将不会被执行。
另一方面,类对象分配的资源将由类的析构函数负责释放。
因此,如果我们使用类来控制资源的分配,就能确保无论函数正常结束还是遭遇异常,资源都能被正确地释放。析构函数在栈展开的过程中执行,这一事实影响着我们编写析构函数的方式。
在栈展开的过程中,已经引发了异常但是我们还没有处理它。如果异常抛出后没有被正确捕获,则系统将调用 terminate函数。因此,出于栈展开可能使用析构函数的考虑,析构函数不应该抛出不能被它自身处理的异常。换句话说,如果析构函数需要执行某个可能抛出异常的操作,则该操作应该被放置在一个try语句块当中,并且在析构函数内部得到处理。
在栈展开的过程中,运行类类型的局部对象的析构函数。因为这些析构函数是自动执行的,所以它们不应该抛出异常。
一旦在栈展开的过程中析构函数抛出了异常。并且析构函数自身没能捕获到该异常,则程序将被终止。
catch子句和函数的相似之处
catch子句中的异常声明看起来像是只包含个形参的函数形表,像在形参列表中一样,如果catch无须访问抛出的表达式的话,则我们可以忽略捕获形参的名字。
声明的类型决定了处理代码所能捕获的异常类型,这个类型必须是完全类型,它可以是左值引用,但不能是右值引用。
相似处1
当进入一个catch语句后,通过异常对象初始化异常声明中的参数。和函数的参数类似,如果catch的参数类型是非引用类型,则该参数是异常对象的一个副本,在catch语句内改变该参数实际上改变的是局部副本而非异常对象本身;相反,如果参数是引用类型,则和其他引用参数一样,该参数是异常对象的一个别名,此时改变参数也就是改变异常对象
相似处2
catch的参数还有一个特性也与函数的参数非常类似:如果catch的参数是基类类型,则我们可以使用其派生类类型的异常对象对其进行初始化。
此时,如果 catch的参数是非引用类型,则异常对象将被切掉一部分,这与将派生类对象以值传递的方式传给一个普通函数差不多。
另一方面,如果catch的参数是基类的引用,则该参数将以常规方式绑定到异常对象上。
相似处3
最后一点需要注意的是,异常声明的静态类型将决定 catch 语句所能执行的操作。
如果catch的参数是基类类型,则catch无法使用派生类特有的任何成员。
通常情况下,如果catch接受的异常与某个继承体系有关,则最好将该catch的参数定义成引用类型。
查找匹配的处理代码
在搜寻catch语句的过程中,我们最终找到的catch未必是异常的最佳匹配。相反,挑选出来的应该是第一个与异常匹配的catch语句。
因此,越是专门的catch越应该置于整个catch列表的前端。
因为catch 语句是按照其出现的顺序逐一进行匹配的,所以当程序使用具有继承关系的多个异常时必须对catch语句的顺序进行组织和管理,使得派生类异常的处理代码出现在基类异常的处理代码之前。
与实参和形参的匹配规则相比,异常和catch异常声明的匹配规则受到更多限制。
此时,绝大多数类型转换都不被允许,除了一些极细小的差别之外,要求异常的类型和catch声明的类型是精确匹配的:
- 允许从非常量向常量的类型转换,也就是说,一条非常量对象的throw语句可以匹配一个接受常量引用的catch语句。
- 允许从派生类向基类的类型转换。
- 数组被转换成指向数组(元素)类型的指针,函数被转换成指向该函数类型的指针。
除此之外,包括标准算术类型转换和类类型转换在内,其他所有转换规则都不能在匹配catch的过程中使用
异常的用法
异常的抛出和捕获
异常的抛出和捕获的匹配原则:
准则一
异常是通过抛出异常类型(通常是对象)而引发的,该异常类型(通常是对象)决定了应该激活哪个catch的处理代码,如果抛出的异常对象没有捕获,或是没有匹配类型的捕获,那么程序会终止报错。
相匹配的例子
#include<iostream>
using namespace std;
int A(int a)
{
if (a == 0)
throw"a不能为0";
else
return a;
}
int main() {
int a;
scanf("%d", &a);
try
{
int b = A(a);
}
catch (const int* s)
{
cout << "第一个" << endl;
}
catch (const char* s)
{
cout << "第二个" << endl;
}
cout << 7 / a;
}
没有相匹配的例子
#include<iostream>
using namespace std;
int A(int a)
{
if (a == 0)
throw"a不能为0";
else
return a;
}
int main() {
int a;
scanf("%d", &a);
try
{
int b = A(a);
}
catch (const int* s)
{
cout << "第一个" << endl;
}
catch (const float* s)
{
cout << "第二个" << endl;
}
cout << 7 / a;
}
准则二
被选中的处理代码(catch块)是调用链中与该对象类型匹配且离抛出异常位置最近的那一个。
看个例子
#include<iostream>
using namespace std;
int A(int a)
{
if (a == 0)
throw"a不能为0";
else
return a;
}
int main() {
int a;
scanf("%d", &a);
try
{
int b = A(a);
}
catch (const char* s)
{
cout << "第一个" << endl;
}
catch (const char* s)
{
cout << "第二个" << endl;
}
cout << 7 / a;
}
异常只能被第一个捕获
准则三
抛出异常对象后,编译器总会生成一个异常对象的拷贝,即使异常规范和catch块指定的是引用,这个拷贝的临时对象会在被catch以后销毁。(类似于函数的传值返回)
class AA{...}
...
void A()
{
AA a;
if(...)
{
throw a;
}
...
}
try
{
A();
}
catch(AA&t)
{...}
t将指向a的副本而不是a本身。这是件好事,因为函数A执行完后a将不复存在。
准则四
catch(...)可以捕获任意类型的异常,但捕获后无法知道异常错误是什么。
catch(...)通常被放在众多catch块的最后面,防止程序因为没有相匹配的异常类型而导致停止运行
#include<iostream>
using namespace std;
int A(int a)
{
if (a == 0)
throw"a不能为0";
else
return a;
}
int main() {
int a;
scanf("%d", &a);
try
{
int b = A(a);
}
catch (char a)
{
cout << "第一个" << endl;
}
catch (...)
{
cout << "未知错误" << endl;
}
cout << 7 / a;
}
准则五
实际异常的抛出和捕获的匹配原则有个例外,捕获和抛出的异常类型并不一定要完全匹配,可以抛出派生类对象,使用基类进行捕获,这个在实际中非常有用。但是也会带来一些问题。
#include<iostream>
using namespace std;
class AA
{};
class BB:public AA
{};
class CC:public BB
{};
int A(int a)
{
CC m;
if (a == 0)
throw m;
else
return a;
}
int main() {
int a;
scanf("%d", &a);
try
{
int b = A(a);
}
catch (AA&t)//改成BB&t或者CC&t也可以匹配到
{
cout << "第一个" << endl;
}
cout << 7 / a;
}
使用基类引用则可以捕获抛出的该基类和所有派生类的异常,而使用派生类引用则只能捕获它所属类及从这个类派生来的类对象。
而引发的异常对象将先和第一个与之匹配的catch块捕获。这意味着catch块的排列顺序应该和派生顺序相反。
如果有一个异常类继承层次结构,应该这样排列catch块:将捕获位于层次结构最下面的异常类的catch语句放在最前面,将捕获基类异常的catch语句放在最后面
#include<iostream>
using namespace std;
class AA
{};
class BB:public AA
{};
class CC:public BB
{};
int A(int a)
{
BB m;
if (a == 0)
throw m;
else
return a;
}
int main() {
int a;
scanf("%d", &a);
try
{
int b = A(a);
}
catch (CC&t)
{
cout << "第一个" << endl;
}
catch (BB& t)//改成BB&t或者CC&t也可以匹配到
{
cout << "第二个" << endl;
}
catch (AA& t)
{
cout << "第三个" << endl;
}
cout << 7 / a;
}
异常的重新抛出
有时,一个单独的catch 语句不能完整地处理某个异常。
在执行了某些校正操作之后,当前的catch可能会决定由调用链更上一层的函数接着处理异常。
一条catch 语句通过重新抛出(rethrowing)的操作将异常传递给另外一个catch语句。
这里的重新抛出仍然是一条throw语句,只不过不包含任何表达式:
throw;
空的throw 语句只能出现在catch语句或catch语句直接或间接调用的函数之内。如果在处理代码之外的区域遇到了空throw语句,编译器将调用terminate。
一个重新抛出语句并不指定新的表达式,而是将当前的异常对象沿着调用链向上传递。
很多时候,catch语句会改变其参数的内容。如果在改变了参数的内容后catch语句重新抛出异常,则只有当 catch异常声明是引用类型时我们对参数所做的改变才会被保留并继续传播:
catch (my error &eObj) //引用类型
{
eObj.status =errCodes::severeErr; // 修改了异常对象
throw; // 异常对象的status成员是severeErr
}
catch (other error ebj) // 非引用类型
{eObj.status =errCodes::badErr; // 只修改了异常对象的局部副本
throw; // 异常对象的status成员没有改变
}
异常安全
将抛异常导致的安全问题叫做异常安全问题,对于异常安全问题下面给出几点建议:
- 构造函数完成对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象不完整或没有完全初始化。
- 析构函数主要完成对象资源的清理,最好不要在析构函数中抛出异常,否则可能导致资源泄露(内存泄露、句柄未关闭等)。
- C++中异常经常会导致资源泄露的问题,比如在new和delete中抛出异常,导致内存泄露,在lock和unlock之间抛出异常导致死锁,C++经常使用RAII的方式来解决以上问题。
异常规范
为了让函数使用者知道某个函数可能抛出哪些类型的异常,C++标准规定:
在函数的后面接throw(type1, type2, ...),列出这个函数可能抛掷的所有异常类型。
在函数的后面接throw()或noexcept(C++11),表示该函数不抛异常。
若无异常接口声明,则此函数可以抛掷任何类型的异常。(异常接口声明不是强制的)
比如:
//表示func函数可能会抛出A/B/C/D类型的异常
void func() throw(A, B, C, D);
//表示这个函数只会抛出bad_alloc的异常
void* operator new(std::size_t size) throw(std::bad_alloc);
//表示这个函数不会抛出异常
void* operator new(std::size_t size, void* ptr) throw();
感兴趣的可以看看这个:noexcept: http://t.csdnimg.cn/BQymD
自定义异常体系
实际中很多公司都会自定义自己的异常体系进行规范的异常管理。
公司中的项目一般会进行模块划分,让不同的程序员或小组完成不同的模块,如果不对抛异常这件事进行规范,那么负责最外层捕获异常的程序员就非常难受了,因为他需要捕获大家抛出的各种类型的异常对象。
因此实际中都会定义一套继承的规范体系,先定义一个最基础的异常类,所有人抛出的异常对象都必须是继承于该异常类的派生类对象,因为异常语法规定可以用基类捕获抛出的派生类对象,因此最外层就只需捕获基类就行了。
最基础的异常类至少需要包含错误编号和错误描述两个成员变量,甚至还可以包含当前函数栈帧的调用链等信息。该异常类中一般还会提供两个成员函数,分别用来获取错误编号和错误描述。比如:
class Exception
{
public:
Exception(int errid, const char* errmsg)
:_errid(errid)
, _errmsg(errmsg)
{}
int GetErrid() const
{
return _errid;
}
virtual string what() const
{
return _errmsg;
}
protected:
int _errid; //错误编号
string _errmsg; //错误描述
//...
};
其他模块如果要对这个异常类进行扩展,必须继承这个基础的异常类,可以在继承后的异常类中按需添加某些成员变量,或是对继承下来的虚函数what进行重写,使其能告知程序员更多的异常信息。比如:
class CacheException : public Exception
{
public:
CacheException(int errid, const char* errmsg)
:Exception(errid, errmsg)
{}
virtual string what() const
{
string msg = "CacheException: ";
msg += _errmsg;
return msg;
}
protected:
//...
};
class SqlException : public Exception
{
public:
SqlException(int errid, const char* errmsg, const char* sql)
:Exception(errid, errmsg)
, _sql(sql)
{}
virtual string what() const
{
string msg = "CacheException: ";
msg += _errmsg;
msg += "sql语句: ";
msg += _sql;
return msg;
}
protected:
string _sql; //导致异常的SQL语句
//...
};
说明一下:
异常类的成员变量不能设置为私有,因为私有成员在子类中是不可见的。
基类Exception中的what成员函数最好定义为虚函数,方便子类对其进行重写,从而达到多态的效果。
异常的优缺点
异常的优点:
- 异常对象定义好了,相比错误码的方式可以清晰准确的展示出错误的各种信息,甚至可以包含堆栈调用等信息,这样可以帮助更好的定位程序的bug。
- 返回错误码的传统方式有个很大的问题就是,在函数调用链中,深层的函数返回了错误,那么我们得层层返回错误码,最终最外层才能拿到错误。
- 很多的第三方库都会使用异常,比如boost、gtest、gmock等等常用的库,如果我们不用异常就不能很好的发挥这些库的作用。
- 很多测试框架也都使用异常,因此使用异常能更好的使用单元测试等进行白盒的测试。
- 部分函数使用异常更好处理,比如T& operator这样的函数,如果pos越界了只能使用异常或者终止程序处理,没办法通过返回值表示错误。
异常的缺点:
C++异常的缺点包括以下几点:
-
性能开销:在抛出异常时,C++需要执行一些额外的操作,比如堆栈展开和资源清理。这些操作可能会导致性能下降,尤其是在频繁出现异常的情况下。
-
不适合底层开发:在底层开发中,对性能要求非常高,因此异常处理可能不适用。异常处理需要一定的处理逻辑和系统开销,这对于低级别的系统编程来说可能是不可接受的。
-
可能引发资源泄露:如果异常没有被正确处理,可能会导致资源泄露。如果在异常发生时没有及时释放资源,可能会导致内存泄露、文件句柄泄露等问题。
-
可能引发不确定行为:在异常发生时,如果没有适当的处理,程序可能会进入不确定的状态。这可能导致程序崩溃、数据损坏或其他不可预测的行为。
-
不够直观:异常处理可能使代码变得复杂,不够直观。异常的处理逻辑通常分散在代码中的多个地方,这可能使代码难以阅读和维护。
-
可能导致资源泄漏:当程序在异常处理过程中退出时,可能会导致未释放的资源,导致资源泄漏。这是因为异常处理通常是在函数调用堆栈展开时进行的,当异常处理结束后,函数调用堆栈将不再展开,从而导致资源泄漏。
-
异常会导致程序的执行流乱跳,并且非常的混乱,这会导致我们跟踪调试以及分析程序时比较困难。
总的来说,C++异常处理机制在某些情况下可能会导致性能下降、不确定行为和资源泄露等问题。因此,在使用异常处理时需要谨慎,并在适当的情况下选择合适的替代方案。