CD75.【C++ Dev】异常
目录
1.C语言处理错误的方式
2.C++的异常
演示异常
单步演示
异常的抛出和匹配原则
在函数调用链中异常栈展开匹配原则
服务器开发中通常使用的异常继承体系
C++标准库的异常体系
看看exception的成员函数
VS实现的exception
libstdc++的exception
异常规范
函数不抛异常的两种写法
3.总结异常的缺点
执行流乱跳
解决方法:异常的重新抛出
使用嵌套的try+catch释放内存空间
使用catch(...)
性能的开销
没有垃圾回收机制
标准库的异常体系定义
4.总结异常的优点
清晰准确的展示出错误的各种信息
部分函数使用异常更好处理
5.抛异常的建议
1.C语言处理错误的方式
之前在OS23.【Linux】进程终止文章提到过进程终止的几种可能:
代码运行完毕,结果正确
代码运行完毕,结果不正确
代码异常终止
如果进程正常运行完毕,会返回对应的退出码,退出码的含义由开发者自行制定
假设一个用C语言写的大型项目,针对于不同的情况,项目运行结束后会返回不同的退出码,如果某个退出码解读为运行结果不正确,那么查错的过程为:
1.查退出码表,明确该退出码的含义
2.可能出现层层调用的情况,需要向上一直查到最开始出错的位置
为什么要向上一直查?
答: 例如当某个函数出现问题时,返回对应的值给其他函数,其他函数层层接力,一直传递,最后传递到main函数,那么查错时就要倒着查,即向上一直查
这样做会非常麻烦,C++通过抛异常的方式解决问题
2.C++的异常
演示异常
#include <iostream>
using namespace std;
double div_func(int a, int b)
{// 当b == 0时抛出异常if (b == 0)throw "Division by zero condition!";elsereturn ((double)a / (double)b);
}int main()
{try {int a, b;cin >> a>> b;cout << div_func(a, b) << endl;}catch (const char* errmsg){cout << errmsg << endl;}return 0;
}
运行结果:
注: 被除数÷除数=商……余数
除数不为0,不会抛异常:
除数为0,会抛异常,打印异常信息:
单步演示
catch完后,会正常执行下面的代码
输出信息提示异常:
结论:本代码只有函数抛异常才会打印异常信息,即执行catch部分
可以看到,C++不用像C语言那样层层返回错误信息,C++抛异常可以一步到位
异常的抛出和匹配原则
1.异常是通过抛出对象而引发的,该对象的类型决定了应该激活哪个catch的处理代码
throw关键字用于抛异常,catch关键字用于捕获异常
运行结果:异常无法被捕获
2.抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个临时对象,所以会生成一个拷贝对象,这个拷贝的临时对象会在被catch以后销毁(这里的处理类似于函数的传值返回)
修改div_func函数:
double div_func(int a, int b)
{// 当b == 0时抛出异常if (b == 0){string tmp("Division by zero condition!");throw tmp;}elsereturn ((double)a / (double)b);
}
上方代码的tmp是临时变量,执行完div_func后函数后,tmp会被析构,throw的是tmp的拷贝
可以通过反汇编验证:
3.throw会改变执行流
double div_func(int a, int b)
{// 当b == 0时抛出异常if (b == 0){throw "Division by zero condition!";throw "xxx";//无效}elsereturn ((double)a / (double)b);
}
执行到throw "Division by zero condition!"时会改变执行流,将不会再执行throw "xxx"
改变执行流既是异常的优点(不用像C语言那样层层通过传递),也是缺点(执行流乱跳)
4.catch(...)可以捕获任意类型的异常,问题是不知道异常错误是什么
实际在开发的过程中,如果异常没有被catch(具体类型)捕获,那么进程不能直接终止,会影响其他服务的执行和用户的体验感,可以用catch(...)捕获任意类型的异常,例如以下代码:
#include <iostream>
using namespace std;
double div_func(int a, int b)
{// 当b == 0时抛出异常if (b == 0){throw 1;//不会被catch (const char* errmsg)捕获}elsereturn ((double)a / (double)b);
}int main()
{try{int a, b;a = 3;b = 0;cout << div_func(a, b) << endl;}catch (const char* errmsg){cout << errmsg << endl;}catch (...){cout << "Unknown Exception!" << endl;}return 0;
}
运行结果:
一般catch(...)放到最后,如果放前面会报错
catch (...)
{cout << "Unknown Exception!" << endl;
}catch (const char* errmsg)
{cout << errmsg << endl;
}
会导致const char*类型的异常信息无法被catch (const char* errmsg)捕获,语法上是不允许的
结论:catch(...)用于捕获各种类型的异常,防止无法捕获不规范的异常,一般catch(...)放到最后,为"最后一道防线"
5.被选中的处理代码是调用链中与该对象类型匹配且离抛出异常位置最近的那一个
6.实际中抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,可以抛出的派生类对象然后使用基类捕获
文章后面会详细讲解5和6
在函数调用链中异常栈展开匹配原则
1. 首先检查throw本身是否在try块内部,如果是再查找匹配的catch语句如果有匹配的,则
调到catch的地方进行处理
2. 没有匹配的catch则退出当前函数栈,继续在调用函数的栈中进行查找匹配的catch
3. 如果到达main函数的栈,依旧没有匹配的,则终止进程
上述这个沿着调用链查找匹配的catch子句的过程称为栈展开。所以实际中我们最后都要加一个catch(...)捕获任意类型的异常,否则当有异常没捕获,进程就会直接终止
4. 找到匹配的catch子句并处理以后,会继续沿着catch子句后面继续执行
验证第1点:
try
{throw "go to catch";int a, b;a = 3;b = 0;cout << div_func(a, b) << endl;
}
catch (const char* errmsg)
{cout << errmsg << endl;
}
try块中的throw "go to catch"以下的部分不会被执行
运行结果:
验证第2点:
#include <iostream>
using namespace std;
void func2()
{try{throw 1//不会被catch (float)捕获,会继续上抛}catch (float){cout << "catch float number"<<endl;}
}
void func1()
{try{func2();}catch (int){cout << "catch int number" << endl;}
}int main()
{try{func1();}catch (...){cout << "Unknown Exception!" << endl;}return 0;
}
如果当前的函数中没有匹配的异常捕获的话,那么异常就会继续往上抛
第3点前面验证过了,这里不在赘述
验证第4点:
#include <iostream>
using namespace std;
int main()
{try{throw 1;}catch (...){cout << "Unknown Exception!" << endl;}cout << "The code after catch (...) is executed" << endl;return 0;
}
运行结果:
服务器开发中通常使用的异常继承体系
下面解释本文前面提到的"实际中抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,可以抛出的子类对象然后使用父类捕获"
实际开发过程中,不同模块会因不同情况出不同问题,而且不仅仅有基本一次信息,而且会"夹带私货"
那么可以定义一个父类来存储基本的异常信息
基本的异常信息通常至少包含具体原因和异常id(应对不同的id处理策略不一样,例如id= 1表示没有权限,id= 2表示服务器故障,id=3表示网络错误,需要重试)
"夹带私货"即需要附加一些异常信息,通过继承来实现
如下:
class Exception//区分C++自带的exception
{
public:Exception(const string& errmsg, int id):_errmsg(errmsg), _id(id){}virtual string what() const{return _errmsg;}
protected:string _errmsg;//具体原因int _id;//异常的ID
};
Exception充当父类,子类可以继承父类,
例如设计这几个子类:
1.数据库异常子类
可以往错误信息里面添加执行错误的数据库语句
class Data_Base_Exception : public Exception
{
public:Data_Base_Exception(const string& errmsg, int id, const string& sql_code):Exception(errmsg, id), _sql_code(sql_code){}virtual string what() const{string str = "Data_Base_Exception:";str += _errmsg;str += "->";str += _sql_code;return str;}
private:const string _sql_code;
};
2.缓存异常子类
class Cache_Exception : public Exception
{
public:Cache_Exception(const string& errmsg, int id):Exception(errmsg, id){}virtual string what() const{string str = "CacheException:";str += _errmsg;return str;}
};
3.网络异常子类
可以往错误信息里面添加网络异常的类型
class Network_Exception : public Exception
{
public:Network_Exception(const string& errmsg, int id,int type):Exception(errmsg, id),_type(type){}virtual string what() const{string str = "Network_Exception:";str += _errmsg;str += " ";str += to_string(_type);return str;}
protected:int _type;
};
接下来抛出的子类对象然后使用父类捕获,即使用切片语法,再重写what函数,就会通过多态的方式调用到子类的what函数,从而打印子类的内容
int main()
{srand((unsigned int)time(nullptr));//均匀生成随机数std::uniform_int_distribution<unsigned long long> distribution(0, 2);std::default_random_engine generator;while (1){Sleep(1000);try{int val = distribution(generator);if (val == 0)throw Cache_Exception("Cache_Exception",0);if (val == 1)throw Data_Base_Exception("Data_Base_Exception", 1, "code");if (val==2)throw Network_Exception("Network_Exception", 2, 6);}catch (const Exception& e){cout << e.what() << endl;}}return 0;
}
what()写成虚函数的原因:让子类可以重写,如果某个类没有继承Exception类,会显示未知异常
运行结果:
C++标准库的异常体系
C++ 提供了一系列标准的异常,定义在<exception>头文件中,具体参见https://legacy.cplusplus.com/reference/exception/exception/网站
之前在CD31.【C++ Dev】类和对象(21) 内存管理(中)文章提到过new如果申请内存失败就会抛异常:
new会调用operator new,而operator new又会调用malloc,以下是VS2010上的代码,需要转到反汇编后单步调试才能看到
void *__CRTDECL operator new(size_t size) _THROW1(_STD bad_alloc)
{ // try to allocate size bytesvoid *p;while ((p = malloc(size)) == 0)if (_callnewh(size) == 0){ // report no memorystatic const std::bad_alloc nomem;//如果分配内存失败,会抛bad_alloc异常_RAISE(nomem);}return (p);
}
exception的派生类(所有标准异常类直接或者间接继承exception)的表格:
异常类 | 描述说明 |
---|---|
std::exception | 所有标准 C++ 异常的父类 |
std::bad_alloc | 可能通过new抛出 |
std::bad_cast | 可能通过dynamic_cast抛出 |
std::bad_exception | 这在处理C++程序中无法预期的异常时非常有用 |
std::bad_typeid | 可以通过typeid抛出 |
std::logic_error | 理论上可通过阅读代码检测到的异常 |
std::domain_error | 使用无效的数学域时抛出 |
std::invalid_argument | 使用无效参数时抛出 |
std::length_error | 创建过长的std::string时抛出 |
std::out_of_range | 通过方法抛出,例如std::vector和 std::bitset::operator[]等 |
std::runtime_error | 理论上无法通过阅读代码检测到的异常 |
std::overflow_error | 发生数学上溢时抛出 |
std::range_error | 尝试存储超出范围的值时抛出 |
std::underflow_error | 发生数学下溢时抛出 |
看看exception的成员函数
C++20标准ISO/IEC 14882:2020(E)的第533页给出:
class exception
{
public:exception () noexcept;exception (const exception&) noexcept;exception& operator= (const exception&) noexcept;virtual ~exception();virtual const char* what() const noexcept;
}
但是没有成员变量,可以看看VS2022的vcruntime_exception.h,可以通过http://zhangcoder.ysepan.com/下载
VS实现的exception
VS2022提供的成员变量为:
__std_exception_data _Data;
_Data是结构体,可以放what()打印的字符串:
struct __std_exception_data
{char const* _What;bool _DoFree;
};
只解释what():
what()有virtual修饰,可以进行虚函数重写,what()返回值为const char*,可以打印字符串内容,VS2022的实现为:
_NODISCARD virtual char const* what() const
{return _Data._What ? _Data._What : "Unknown exception";
}
返回的就是_Data的_What指向的字符串的指针
libstdc++的exception
这里展示libstdc++ v3的源码,给出了成员函数的声明,在libsupc++/exception.h中:
class exception{public:exception() _GLIBCXX_NOTHROW { }virtual ~exception() _GLIBCXX_TXN_SAFE_DYN _GLIBCXX_NOTHROW;
#if __cplusplus >= 201103Lexception(const exception&) = default;exception& operator=(const exception&) = default;exception(exception&&) = default;exception& operator=(exception&&) = default;
#endif/** Returns a C-style character string describing the general cause* of the current error. */virtual const char*what() const _GLIBCXX_TXN_SAFE_DYN _GLIBCXX_NOTHROW;};
没有像VS那样有私有成员结构体_Data,不同编译器的具体实现不一样! what()返回字符串的指针,具体的定义在libsupc++/eh_exception.cc中
const char*
std::exception::what() const _GLIBCXX_TXN_SAFE_DYN _GLIBCXX_USE_NOEXCEPT
{// NB: Another elegant option would be returning typeid(*this).name()// and not overriding what() in bad_exception, bad_alloc, etc. In// that case, however, mangled names would be returned, PR 14493.return "std::exception";
}
异常规范
1.可以在函数的后面加throw(类型),用于列出这个函数可能抛出的所有异常类型
例如以下代码:
void func(int a) throw(int,char,short)
{if (a == 1)throw (int)1;if (a == 2)throw 'x';if (a == 3)throw (short)3;
}
2.如果函数内部抛出的异常类型不在throw(类型)中,编译器会报警告,例如以下代码:
void func(int a) throw(int,char,short)
{if (a == 1)throw 1;if (a == 2)throw 'x';if (a == 3)throw (short)3;if (a==4)throw true;
}
编译结果:
3.若无异常接口声明,则此函数可以抛掷任何类型的异常
函数不抛异常的两种写法
C++98: 函数后加throw()
C++98: 函数后加noexcept
3.总结异常的缺点
执行流乱跳
上面提到过异常的其中一个缺点:执行流乱跳,这在某些情况下会出问题
例如以下代码会导致内存泄漏:
#include <iostream>
using namespace std;
void func(int * p)
{throw "errmsg";
}
int main()
{try{int* p = new int(1);func(p);delete p;}catch (const char* errmsg){cout << errmsg << endl;}return 0;
}
如果func抛异常,那么p指向的内存空间无法释放,导致内存泄漏,Linux下的valgrind工具检测:
valgrind --tool=memcheck --leak-check=full ./a.out
运行结果:
解决方法:异常的重新抛出
1.使用thow;重新抛出异常(re-throw)便于之后的catch捕获
使用嵌套的try+catch释放内存空间
#include <iostream>
#include <string>
using namespace std;
class Exception
{
public:Exception(string errmsg, int* ptr):_errmsg(errmsg),_p(ptr){ }string _errmsg;int* _p;
};void func(int * p)
{throw Exception("errmsg",p);
}int main()
{try{try{int* p = new int(1);func(p);delete p;}catch (const Exception& e){cout << e._errmsg << endl;throw;}}catch (const Exception& e){delete e._p;}return 0;
}
无论func函数是否抛异常,p指向的内存空间都能被释放
使用catch(...)
#include <iostream>
#include <string>
using namespace std;
void func(int* p)
{throw "errmsg";
}int main()
{int* p = new int(1);try{try{func(p);}catch (...){delete p;//提前释放内存throw;//重新抛出}}catch (const char* errmsg){cout << errmsg << endl;}catch (...){cout << "Unknowen Error!" << endl;}return 0;
}
throw;的写法在开发中很常见,例如GoogleTest的src目录下的gtest.cc文件:
的介绍参见https://google.github.io/googletest/primer.html网站
性能的开销
异常会有一些性能的开销,当然在现代硬件速度很快的情况下,这个影响基本忽略不计
没有垃圾回收机制
C++没有垃圾回收机制,资源需要自己管理
有了异常非常容易导致内存泄漏、死锁等异常安全问题,需要使用RAII来处理资源的管理问题
标准库的异常体系定义
C++标准库的异常体系定义得不好,导致需要各自定义各自的异常体系,非常的混乱
4.总结异常的优点
清晰准确的展示出错误的各种信息
对比C语言,C++异常对象定义好了,相比错误码的方式可以清晰准确的展示出错误的各种信息,甚至可以包含堆栈调用的信息,这样可以帮助更好的定位程序的问题
部分函数使用异常更好处理
比如构造函数没有返回值,不方便使用错误码方式处理,例如以下代码
T& operator[](size_t pos)
{if (pos >= _size)//无法return,只能抛异常throw out_of_range("pos越界");elsereturn arr[pos];
}
如果pos越界了只能使用异常或者终止程序处理,没办法通过返回值表示错误
5.抛异常的建议
1.抛出异常类型都继承自一个基类
2.函数是否抛异常、抛什么异常,都使用 func(xxx) throw(类型)的方式规范化
异常总体而言利大于弊,所以工程中是鼓励使用异常的,另外面向对象的语言基本都是
用异常处理错误,这也可以看出这是大势所趋