文章目录
前言: 像在C语言中 程序出现错误都是怎么解决的?程序直接终止,直接返回错误码。这种方式让程序员有点难受,因为有时并不能准确的判断,是在哪里 出现了错误。所以C++提供一些对异常,程序错误的捕获机制。本文,来主要讲解一下,如何设置异常,如何捕获异常,不讲原理哈,了解会用即可。
1.异常的概念
- 异常:程序运行中可以预料的情况
- bug:程序中的错误,不被预期的情况
这点得理解哈。
1.1 C语言中程序出错,处理方式
C语言是面向过程的语言,并没有特别舒服的方式来捕获异常,但是也能捕获异常。
- 通过函数返回值,也就是退出码
- assert()断言
- C标准库中setjmp()和longjmp()组合使用
第一种情况:
这就得用到简单的if else 语句就能完成,比如:
int divide(int x,int y)
{
if (y == 0)
{
// exit() 程序退出
// abort() 程序报错
return -1;
}
else
{
return x / y;
}
}
int main()
{
int x, y;
while (scanf("%d%d", &x, &y))
{
double ret = divide(x, y);
if (ret == -1)
{
printf("除0错误\n");
return -1;
}
else
{
printf("两数相除的结果:%f\n", ret);
}
}
返回值为 -1 ,说明程序出现异常。返回值的设置,可以自定义的,程序退出的方式也不一定是return ,还可以是 exit()或者是abort(); 但是这是有区别的。return 是用户层这种返回。exit()调用后,这个进程就直接退出了,简单理解就是整个程序退出,它设置的返回值是给操作系统的;abort() 它很暴力,它是只要调用后,会直接报错误。这三种方式,return 是最温柔的。
第二种情况:
利用assert() 断言,也是非常暴力哈,它相当于 if + abort() 。
比如以下代码的效果是相同的:
if(y == 0)
abort();
assert(y!=0);
第三种情况:
这个用的已经是很少了,setjmp()的功能就是将程序的上下文保存在jmp_buf结构体中;longjmp()的功能就是跳转到setjmp()的位置,然后设置返回值,longjmp()的第二个参数就是返回值,setjmp()默认的返回值是 0,如果从longjmp()的返回的,那么setjmp的返回值会变成longjmp()的返回值。
总的来说,类似于C语言中的goto。
我画个图:
代码举例:
#include<iostream>
#include<stdio.h>
#include<errno.h>
#include<assert.h>
#include<setjmp.h>
using namespace std;
jmp_buf env;
int divide(int x,int y)
{
if (y == 0)
{
longjmp(env, 1);
}
return x / y;
}
int main()
{
int x, y;
while (scanf("%d%d", &x, &y))
{
if (setjmp(env)==0)
{
int ret = divide(x, y);
printf("两数相除的结果:%d\n", ret);
}
else
{
printf("除0错误\n");
}
}
return 0;
}
1.2 C++中异常的处理
C++的异常处理需要三个关键字的配合:
- throw: 这是用于抛异常的
- try:保护区域,也就是代码区域
- catch:是用于捕获异常的
画图看一下:
2. 异常的使用
2.1 抛异常和捕获异常
- throw抛出对象类型,catch接收对象,类型必须匹配
- catch接收对象,是有就近原则的,这个我们会后面会给实例
- throw抛出对象后,会生成一个临时拷贝,这个临时拷贝被接收后会被销毁
- catch(…) 可以捕获任意类型的异常,问题是 这里会不清楚是谁抛出的异常
- 实际中抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,可以抛出的派生类对象,使用基类捕获,这个在实际中非常实用,我们后面会详细讲解这个。
举个例子:
(1) 验证throw 和catch 类型匹配
void func1()
{
cout << "I am func1()" << endl;
throw "抛个异常没毛病";
}
int main()
{
try
{
func1();
}
catch (const char* s)
{
cout << s << endl;
}
return 0;
}
运行结果:
这是最简单的抛异常,接收异常的代码:
如果没有匹配的类型,会直接报错:
void func1()
{
cout << "I am func1()" << endl;
throw 111;
}
int main()
{
try
{
func1();
}
catch (const char* s)
{
cout << s << endl;
}
return 0;
}
所以保险起见,一般会给一个 catch(...)
:
void func1()
{
cout << "I am func1()" << endl;
throw 111;
}
int main()
{
try
{
func1();
}
catch (const char* s)
{
cout << s << endl;
}
catch (...)
{
cout << "未知错误" << endl;
}
return 0;
}
(2) 验证就近原则
int divide(int x,int y)
{
if (y == 0)
{
throw "除0错误";
}
return x / y;
}
void func1()
{
cout << "I am func1(),要进行除法运算:" << endl;
try
{
divide(1, 0);
}
catch (const char* s)
{
cout <<"func1():" << s << endl;
}
}
int main()
{
try
{
func1();
}
catch (const char* s)
{
cout <<"main():" << s << endl;
}
catch (...)
{
cout << "未知错误" << endl;
}
return 0;
}
运行结果:
可以看到,是被func1() catch到了异常,但是这种异常接收不是常用的,因为我们更喜欢在一个地方进行异常接收。如果在上层异常就被接收走,那么下层总的接收异常的地方,它的异常接收日志就会有缺失。
假如,不在func1()接收异常,那么会往下找,依旧就近原则被接收:
比如上面代码,我改动func1(),使其不能接收字符串类型的异常:
void func1()
{
cout << "I am func1(),要进行除法运算:" << endl;
try
{
divide(1, 0);
}
catch (const int a)
{
cout <<"func1():" << a << endl;
}
}
我依旧运行以上程序,其余地方不变:
很明显被main() 函数接收异常了。
画图理解:
就是逐层往下找,依据就近原则,最终在匹配的那一层被捕获。注意:如果下面所有层都没有匹配到异常,那么就会报错。不过一般最后一层都有 catch(...)
;
2.2 异常导致内存泄漏,死锁等问题
异常会导致,new的对象,没有被delete,就直接跳转到catch处,这就导致内存泄漏了。或者是 死锁问题,就是 一个线程 抱着锁被异常终止了,它的锁资源没有被释放,也就是在lock()和unlock() 之间抛异常。C++经常使用RAII来解决以上问题,这个是利用到了智能指针的知识,本文不做过多讲解,后续文章会更新智能指针的。
我们可以利用非常简单的方式,解决这块问题,那就是 异常重新抛出
;
举例:
void func1()
{
int* arry = new int[10];
throw "异常";
delete[] arry;
}
int main()
{
try
{
func1();
}
catch (const char* s)
{
cout << s << endl;
}
catch(...)
{
cout << "未知道错误" << endl;
}
return 0;
}
很明显以上程序,它并没有释放arry的内存,然后就因为异常跳转走了,怎么解决?
void func1()
{
int* arry = new int[10];
try {
throw "异常";
}
catch (...)
{
delete[] arry;
throw ;
}
}
当然这个代码写的有点智障,不过为了演示嘛。
重新抛出异常,我捕获到了异常后,释放掉我要释放的资源,然后再向下throw。可以看到重新抛出异常只需要 throw
就可以了。
2.3 异常的安全问题
给出两点:
- 不要在构造函数中抛异常,导致对象构造不完整或不成功
- 不要在析构函数中抛异常,导致资源未释放完成
还有那种情况的处理:RAII智能指针或是利用重新抛异常
2.4 异常规范
这个规范是为了使它人更轻易的看懂代码,它能表示出,一个函数能够throw出多少个异常类型。
格式:函数 throw(类型)
比如: void func1() throw(int,char,float)
,这就表示 此函数可能会抛出int,char,float三种类型的异常。
注意 :这个异常规范,方便阅读代码,没别的用处,上面的fun1() 虽然异常规范它抛出三种类型的异常,但是 有没有可能抛出别的类型的异常,是有可能的,它不做检查。
所以 异常规范,它只是提醒一下 此函数可能抛出几种类型的异常,但是 如果抛出其它类型的异常,也不报错,不处理。
总结有以下三种情况:
- 函数 + throw() 不抛异常
- 函数 + throw(A,B,C,D) 抛这几种类型的异常
- 函数 不加异常规范 可以抛任意类型的异常
2.5 异常概念再次理解
为什么要抛异常?抛异常的方式 比返回错误码用起来方便。异常是可以预料的情况,出现了异常通过抛异常 可以 更加精确的定位 到出现异常的地方。
并且,抛异常后 它并不会影响其他的代码块。也就是 异常被捕获后,它后续的代码依旧可以运行:
double Division(int a, int b)
{
// 当b == 0时抛出异常
if (b == 0)
{
string str("除零错误");
throw str;
}
else
{
return ((double)a / (double)b);
}
}
void Func2()
{
int len, time;
cin >> len >> time;
if (time != 0)
{
throw 3.33;
}
else
{
cout << len << " " << time << endl;
}
}
void Func1()
{
try
{
int len, time;
cin >> len >> time;
cout << Division(len, time) << endl;
Func2() ; // 这里会受影响,因为是一个try块里的。
}
catch (const string& errmsg)
{
cout << errmsg << endl;
}
Func2(); // 不受影响
}
int main()
{
try
{
Func1();
}
catch (const string& errmsg)
{
cout << errmsg << endl;
}
catch (int errid)
{
cout << errid << endl;
}
catch (...)
{
cout << "未知异常" << endl;
}
cout << "hellow dajia" << endl;// 不受影响
return 0;
}
运行结果:
总结:抛异常后,只会影响同一个try块的后续代码,异常的后续代码不会运行;但是不会影响其他块的代码,它们会继续执行。
3.异常体系
实际使用中很多公司都会自定义自己的异常体系进行规范的异常管理,因为一个项目中如果大家随意抛异常,那么外层的调用者基本就没办法玩了,所以实际中都会定义一套继承的规范体系。这样大家抛出的都是继承的派生类对象,捕获一个基类就可以了。
3.1 自定义异常体系
抛出的异常是继承的派生类对象,捕获的是一个基类。
这是用到继承和多态,很好理解,父类可以接收子类,这是一种切片;然后子类对父类中的抛异常函数进行重写,这是多态对吧,这就形成了自定义的异常体系。
class father
{
protected:
string _abnormal;
public:
father(const string& s)
:_abnormal(s)
{}
virtual string what_abnormal()
{
return _abnormal;
}
};
class childA :public father
{
public:
childA(const string& s)
:father(s)
{
}
virtual string what_abnormal()
{
_abnormal += "childA";
return _abnormal;
}
};
class childB :public father
{
public:
childB(const string& s)
:father(s)
{
}
virtual string what_abnormal()
{
_abnormal += "childB";
return _abnormal;
}
};
void func1()
{
throw childA("除0错误:");
}
void func2()
{
throw childB("内存泄漏:");
}
int main()
{
try
{
int n = 0;
cin >> n;
if (n == 1)
{
func1();
}
else
{
func2();
}
}
catch (father& e)
{
cout<<e.what_abnormal()<< endl;
}
catch (...)
{
cout << "未知错误" << endl;
}
return 0;
}
child继承father,然后对what_abnormal() 进行重写。string _abnormal 是异常信息。
看一下运行结果:
因为这里的异常抛的是自定义对象,所以我们可以增加很多信息。非常方便。
3.2 C++标准库的异常体系
C++标准库的异常体系,其实用的少,和上面一样,它们有一个共同的父类;我们其实也可以继承其父类,然后进行重写。但是 很少用,一般情况下,还是自己写自定义异常体系好用。
这个就是 标准库中的异常体系的最终父类:
class exception {
public:
exception () noexcept;
exception (const exception&) noexcept;
exception& operator= (const exception&) noexcept;
virtual ~exception();
virtual const char* what() const noexcept;
}
看到了吧,它们重写了 what()函数,它就是显示错误信息的函数。
再看一下它的子类:
然后这些子类还有它们自己的子类们:
就这俩个子类还有子类。
简单介绍后,我们来自己用一用:
int main()
{
try {
vector<int> v(10, 5);
// 这里越界会抛异常
v.at(10) = 100;
}
catch (const exception& e) // 这里捕获父类对象就可以
{
cout << e.what() << endl;
}
catch (...)
{
cout << "Unkown Exception" << endl;
}
return 0;
}
这是越界抛的异常,感觉信息也挺清楚:
再来演示一个异常:
v.reserve(10000000000000000000);
这是说明 太长了,vector。
如果平常我们来写代码,用标准库里的 还是不错的,你看 上面抛的异常信息还可以。
和自定义异常做一下对比:
- 自定义异常也是抛一个对象,这个对象里可以存很多信息,不过需要我们自己去判断什么时候抛异常。这在公司中常用。
- 标准库,比较省事,只需要捕获 父类exception就可以了。这我们个人使用,还不错。
4. 异常的优缺点
异常其实我感觉还蛮好用的,最起码有点保障,虽然不能完全的避开bug,但是也能人为的避开部分bug。
- 优点:
- 异常对象定义好了,相比错误码的方式可以清晰准确的展示出错误的各种信息,甚至可以包含堆栈调用的信息,这样可以帮助更好的定位程序的bug。
- 返回错误码的传统方式有个很大的问题就是,在函数调用链中,深层的函数返回了错误,那么我们得层层返回错误,最外层才能拿到错误。
- 很多的第三方库都包含异常,比如boost、gtest、gmock等等常用的库,那么我们使用它们也需要使用异常。
- 很多测试框架都使用异常,这样能更好的使用单元测试等进行白盒的测试。
- 部分函数使用异常更好处理,比如构造函数没有返回值,不方便使用错误码方式处理。比如T&operator这样的函数,如果pos越界了只能使用异常或者终止程序处理,没办法通过返回值表示错误。
这是相较于C语言的优点。
- 缺点:
- 异常会导致程序的执行流乱跳,并且非常的混乱,并且是运行时出错抛异常就会乱跳。这会导致我们跟踪调试时以及分析程序时,比较困难。
- 异常会有一些性能的开销。当然在现代硬件速度很快的情况下,这个影响基本忽略不计。
- C++没有垃圾回收机制,资源需要自己管理。有了异常非常容易导致内存泄漏、死锁等异常安全问题。这个需要使用RAII来处理资源的管理问题。学习成本较高。
- C++标准库的异常体系定义得不好,导致大家各自定义各自的异常体系,非常的混乱。
- 异常尽量规范使用,否则后果不堪设想,随意抛异常,外层捕获的用户苦不堪言。所以异常规范有两点:一、抛出异常类型都继承自一个基类。二、函数是否抛异常、抛什么异常,都使用 异常规范化。
说实话,C++没有回收机制,真的有点小难受,这里面的bug很恶心。大家自行体会,学习起来不容易,大家加油。