前言
在现实项目中,不仅仅是要以语法为基础去实现高效的代码逻辑,大多数情况下还需要留好后门,方便测试和查漏补缺。
比如近年来,互联网大厂故障时间频发:
23年底,滴滴平台崩溃,出现无法打车、无法接单,天价订单的问题,系统基本经历一天才恢复差不多;
支付宝作为一个金融软件,也频发类似于错误折扣、反复扣款、花呗账单还款后未消除等问题。
为了防止bug,也为了能够快速找到并修复bug,面向对象的语言经常会设计异常机制,交由程序员控制,如果出现某种异常,需要代码做如何处理。
一、异常的概念
异常处理机制允许程序中独立开发的部分运行时出现错误即可进行记录并做出相应处理,异常可以将问题的检测与问题的解决过程分开,即将异常抛出并交由部分程序解决。
异常的机制主要在C语言处理错误的机制上进行了优化,C语言如果出现错误,它会让程序return相应的错误码,但是这么处理有一大坏处就是十分麻烦,你还必须查错误码表才能知道到底是什么错误,异常就可以让程序员提前做好防护,易于查找与修复。
二、处理异常的过程
单单听概念其实不知道异常是干嘛的,大致上就知道,噢,异常比C语言那种return错误码好一点,至于咋好不知道。
处理异常的关键字有三个try-throw-catch。
1.大致流程
一般情景是这样的:
cpp
double Divide(int a, int b)
{
// 当b == 0时抛出异常
if (b == 0)
{
string s("Divide by zero condition!");
throw s;
}
else
{
return ((double)a / (double)b);
}
}
如果除数为0,绝对是不允许的,所以一旦除数为0,按照面向对象的语言特性,就应该抛出异常,抛出的异常是一个对象,以便根据对象的类型作以特定的处理。
一般将可能出现异常的语句放在try语句模块下,如果抛出异常,在外部有多种类型的catch语句来等待捕获异常。因为考虑到程序里不止有一个地方会抛出异常,所以类型就像钥匙,根据异常的类型去匹配相应的catch语句。
例如:
cpp
double Divide(int a, int b)
{
try
{
// 当b == 0时抛出异常
if (b == 0)
{
string s("Divide by zero condition!");
throw s;
}
else
{
return ((double)a / (double)b);
}
}
catch (const int& x)
{
cout << x << endl;
}
}
void Func()
{
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl;
}
int main()
{
try
{
Func();
}
catch (const string& errmsg)
{
cout << errmsg << endl;
}
return 0;
}
捕获异常的大致流程如下:
一旦抛出异常,会检查throw语句当前局部域是否在try语句中,如果在,将会去一个个对照catch语句,这一点有点像if-else分支,但是这里看的不是bool值,而是看的类型,根据抛出异常对象的类型去查找。
在上面的例子中,如果除数输入0,则最后将会throw一个string对象,经检查,throw语句在try-catch模块中,所以回去找对应的catch模块执行,但是很明显,Divide内部的try-catch模块,catch语句只捕获int类型的异常,所以并不能匹配。
难道我们抛出的异常不能被处理吗?
如果局部域中throw语句并没有在try-catch模块中,或者抛出的异常所在的try-catch模块中并没有匹配的catch语句,那么将会顺着调用链往上查找。
什么叫调用链呢?

以所举例子画图,可知,C++的程序是由main函数开始执行的,main函数内调用了Func函数,Func函数内还调用了Divide函数,这就是测试代码的调用链。
那么根据规则,将会顺着往上找try-catch模块:
可知Func内并未对Divide函数进行捕获异常;
main函数对于Func函数的调用进行了try-catch捕获异常,则相当于Divide在main函数的try-catch语句,并且可以看到,最终被捕获。

