本篇博客让我们来认识一下C++中对于异常的处理机制

[TOC]

1.概念

1.1 C语言对于异常的处理

在之前我们遇到一些bug的时候,通常会用if判断或者assert断言等问题进行处理。但这种方式太过暴力,会直接中断程序的运行

另外一种办法是返回错误码,C语言的报错大多使用这种方式。不过这需要程序的用户自己去查对应的错误码表格,较为麻烦


1.2 C++异常

所谓异常,便是程序运行过程中可能遇到的bug或者问题。程序可以有选择地抛出一个异常,告知用户程序运行出现了问题。

C++标准库中便使用了一个exception类来进行异常的处理,我们运行程序中遇到的一些报错,其实就是标准库里面抛出了对应的异常

image-20220929164037516

其操作主要借助下面三个关键字

  • throw 在出现问题的地方抛出异常
  • try 监控后续代码中出现的异常,后续需要以catch作为结尾
  • catch 用于捕获异常,同一个try可以用多个不同类型的catch进行捕获

throw关键字可以抛出任意类型的异常

2.基本操作

下面用除法函数,以除0的情况来做一个最简单的演式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int Div(){
int a, b;
cin >> a >> b;
if (b == 0)
throw "div 0 err!";

return a / b;
}

int main()
{
try {
cout << Div() << endl;
}
catch(const char* s){
cout << s << endl;
}
return 0;
}

image-20220929165732458

2.1 需要注意的点

这里有几个需要注意的点:

catch类型对应

当我们进行抛异常的时候,一定需要有对应类型的catch,否则会报错

比如我们throw的是一个常量字符串,如果用string来catch,就会因为类型不匹配而出现报错

image-20220929165932904

所以当我们使用某一个会抛异常的函数的时候,一定要注意其抛出异常的类型

image-20220929170015951

利用…进行全捕获

假设我们不知道这里面会抛出什么类型的错误呢?总不能把所有类型都catch一下吧?

当然不需要,我们可以使用下面的函数进行全捕获

image-20220929170323269

这就可以用于当我们不知道报错类型的时候。不过一般的使用场景是,在这之前先catch已知的错误类型,最后再加上一个全捕或,作为未知错误的标识

不过catch(...)有一个缺点,那便是我们不能知道异常的类型


基类捕获派生类的异常

当我们出现异常的时候,如果throw了一个子类对象,可以用基类的引用来接收!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class A {
int a;
};
class B : public A {
int b;
public:
B()
:b(1)
{}
};

void testab()
{
B bt;
throw bt;
}

int main()
{
try {
testab();
}
catch (A& e) {
cout << "err class A" << endl;
}
catch (...) {
cout << "err" << endl;
}

return 0;
}

image-20220929170816245

这个在进行继承多态的错误编写的时候就很有用啦

2.2 异常和栈帧

抛出异常后,会被离这个异常最近的catch捕获,如果没有任何catch则会报错

image-20220929171133649

比如我们单独写一个函数,而这个函数体内有try/catch的话,那么会直接和这个最近的匹配,并不会和main函数里面的匹配

image-20220929171308949

而如果该函数里面没有进行此操作,则会直接到main的对应catch处

image-20220929171424057

注意!这里是直接跳转到对应catch语句,并不会出现先跳到testCatch函数在跳回main的情况!

2.3 重新抛出异常

假设我们遇到了这种情况

1
2
3
4
5
6
7
8
9
10
11
12
void testab()
{
B bt;
throw bt;
}

void testD()
{
int* arr = new int[10];
testab();
delete[] arr;
}

testab()函数中抛出了异常,导致testD()函数提前终止!

image-20220929172649718

可我们new的东西还没释放呢!

这就出现了内存泄漏

注意:内存泄漏是一个不能被忽略的问题,即便我们每一次new的空间很小,但是积小成多就是大问题!

这时候我们就需要提前进行异常处理,如果出现问题,先释放我们new的资源之后,再将异常重新抛出。可以理解为是提前拦截异常

image-20220929173313288

3.异常需要注意的一些问题

3.1 异常安全

当我们操作异常的时候,需要注意一些相关的问题

  • 上面2.3中提到的内存泄露问题,在new和delete之间抛出异常而没有中途处理,导致内存泄漏
  • 不要在构造或析构函数中抛出异常,否则可能导致对象不完全初始化(对象不完整)或不完全析构(内存泄漏)
  • 多线程操作中在lock与unlock之间抛出异常,导致死锁

当然是有解决方案的,C++使用RAII来解决上述问题,这个待我下一篇智能指针的博客来讲解!

3.2 异常规范

因为异常都是手动写代码进行处理的,那么就极其需要些代码的老哥拥有很好的编程规范。

  • 在函数后加上throw(类型A,类型B)可以列出这个函数能抛出的所有异常类型
1
void test() throw(string,vector<int>);
  • 如果只跟一个类型,代表该函数只会抛出一种类型的异常
1
void* operator new(size_t size) throw (std::bad_alloc);
  • 如果跟的是throw()代表这个函数不会抛出异常
1
void* test2(size_t sz, void* p) throw();

C++11中还新增了一个关键字noexcept来标识不会抛出异常

1
void* test2(size_t sz, void* p) noexcept;

但是这些都依赖于用户的编程习惯,C++并没有强制用户一定要在函数尾部写上异常抛出的类型。而且也不会对写好的异常类型和实际抛出的异常类型进行检查。

假设在项目合作中,来了一个“实习生”

  • 写了一个会抛出异常的函数,却没有标识该函数会抛异常,那下面调用该函数的地方没有进行异常处理,那不就蛋糕了,程序提前中止!
  • 或者说是抛出异常的类型写错了,没有对应的异常处理语句,只能被catch(...)捕获
  • 或者是明明不抛出异常非要写自己抛出,白写了异常处理

