C++之异常
创始人
2024-05-29 04:13:48
0

目录

一、C语言传统的处理错误的方式

二、C++异常概念

三、异常的使用

异常的抛出和捕获

如何就规范异常这个问题呢?

在函数调用链中异常栈展开匹配原则

异常的重新抛出 

如何做到把资源释放了,还要让异常在最外面处理呢?

四、自定义异常体系

C++标准库的异常体系

五、异常安全

六、异常规范

七、异常的优缺点

C++异常的优点:

C++异常的缺点:

异常不仅仅是属于C++的一个概念,它是属于面向对象的概念。面向对象的语言处理错误的方式几乎都选择了异常。对比的就是C语言处理错误的方式。

一、C语言传统的处理错误的方式

传统的错误处理机制:
  • 1. 终止程序,如assert,缺陷:用户难以接受。如发生内存错误,除0错误时就会终止程序。
  • 2. 返回错误码,缺陷:需要程序员自己去查找对应的错误。如系统的很多库的接口函数都是通过把错误码放到errno中,表示错误。
  • 3. C 标准库中setjmp和longjmp组合。这个不是很常用,了解一下。
实际中C语言基本都是使用返回错误码的方式处理错误,部分情况下使用终止程序处理非常严重的错误。 

二、C++异常概念

异常是一种处理错误的方式,当一个函数发现自己无法处理的错误时就可以抛出异常,让函数的直接或间接的调用者处理这个错误。
  • throw: 当问题出现时,程序会抛出一个异常。这是通过使用 throw 关键字来完成的。相比错误码而言,你抛出的对象可以包含很多的信息,因为错误码只是一个编号,但是这个对象可以是个字符串甚至是一个自定义类型的对象,可以包含更多的信息。
  • catch: 在您想要处理问题的地方,通过异常处理程序捕获异常.catch 关键字用于捕获异常,可以有多个catch进行捕获。
  • try: try 块中的代码标识将被激活的特定异常,它后面通常跟着一个或多个 catch 块。
如果有一个块抛出一个异常,捕获异常的方法会使用 try 和 catch 关键字。try 块中放置可能抛出异常的代码,try 块中的代码被称为保护代码。使用 try/catch 语句的语法如下所示:
try
{// 保护的标识代码
}catch( ExceptionName e1 )
{// catch 块
}catch( ExceptionName e2 )
{// catch 块
}catch( ExceptionName eN )
{// catch 块
}

eg:我们的代码就是主函数执行Func,Func中包含一个Division函数,如果除数等于0抛异常,不等于0则返回计算的结果。

double Division(int a, int b)
{// 当b == 0时抛出异常if (b == 0)throw "Division by zero condition!";elsereturn ((double)a / (double)b);
}
void Func()
{int len, time;cin >> len >> time;cout << Division(len, time) << endl;
}
int main()
{try {Func();}catch (const char* errmsg){cout << errmsg << endl;} return 0;
}

我们这里throw的就是一个字符串对象,我们是在main函数中处理的,try处理完之后就会自动跳到catch的地方,我们这里的catch就是直接捕获一个字符串,然后输出。 

1.程序没有抛异常的执行逻辑

我们输入正确的数字,它是直接从23行跳转到29的,中间的catch并不会执行,因为我们现在的程序是正确的,也就是说如果你没有抛异常,catch的代码就跳过了。

2.程序抛异常后,代码的执行逻辑

程序抛了异常,直接从8行跳转到26行。也就是说如果代码抛了异常直接跳转到捕获他的地方。我们这里写的catch就是拿到这个字符串,并输出一下。

三、异常的使用

异常的抛出和捕获

异常的抛出和匹配原则

  • 1. 异常是通过抛出对象而引发的,该对象的类型决定了应该激活哪个catch的处理代码。catch是可以有任意多个的。 

eg:基于上面的代码进行修改