2.总结规则
大致体会了怎么抛出异常怎么捕获异常有以下几点规则:
- 我们提前对异常进行监管,程序一旦出现问题,通过throw抛出一个异常,该对象的类型和调用链决定了它将会由哪个catch语句捕获
- 一旦抛出异常异常,程序对当前函数的执行立即停止,接下来所做的就是拿着这个异常对象去匹配catch语句,则有:throw语句后的语句不再执行,程序会顺着调用链寻找catch语句,那么将会出现:调用链的函数可能提前结束,那么一旦处理异常只要没有找到catch语句的函数,直接被回收栈空间,最后我们看到的效果就是由throw语句直接匹配到对应的catch语句中
- 容易知道,catch模块是对异常的记录和处理,那么被选中的处理代码是调用链中与异常对象类型相同且最近的
- 抛出的异常对象,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个局部对象,所以会生成一个拷贝对象,这个拷贝的对象会在catch子句后销毁
- 调用链也是有限的,如果顺着调用链查找到main函数都没有找到对应的catch语句,那么说明这种异常情况并没有被捕捉,程序会调用标准库的terminate函数终止程序
- catch语句之后的语句正常执行
以上规则,有的已经看过,但是有些并没有观察到。
throw的跳跃,不执行throw之后语句的效果

我一直按的都是F11,这里直接就跳到最匹配的catch语句。
如果找到main函数都找不到

VS下直接报警告窗口了就。
如果Divide这个最近的try-catch就可以捕获
cpp
double Divide(int a, int b)
{
try
{
// 当b == 0时抛出异常
if (b == 0)
{
string s("Divide by zero condition!");
throw s;
}
else
{
return ((double)a / (double)b);
}
}
catch (const string& errmsg)
{
cout << errmsg << endl;
}
}

而且在此基础上还可以验证,catch以后的语句将会正常执行
cpp
double Divide(int a, int b)
{
try
{
// 当b == 0时抛出异常
if (b == 0)
{
string s("Divide by zero condition!");
throw s;
}
else
{
return ((double)a / (double)b);
}
}
catch (const string& errmsg)
{
cout << errmsg << endl;
}
cout << "double Divide(int a, int b):继续执行" << endl;
}
void Func()
{
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl;
cout << "void Func():继续执行" << endl;
}
int main()
{
try
{
Func();
}
catch (const string& errmsg)
{
cout << errmsg << endl;
}
//Func();
return 0;
}

截图很明显,catch语句以后的就还正常执行。
这也就又引出一个问题:Divide函数不是所有的路径都有返回值

如果catch了,那下面的语句正常执行,但是throw直接跳到这里,岂不是刚好错过return语句,而在Func函数中还有:

所以在上面的结果中可以看到:

因此如果内部捕获,还必须搞个返回值:
cpp
double Divide(int a, int b)
{
try
{
// 当b == 0时抛出异常
if (b == 0)
{
string s("Divide by zero condition!");
throw s;
}
else
{
return ((double)a / (double)b);
}
}
catch (const string& errmsg)
{
cout << errmsg << endl;
}
cout << "double Divide(int a, int b):继续执行" << endl;
return 0;
}