以上三种情况都是我们不期望遇到的,所以在写相关函数的时候,最好明确标识相关异常抛出的类型!

有些人可能会对noexcept关键字有误解,认为写了这个关键字函数就无法抛出异常了。其实并不然,这里的throw和noexcept关键字的标定你可以理解为是函数的一个特殊的注释,并没有实际作用。后面跟着noexcept的函数依然可以抛出异常!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <stdexcept>

void test_exp() noexcept
{
throw std::runtime_error("err");
}

int main()
{
try
{
test_exp();
std::cout << "no throw\n";
} catch (...)
{
std::cout << "throw\n";
}
std::cout << "end\n";
return 0;
}

上面这个代码的运行结果如下,依旧抛出了异常。但是因为noexcept和抛出异常的操作在变成规范中冲突了,g++在编译的时候会进行warning警告,并且会在该函数抛出异常时直接终止程序(因为你写出了一个不符合编程规范的代码)

1
2
3
4
5
6
7
8
> g++ test.cpp -o test && ./test
test.cpp: In function ‘void test_exp()’:
test.cpp:6:5: warning: ‘throw’ will always call ‘terminate’ [-Wterminate]
6 | throw std::runtime_error("err");
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
terminate called after throwing an instance of 'std::runtime_error'
what(): err
[1] 260443 IOT instruction ./test

当你把test_exp函数的noexcept关键字声明删除后,这个程序才能按照预期正常抛出异常并被catch捕获

1
2
3
> g++ test.cpp -o test && ./test
throw
end

3.3 自定义异常类型

要是在协作中,不同用户抛出了太多不同类型的异常,那还怎么调用函数?

前面提到了,子类抛出的异常可以用基类接受。所以在项目中一般都是会定义一个继承的规范异常体系,用于处理不同的异常。

这样我们就只需要捕获一个基类对象,就能捕获到所有派生类的异常对象。

关于基类捕获子类异常的方法在2.1中已经提及,这里不再演示;

3.4 C++标准库中的异常

在C++标准库中,异常是围绕下图组织的

cplusplus:https://legacy.cplusplus.com/reference/exception/exception/?kw=exception

image-20221003162517595

标准异常类的成员:

  • 在上述继承体系中,每个类都有提供了构造函数、复制构造函数、和赋值操作符重载。
  • logic_error类及其子类、runtime_error类及其子类,它们的构造函数是接受一个string类型的形参,用于异常信息的描述
  • 所有的异常类都有一个what()方法,返回const char*类型描述异常信息

标准异常类的具体描述:

异常名称描述
exception所有标准异常类的父类
bad_alloc当operator new and operator new[],请求分配内存失败时
bad_exception这是个特殊的异常。如果函数的异常抛出列表里声明了bad_exception异常,而函数内部抛出了异常抛出列表中没有的异常,不论什么类型,都会被替换为bad_exception类型
bad_typeid使用typeid操作符,操作一个NULL指针,而该指针是带有虚函数的类,这时抛出bad_typeid异常
bad_cast使用dynamic_cast转换引用失败的时候
ios_base::failureio操作过程出现错误
logic_error逻辑错误,可以在运行前检测的错误
runtime_error运行时错误,仅在运行时才可以检测的错误

logic_error的子类:

异常名称描述
length_error试图生成一个超出该类型最大长度的对象时,例如很长的string
domain_error参数的值域错误,主要用在数学函数中。例如使用一个负值调用只能操作非负数的函数
out_of_range超出有效范围,vetor的at抛出了此异常
invalid_argument参数不合适。在标准库中,当利用string对象构造bitset时,而string中的字符不是’0’或’1’的时候,抛出该异常
future_error(C++11)This class defines the type of objects thrown as exceptions to report invalid operations on future objects or other elements of the library that may access a future‘s shared state.

runtime_error的子类:

异常名称描述
range_error计算结果超出了有意义的值域范围
overflow_error算术计算上溢
underflow_error算术计算下溢
system_error(C++11)运行时从操作系统或其他具有关联error_code的低级应用程序接口引发的异常
1
2
以上部分C++标准库异常解释来自
https://blog.csdn.net/linxi8693/article/details/90318166

4.异常优缺点

优点

  • 异常对象定义完备之后,相比于错误码的方式,能让用户更加清楚的了解到自己遇到了什么类型的问题,更好定位程序的bug
  • 函数错误码若遇到,需要层层向外返回;而异常则通过catch可以直接跳到对应异常处理位置,避免多重判断和返回;
  • 第三方库包含异常,我们在使用类似于boost/gtest等第三方库的时候也需要使用对应的异常处理
  • 对于T& operator[]这种操作符重载,我们没办法很好地使用返回值来标识错误(因为不同类型的返回值不一样,没办法统一处理)这时候就可以用异常来抛出越界问题

缺点

  • 异常可能会导致程序到处乱跳(因为会跳到最近的catch位置)给观察错误情况增添了一些难度,代码可读性变差;
  • 异常有一定性能开销(可忽略);
  • 异常容易导致资源泄漏等等问题(内存泄漏);
  • 异常依赖于用户编程规范,否则函数调用容易出现异常没有得到处理的问题;

image-20221003164426499

结语

总体而言,异常处理利大于弊。很多语言都是用异常来处理错误的,比如python。只要维持一个良好的编程习惯,在函数后声明会抛出的异常类型,针对性进行处理,还是很香的!

QQ图片20220413084241