double Division(int a, int b)
{// 当b == 0时抛出异常if (b == 0)throw "Division by zero condition!";elsereturn ((double)a / (double)b);
}
void Func1()
{int len, time;cin >> len >> time;cout << Division(len, time) << endl;
}void Func2()
{int len, time;cin >> len >> time;if (time != 0){throw 1;}else{cout << len << " " << time << endl;}
}
int main()
{try {Func1();Func2();}catch (const char* errmsg){cout << errmsg << endl;} catch (int errid){cout << errid << endl; }catch (...) {cout << "unkown exception" << endl;}return 0;
}

我们在执行Func2的时候抛了异常,直接就从25行跳转到了45行,第一个catch就直接跳过,剩下的catch也不会执行,执行完当前的catch就直接跳转到51行结束程序。 

  • 2. 被选中的处理代码是调用链中与该对象类型匹配且离抛出异常位置最近的那一个。

这个调用链指的就是函数的调用。像我们上面的代码就有一个这样的调用链。main函数调用Func1,Func1调用Division,这就是一个调用链。在Division中可以进行抛异常。这个Division抛出的异常激活的可不一定是主函数里面的catch,我们上面的例子激活的都是主函数里面的catch是因为别的地方都没有对应的catch。

eg: Func1中也有捕获。

double Division(int a, int b)
{// 当b == 0时抛出异常if (b == 0)throw "Division by zero condition!";elsereturn ((double)a / (double)b);
}
void Func1()
{try{int len, time;cin >> len >> time;cout << Division(len, time) << endl;}catch (const char* errmsg){cout << errmsg << endl;}
}void Func2()
{int len, time;cin >> len >> time;if (time != 0){throw 1;}else{cout << len << " " << time << endl;}
}
int main()
{try {Func1();Func2();}catch (const char* errmsg){cout << errmsg << endl;} catch (int errid){cout << errid << endl; }catch (...) {cout << "unkown exception" << endl;}return 0;
}

抛出异常后是被Func1捕获到了,而不是被main函数所捕获。因为Func1和main函数中的catch都和这个异常匹配,但是Func1里所抛异常位置最近。 

如果Func1中有捕获但是类型不匹配,那么就会往匹配的地方跳。

总结:

对于这条调用链,首先看抛出的异常和哪个函数中的catch匹配,和哪一个匹配就跳转到哪一个的catch中去;如果两个catch都匹配,那么谁的位置离所抛异常近,就跳转到哪个函数的catch中去  

  • 3. 抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是(绝大多数都是)一个临时对象,所以会生成一个拷贝对象,这个拷贝的临时对象会在被catch以后销毁。(这里的处理类似于函数的传值返回)

eg:

double Division(int a, int b)
{// 当b == 0时抛出异常if (b == 0){string str("除零错误");throw str;}else{return ((double)a / (double)b);}}
void Func1()
{int len, time;cin >> len >> time;cout << Division(len, time) << endl;
}int main()
{try{Func1();}catch (const string& errmsg){cout << errmsg << endl;}return 0;
}

main函数调用Func1,Func1调用Division。Division函数throw了一个str,但是这个str是一个临时对象,是一个局部栈帧的对象,出了作用域它就销毁了。而这个catch就类似于实参传给形参,这个catch捕获的并不是这个str,而是str的拷贝,这个拷贝对象的生命周期在被catch后被销毁。所以编译器不能直接把这个str扔给catch,throw看起来是直接从throw的地方跳转到catch,但实际包含的过程:执行流是直接跳转过去,编译器编译的时候,通过throw的类型依次进行检查,找到对应的地方,编译的时候就把这些东西检查好了;跳转的过程中Division的栈帧进行销毁,随着Func1的栈帧进行销毁,然后回到main,从catch开始执行。  

  • 4. catch(...)可以捕获任意类型的异常,问题是不知道异常错误是什么。

有时候我们确实捕获了异常,但是我们不排除某些地方悄悄的给我们抛出异常。

eg:

double Division(int a, int b)
{// 当b == 0时抛出异常if (b == 0){string str("除零错误");throw str;}else{return ((double)a / (double)b);}
}
void Func1()
{try{int len, time;cin >> len >> time;cout << Division(len, time) << endl;}catch (int errid){cout << errid << endl;}
}void Func2()
{int len, time;cin >> len >> time;if (time != 0){throw 3.33;}else{cout << len << " " << time << endl;}
}
int main()
{try{Func1();Func2();}catch (const string& errmsg){cout << errmsg << endl;}catch (int errid){cout << errid << endl;}return 0;
}

Func2抛出1个3浮点数类型的异常。因为没有对应的catch,所以程序就直接崩溃了。

而且出现的这种警告,在我们使用windows软件的时候也会出现。出现这个问题的原因就是有异常没有被捕获。我们上面的代码根本问题就是catch的类型没有和throw的类型匹配。C++中规定,如果抛出的异常没有与之匹配的就会终止程序。但是我们更多情况下是不希望程序终止的。比如: 用微信聊天,你给某人发消息,发送失败,这就是微信调用发消息函数的这个接口调用失败,发送失败微信就会给你一个发送错误的小提示。但微信不会因为发生消息失败就直接跳出和我们例子类似的窗口,点一下就直接退出了。

为了防止这种情况的发生(某些地方抛出一些程序不认识的异常导致程序直接终止),所以就出现了...,这...可以匹配任意的类型,有匹配的异常就走匹配的,没匹配异常就走...,但是整个东西不好的地方在于我虽然捕获到异常没有让程序终止,但是我不知道这个异常是什么。

eg:基于上面代码增加一个catch...

  • 5. 实际中抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,可以抛出的派生类对象,使用基类捕获,这个在实际中非常实用,我们后面会详细讲解这个。 

 eg:比如公司写一个服务器从上到下很多层

不管是哪一个层出来错误(抛异常)都不处理,最终异常就都到了网络层里面去了,一般会在网络这层统一捕获,捕获以后要记录日志。一般公司都会以类似这种的方式去设计。

再比如说公司比较大,每一个小组都是5个人,下面的小组你抛你的异常,我抛我的异常,一会抛字符串类型,一会抛自定义类型,我虽然可以把常见的类型都捕获了,但是对于自定义类型却很难办,所以随便抛异常会让最外层的人很难受,所以一般我们都会有异常规范的,不能想抛啥就抛啥

如何就规范异常这个问题呢?

比如数据库持久化层抛出的时候就记录下SQL语句,缓存层记录下插入缓存的id是什么,业务逻辑层记录下业务逻辑干的是啥,网络层可能要记录下是什么网络请求......每一次都有对应的要求。

正常规范的代码几乎是不太可能去抛整形,字符串这样的异常的,一般是抛自定义类型。所以C++衍生出:抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,可以抛出的派生类对象,使用基类捕获。实际中也都是这么干的。

比如:我catch一个Exception类的对象,throw的时候就可以跑Exception对象和Exception子类对象,也就是说你要抛各种类型的异常都可以,但是前提就是必须是Exception对象和Exception子类对象。

在函数调用链中异常栈展开匹配原则

1. 首先检查throw本身是否在try块内部,如果是再查找匹配的catch语句 。如果有匹配的,则调到catch的地方进行处理。
2. 没有匹配的catch则退出当前函数栈,继续在调用函数的栈中进行查找匹配的catch。
3. 如果到达main函数的栈,依旧没有匹配的,则终止程序。上述这个沿着调用链查找匹配的catch子句的过程称为栈展开。所以实际中我们最后都要加一个catch(...)捕获任意类型的异常,否则当有异常没捕获,程序就会直接终止。
4. 找到匹配的catch子句并处理以后,会继续沿着catch子句后面继续执行。 

异常的重新抛出 

double Division(int a, int b)
{// 当b == 0时抛出异常if (b == 0){throw "Division by zero condition!";}return (double)a / (double)b;
}void Func()
{// 这里可以看到如果发生除0错误抛出异常,另外下面的array没有得到释放。// 所以这里捕获异常后并不处理异常,异常还是交给外面处理,这里捕获了再// 重新抛出去。int* array = new int[10];int len, time;cin >> len >> time;cout << Division(len, time) << endl;cout << "delete []" << array << endl;delete[] array;
}int main()
{try{Func();}catch (const char* errmsg){cout << errmsg << endl;// 记录日志}return 0;
}

对于这份代码,如果Division抛了异常,那么在Func中,就不会执行206行以后的代码了,因为throw了异常就直接跳到main函数中catch的地方了,这个时候就会出现内存泄露,这个就是异常安全的问题。

针对这个问题应该如何解决呢?

1. 智能指针(这里我们先不介绍,在以后的文章会进行介绍)

2. 直接在Func里面捕获这个异常  

这个时候因为在Func里直接就捕获了,所以catch以后的代码就继续正常执行。 

但是这样处理存在一个不好的问题,因为我们之前说希望最外层统一进行处理异常,因为最外层有记录日志,都在最外层统一记录,这个Func中是没有记录日志的或者说在Func里记录日志非常的麻烦,我就是要去最外层记录日志。我早Func中拦截异常,不是要处理异常,而是要正常释放资源。

如何做到把资源释放了,还要让异常在最外面处理呢?

这种方法叫做异常的处理抛出。 捕获了释放了资源再次重新抛。

还有一种更简单的方式:

四、自定义异常体系

实际使用中很多公司都会自定义自己的异常体系进行规范的异常管理,因为一个项目中如果大家随意抛异常,那么外层的调用者基本就没办法玩了,所以实际中都会定义一套继承的规范体系。这样大家抛出的都是继承的派生类对象,捕获一个基类就可以了。(异常一般是在比较大的项目中才会出现的)

基类一般包含这两个成员errmsg与id,比如说:微信发消息,如果是出现权限问题(本来我们是好友,但是对方把你拉黑了),我给你发消息就失败了,就直接报错,给一个提示“你已经不是对方的好友”。本质上,微信针对这个底层就是抛一个异常处理,微信上层就捕获一下,发现拿到的异常是正常的错误,然后就把这个错误显示出来。发消息发不出去还有一种可能是发生了网络错误,比如你手机没流量了或者你在电梯里面,导致你没信号或者信号不好,出现这种情况,并不是一下发不出消息就直接报错“网络错误”,而是第一次发不出去尝试第二次,第二次发不出去尝试第三次...比如产品经理在设计的时候要求网络不行的时候尝试10次,10次都发不出去才会显示网络错误。当发生其他错误就直接把错误展示出来;当发生网络错误,我还要调用这个接口重试几次,我不能因为出现这个异常了就把网络错误的信息直接展示出来。所以针对某种错误我们要进行特殊处理。我们怎么知道它是哪一种错误呢,这个时候,错误的编号就显得尤为重要,所以我不仅要有错误的描述,我还要有错误的编号。

所以errmsg就是一个字符串用来描述到底是什么错误,id也很重要,因为有时候要区分一些错误,针对某种错误进行特殊处理。

eg:模拟一个异常体系

// 服务器开发中通常使用的异常继承体系
class Exception
{
public:Exception(const string& errmsg, int id):_errmsg(errmsg), _id(id){}virtual string what() const{return _errmsg;}protected://基类一般包含这两个成员string _errmsg;int _id;};
class SqlException : public Exception
{
public:SqlException(const string& errmsg, int id, const string& sql):Exception(errmsg, id), _sql(sql){}virtual string what() const{string str = "HttpServerException:";str += _errmsg;str += "->";str += _sql;return str;}
private:const string _sql;
};class CacheException : public Exception
{
public:CacheException(const string& errmsg, int id):Exception(errmsg, id){}virtual string what() const{string str = "CacheException:";str += _errmsg;return str;}
};
class HttpServerException : public Exception
{
public:HttpServerException(const string& errmsg, int id, const string& type):Exception(errmsg, id), _type(type){}virtual string what() const{string str = "HttpServerException:";str += _type;str += ":";str += _errmsg;return str;}private:const string _type;
};void SQLMgr()
{srand(time(0));if (rand() % 7 == 0){throw SqlException("权限不足", 100, "select * from name = '张三'");}throw "xxxxx"; //偷偷抛一个不知道的异常
}void CacheMgr()
{srand(time(0));if (rand() % 5 == 0){throw CacheException("权限不足", 100);}else if (rand() % 6 == 0){throw CacheException("数据不存在", 101);}SQLMgr();
}void HttpServer()
{srand(time(0));if (rand() % 3 == 0){throw HttpServerException("请求资源不存在",100,"get");}else if (rand() % 4 == 0){throw HttpServerException("权限不足", 101, "post");}CacheMgr();
}void ServerStart()
{while (1){this_thread::sleep_for(chrono::seconds(1));try{HttpServer();}catch (const Exception& e) // 这里捕获父类对象就可以{//子类重写了父类的虚函数,而且是父类的引用进行调用//符合多态的条件,所以抛的是谁的,调用的就是谁的cout << e.what() << endl;}catch (...){cout << "Unkown Exception" << endl;}}
}int main()
{ServerStart();return 0;
}

C++标准库的异常体系

C++ 提供了一系列标准的异常,定义在库中,我们可以在程序中使用这些标准的异常。它们是以父子类层次结构组织起来的,如下所示:

说明:实际中我们可以可以去继承exception类实现自己的异常类。但是实际中很多公司像上面一样自己定义一套异常继承体系。因为C++标准库设计的不够好用。

eg:标准库的异常体系

五、异常安全

异常虽然用起来有些好处,但是异常用起来也会导致一些问题,这些问题就是异常安全的问题。

  • 构造函数完成对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象不完整或没有完全初始化。
  • 析构函数主要完成资源的清理,最好不要在析构函数内抛出异常,否则可能导致资源泄漏(内存泄漏、句柄未关闭等)。
  • C++中异常经常会导致资源泄漏的问题,比如在new和delete中抛出了异常,导致内存泄漏,在lock和unlock之间抛出了异常导致死锁,C++经常使用RAII来解决以上问题。

 eg:比如一下这些代码。

没有异常我们只需要观察中间有没有return,只要没有return这种影响执行流的,代码一定会顺序执行,只要顺序执行,lock后一定会unlock,malloc,new以后一定会free,delete...但是有了异常就会影响执行流,所以就会出现死锁,内存泄露...这种异常安全的问题。

在比如中间调用了一个func(),要不要去捕获异常呢?如果要捕获,又要去补谁呢?所以就有了异常规范。

六、异常规范

  • 1. 异常规格说明的目的是为了让函数使用者知道该函数可能抛出的异常有哪些。 可以在函数的后面接throw(类型),列出这个函数可能抛掷的所有异常类型。
  • 2. 函数的后面接throw(),表示函数不抛异常。
  • 3. 若无异常接口声明,则此函数可以抛掷任何类型的异常。 
// 这里表示这个函数会抛出A/B/C/D中的某种类型的异常
void fun() 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();

如果大家都遵循这个异常规范,那么实际中,就可以明确知道该函数是否抛异常,也因此可以知道某处地方是没有问题的,异常安全也可以更好更轻松的去解决。但是现实终究是现实,就比如虽然有红绿灯,但是总有人会不遵守交通规则。加之因为C++兼容C语言的缘故,C++也不能强制实行异常规范这个事情。因此异常规范在实际中很难执行。

eg:库中的异常

C++11增加了一个关键字noexcept,只要你不抛异常就加一个noexcept,当然C++11同样兼容C++98。 

七、异常的优缺点

C++异常的优点:

  • 1. 异常对象定义好了,相比错误码的方式可以清晰准确的展示出错误的各种信息,甚至可以包含堆栈调用的信息,这样可以帮助更好的定位程序的bug。因为异常出错都是记录日志,你要在公司排除线上的错误(指的是上线的程序release版本,不能调试)出错了就只能依靠日志去分析。
  • 2. 返回错误码的传统方式有个很大的问题就是,在函数调用链中,深层的函数返回了错误,那么我们得层层返回错误,最外层才能拿到错误,具体看下面的详细解释。
// 1.下面这段伪代码我们可以看到ConnnectSql中出错了,先返回给ServerStart,ServerStart再
返回给main函数,main函数再针对问题处理具体的错误。
// 2.如果是异常体系,不管是ConnnectSql还是ServerStart及调用函数出错,都不用检查,因为抛
出的异常异常会直接跳到main函数中catch捕获的地方,main函数直接处理错误。
int ConnnectSql()
{// 用户名密码错误if (...)return 1;// 权限不足if (...)return 2;
}
int ServerStart() {if (int ret = ConnnectSql() < 0)return ret;int fd = socket() if(fd < 0)return errno;
}
int main()
{if(ServerStart()<0)...return 0;
}
  • 3. 很多的第三方库都包含异常,比如boost、gtest、gmock等等常用的库,那么我们使用它们也需要使用异常。
  • 4. 很多测试框架都使用异常,这样能更好的使用单元测试等进行白盒的测试。
  • 5. 部分函数使用异常更好处理,比如构造函数没有返回值,不方便使用错误码方式处理。比如:T& operator[](size_t pos)这样的函数,如果pos越界了只能使用异常或者终止程序处理,没办法通过返回值表示错误(因为我本身就要返回一个值,编译器是无法知道你返回的值是正常的值还是出现越界了,因此返回错误码对这样的函数很难进行处理)。
//如果非要用返回错误码去处理,比如at(但是这些非常的不好)
int at(size_t pos, T& x)
{}
//成功返回0,失败返回-1,如果成功了,我拿的值在x里面,通过输出型参数去拿着值

C++异常的缺点:

  • 1. 异常会导致程序的执行流乱跳,并且非常的混乱,并且是运行时出错抛异常就会乱跳。这会导致我们跟踪调试时以及分析程序时,比较困难。C语言中跳执行流的是goto,但是建议也是少使用goto
  • 2. 异常会有一些性能的开销。当然在现代硬件速度很快的情况下,这个影响基本忽略不计。
  • 3. C++没有垃圾回收机制,资源需要自己管理。有了异常非常容易导致内存泄漏、死锁等异常安全问题。这个需要使用RAII(比如:智能指针,lockguard)来处理资源的管理问题。学习成本较高。
  • 4. C++标准库的异常体系定义得不好,导致大家各自定义各自的异常体系,非常的混乱。
  • 5. 异常尽量规范使用,否则后果不堪设想,随意抛异常,外层捕获的用户苦不堪言。所以异常规范有两点:一、抛出异常类型都继承自一个基类。二、函数是否抛异常、抛什么异常,都使用 func()throw();的方式规范化。

总结:异常总体而言,利大于弊,所以工程中我们还是鼓励使用异常的。另外OO的语言(面向对象语言)基本都是用异常处理错误,这也可以看出这是大势所趋。

相关内容

热门资讯

【NI Multisim 14...   目录 序言 一、工具栏 🍊1.“标准”工具栏 🍊 2.视图工具...
银河麒麟V10SP1高级服务器... 银河麒麟高级服务器操作系统简介: 银河麒麟高级服务器操作系统V10是针对企业级关键业务...
不能访问光猫的的管理页面 光猫是现代家庭宽带网络的重要组成部分,它可以提供高速稳定的网络连接。但是,有时候我们会遇到不能访问光...
AWSECS:访问外部网络时出... 如果您在AWS ECS中部署了应用程序,并且该应用程序需要访问外部网络,但是无法正常访问,可能是因为...
Android|无法访问或保存... 这个问题可能是由于权限设置不正确导致的。您需要在应用程序清单文件中添加以下代码来请求适当的权限:此外...
北信源内网安全管理卸载 北信源内网安全管理是一款网络安全管理软件,主要用于保护内网安全。在日常使用过程中,卸载该软件是一种常...
AWSElasticBeans... 在Dockerfile中手动配置nginx反向代理。例如,在Dockerfile中添加以下代码:FR...
AsusVivobook无法开... 首先,我们可以尝试重置BIOS(Basic Input/Output System)来解决这个问题。...
ASM贪吃蛇游戏-解决错误的问... 要解决ASM贪吃蛇游戏中的错误问题,你可以按照以下步骤进行:首先,确定错误的具体表现和问题所在。在贪...
月入8000+的steam搬砖... 大家好,我是阿阳 今天要给大家介绍的是 steam 游戏搬砖项目,目前...