三、查找匹配的处理代码
在上面对异常的描述中,其中非常重要的一点就是抛出对象和catch的类型必须完全匹配才进入处理,如果多个匹配那就是调用链里最近的。
但是有些情况下并没有这么严格,类型完全匹配实际上达不到:
- 允许从非常量向常量的类型转换,也就是权限缩小
这一点在上面我们的例子中也有体现,string对象被const string&捕获,这一点类似于函数调用。
- 允许数组转换成指向数组元素类型的指针,函数被转换成指向函数的指针
这一点了解就行了,后者不说,你就说允许数组转换成指针,前提条件是返回数组,你想想啥场景没事返回个数组。
- 允许从派生类向基类类型的转换
这一点是最最实用的一点,在实际应用中大多以这种方式实现:
cpp
#include<thread>
// 一般大型项目程序才会使用异常,下面我们模拟设计一个服务的几个模块
// 每个模块的继承都是Exception的派生类,每个模块可以添加自己的数据
// 最后捕获时,我们捕获基类就可以
class Exception
{
public:
Exception(const string& errmsg, int id)
:_errmsg(errmsg)
, _id(id)
{}
virtual string what() const
{
return _errmsg;
}
int getid() const
{
return _id;
}
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 = "SqlException:";
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 HttpException : public Exception
{
public:
HttpException(const string& errmsg, int id, const string& type)
:Exception(errmsg, id)
, _type(type)
{}
virtual string what() const
{
string str = "HttpException:";
str += _type;
str += ":";
str += _errmsg;
return str;
}
private:
const string _type;
};
void SQLMgr()
{
if (rand() % 7 == 0)
{
throw SqlException("权限不足", 100, "select * from name = '张三'");
}
else
{
cout << "SQLMgr 调用成功" << endl;
}
}
void CacheMgr()
{
if (rand() % 5 == 0)
{
throw CacheException("权限不足", 100);
}
else if (rand() % 6 == 0)
{
throw CacheException("数据不存在", 101);
}
else
{
cout << "CacheMgr 调用成功" << endl;
}
SQLMgr();
}
void HttpServer()
{
if (rand() % 3 == 0)
{
throw HttpException("请求资源不存在", 100, "get");
}
else if (rand() % 4 == 0)
{
throw HttpException("权限不足", 101, "post");
}
else
{
cout << "HttpServer调用成功" << endl;
}
CacheMgr();
}
int main()
{
srand(time(0));
while (1)
{
this_thread::sleep_for(chrono::seconds(1));//休眠
try
{
HttpServer();
}
catch (const Exception& e) // 这里捕获基类,基类对象和派生类对象都可以被捕获
{
cout << e.what() << endl; // 多态
}
}
return 0;
}
大致介绍一下这段测试代码的几个部分以及思路:
cpp
class Exception
{
public:
Exception(const string& errmsg, int id)
:_errmsg(errmsg)
, _id(id)
{}
virtual string what() const
{
return _errmsg;
}
int getid() const
{
return _id;
}
protected:
string _errmsg;
int _id;
};
大型项目中,往往都是用库里的或者自己写一个异常类,异常类里记录错误信息和错误码。
有的人可能说,还要错误码干啥呢,不用错误码就是为了明确。具体下面的例子中会涉及,场景下看更容易理解。
这一点是为了下面的继承做准备:
cpp
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 = "SqlException:";
str += _errmsg;
str += "->";
str += _sql;
return str;
}
private:
const string _sql;
};
下面都是模拟实际项目的几个部门,数据库、缓存、网络协议等等。
它们都是继承自Exception类,核心用法就是某部门抛异常时去构造这个派生类的对象,抛出的是这个派生类对象。
cpp
void SQLMgr()
{
if (rand() % 7 == 0)
{
throw SqlException("权限不足", 100, "select * from name = '张三'");
}
else
{
cout << "SQLMgr 调用成功" << endl;
}
}
还有就是模拟了项目中抛异常的情景。
cpp
int main()
{
srand(time(0));
while (1)
{
this_thread::sleep_for(chrono::seconds(1));//休眠
try
{
HttpServer();
}
catch (const Exception& e) // 这里捕获基类,基类对象和派生类对象都可以被捕获
{
cout << e.what() << endl; // 多态
}
}
return 0;
}
正常项目也是在一个类似于循环的过程中,用户不退出,程序就一直进行,当然,这里不搞那么麻烦,重点是体现抛异常。
核心机制就是运用基类对象捕获所有的异常,并且指向谁调用时(多态),因为继承体系中存在虚函数重写、基类引用/指针的调用,就可以实现多态。

大致就是一直运行下去,每个部分抛出每个部分的异常,这样的一个更大的好处就是在每个派生类的what函数都可以加前缀等限制符,非常明确哪部分哪个函数抛出的异常,查找效率更快,如果正常只有string,你是不是还得对着一点一点找,这么搞调用谁去找谁。
另外就是还有一个比较重要的点:
假设现在有个愣头青,自己搞了个异常类,并没有继承基类的异常,例如:
cpp
class CacheException
{
public:
CacheException(const string& errmsg, int id)
//:Exception(errmsg, id)
{}
virtual string what() const
{
string str = "CacheException:";
//str += _errmsg;
return str;
}
};

main函数不变,由于不是派生类,则无法捕获,此时运行:

必然会有异常捕获不到的情况,异常一旦捕获不到,hh,直接调用终止函数,假如这真的是一个大型项目,就比如滴滴、支付宝,真有了这样的错误,那么软件根本不能用了,这件事会有多可怕。
因此还有一个语法:

如果在catch语句中输入...,意味着这个catch语句可以捕获任意类型异常,所以一般都放到最后,保底,至少保证有些异常并没有捕获到,那就走这个,这样至少程序不会挂,类似于if-elseif-else里的else,啥都匹配不到,就走else。

作用就是保底,这样等于断绝了到main函数异常都没有被捕获而导致程序终止的问题。
四、异常重新抛出
异常重新抛出大致是什么情况呢?
正常try模块抛出异常会找到对应catch语句,在catch语句中会进行处理,但有时候catch处理不到位或其他的一些原因,就会再次抛出异常。
叽里咕噜说这么多,看个模拟场景:
cpp
void _SeedMsg(const string& s)
{
if (rand() % 2 == 0)
{
throw HttpException("网络不稳定,发送失败", 102, "put");
}
else if (rand() % 7 == 0)
{
throw HttpException("你已经不是对象的好友,发送失败", 103, "put");
}
else
{
cout << "发送成功" << endl;
}
}
void SendMsg(const string& s)
{
// 发送消息失败,则再重试3次
for (size_t i = 0; i < 4; i++)
{
try
{
_SeedMsg(s);
break;
}
catch (const Exception& e)
{
// 捕获异常,if中是102
// 捕获异常,else中不是102号错误,则将异常重新抛出
if (e.getid() == 102)
{
// 重试三次以后否失败了,则说明网络太差了,重新抛出异常
if (i == 3)
throw;
cout << "开始第" << i + 1 << "重试" << endl;
}
else
{
throw;
}
}
}
}
int main()
{
srand(time(0));
string str;
while (cin>>str)
{
try
{
SendMsg(str);
}
catch (const Exception& e)
{
cout << e.what() << endl << endl;
}
catch (...)
{
cout << "Unkown Exception" << endl;
}
}
return 0;
}
依旧介绍代码思路:
大致模拟场景是啥呢,发送信息:

发送信息可能一下就发送成功了;
也可能是信号不好,等信号好就可以重新发送;
也可能是对方给你好友删了,那一旦发送,肯定发不过去。

发送信息的循环执行4次,第一次当成正常发送,后面三次当成尝试再次发送,这个场景类似于啥吧,你用vx,信号不好发送信息,就调用这个函数,肯定会调用一次,但是有可能发送不出去啊,它就该转圈圈了,这个过程就是在尝试第二三四次发送信息,当然,这里只是模拟,具体实际场景要求尝试几次不是我们监管的范围。
内部_seedmeg函数也是模拟实现,正常情况下肯定有函数专门判断到底能发送还是网络不好,还是没有对方好友,这里简单模拟一下。
如果发送成功,那么没有抛异常,发送信息函数结束。
如果被删好友进入catch的else里(这里就体现错误码的作用了,对不同异常信息分类处理)就直接throw,这里相当于throw e,把catch捕获的异常重新throw出去,不在try语句中throw的异常,这层try-catch失效,就会直接沿着调用链往上查找:

从而记录异常信息,能走到这里肯定是,被删好友了,被删好友的话,输出:

如果网络异常,直到第四次发送都失败了才会将异常重新抛出,到外层打印:

测试:

字符串都是随便输的,因为毕竟只是随机处理,重要是展示什么情况下需要将异常重新抛出,怎么抛出,抛出怎么再用。
五、异常安全问题
直接举例:
cpp
double Divide(int a, int b)
{
try
{
// 当b == 0时抛出异常
if (b == 0)
{
string s("Divide by zero condition!");
throw s;
}
else
{
return ((double)a / (double)b);
}
}
catch (const int& x)
{
cout << x << endl;
}
cout << "double Divide(int a, int b):继续执行" << endl;
return 0;
}
void Func()
{
int* arr = new int[10];
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl;
cout << "void Func():继续执行" << endl;
delete[] arr;
}
int main()
{
try
{
Func();
}
catch (const string& errmsg)
{
cout << errmsg << endl;
}
return 0;
}
这样的代码有什么问题呢?

在上面我们知道,Divide这个函数是有可能抛异常的,一旦抛异常,异常后面的语句将不再执行,那么你new的内存就得不到释放,因为delete在异常后面。
面对这个问题,可以利用:
cpp
void Func()
{
int* arr = new int[10];
try
{
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl;
cout << "void Func():继续执行" << endl;
}
catch (...)
{
delete[] arr;
}
delete[] arr;
}
既然你抛出异常会找catch,找不到catch前异常后所有代码都不走,那我就让你捕获到,并且用...强制捕获,处理完内存泄露,继续放你顺着调用链寻找合适的catch语句。
对于异常的终止跳转逻辑导致的内存泄露问题,除了上述解决方式,智能指针还有RAII方式解决,那种方法更好。
除此之外,还需要注意析构函数,因为如果析构函数需要释放资源,你在析构中加异常就需要很谨慎,如果不谨慎,很可能出现10个资源你只析构5个,因为异常后5个就不管了。
六、异常规范
- 对于用户和编译器而言,预先知道某个程序会不会抛出异常大有裨益,知道某个函数是否会抛出异常有助于简化用函数的代码
举个不太恰当的例子,你回家肯定就不会跟出门在外坐车了、吃饭了时候那么强警戒心,如果一直很强警戒心,人不都得憋死自己;异常也是这样,如果知道哪里会抛异常哪里不抛异常,可以提高效率,不抛异常的地方用起来也不会畏手畏脚的。
- C++98中函数参数列表的后面接throw(),表示函数不抛异常,函数参数列表的后面接throw(类型1, 类型2...)表示可能会抛出多种类型的异常,可能会抛出的类型用逗号分割
cpp
//这里表示这个函数只会抛出bad_alloc的异常
void* operator new (std::size_t size) throw (std::bad_alloc);
//这里表示这个函数不会抛出异常
void* operator delete (std::size_t size, void* ptr) throw();
- C++11中进行了简化,函数参数列表后面加noexcept表示不会抛出异常,啥都不加表示可能会抛出异常
这里举例就不整代码了,直接看库里面噢:

push_back没有noexcept意味着还是会抛出异常,具体原因可能为:
push_back可能底层调用的就是insert函数,insert函数底层如果写错会有越界问题;
insert函数底层可能需要扩容,扩容就有可能抛异常。
但是:

不管是传统意义上的顺序表,还是用三个指针管理的顺序表,其实size就是个计算,甚至我们学的普通的顺序表根本就是把底层的size拿出来return,这种情况还抛啥异常,闲的吗。
- 编译器并不会在编译时检查noexcept,也就是说如果⼀个函数用noexcept修饰了,但是同时又包含了throw语句或者调用的函数可能会抛出异常,编译器还是会顺利编译通过的(有些编译器可能会报个警告)。但是⼀个声明了noexcept的函数抛出了异常,程序会调用terminate终止程序
比如:

这种内层没有throw,但是调用的函数有,编译没事,但是运行就炸了:


还有:

眼睁睁看着里面throw的有劲,结果你给我干出来noexcept了,VS报的是警告,而且程序还会炸:


- noexcept(expression)还可以作为一个运算符去检测一个表达式是否会抛出异常,可能会则返回false,不会就返回true
当然,noexcept这玩意这么用,其实是运行时:
cpp
int main()
{
string str = "xxxxx";
int i = 0;
cout << noexcept(Divide(1, 2)) << endl;
cout << noexcept(Divide(1, 0)) << endl;
cout << noexcept(++i) << endl;
cout << noexcept(str += 'x') << endl;
cout << noexcept(str.size()) << endl;
return 0;
}

函数可能返回异常就是false;
常数不是函数,一定不抛异常;
加noexcept修饰的一定不抛异常。
七、标准库的异常
标准库专门搞了一套异常的类:
https://legacy.cplusplus.com/reference/exception/

类型不少,主要是还有一大堆派生类:

这里就不再截图了,对于库里面的异常,我们用起来就是基类对象的引用/指针调用即可,也就是这里的exception对象引用接收。

其what函数就是我们上面展示的那种样子,差不多,直接e.what()cout就可以打印。
八、异常的优缺点
现在就只学习语法的层面,我的感触就是异常确实比原来的报错好用,因为最明显的就是抛出的对象可以存储好多类型,这也就意味着存储的异常的信息就更加丰富,更方便使用者。
但是那种跳跃机制的危险也伴随着存在,虽然他能够不再验证异常继续执行,这同时意味着必须中断运行,可能就会有内存泄漏问题。再说,跳跃机制也不是直接跳跃,而是顺着调用链查找,这就带来额外的时间开销,毕竟其实是独立于书写代码外的对比。
只要涉及到交由程序员管理,涉及到影响内存,就注定是把双刃剑,用不好程序可就等着挂吧。
当然,面向对象语言都运用,说明它还是有可取之处的,运用的好,程序的健壮性和清晰度将会增加。