【C++】异常
1.C语言传统的处理错误的方式
1.终止程序,如assert(release版本下不生效),缺陷:用户难以接受。如发生内存错误,除0错误时就会终止程序。可能会导致数据丢失,并且无法进行任何错误恢复或处理的操作
2.返回错误码,缺陷:需要程序员自己去查找对应的错误。如系统的很多库的接口函数都是通过把错误码放到全局变量errno中,表示错误。实际中C语言基本都是使用返回错误码的方式处理错误。存在多次函数调用情况就需要逐层返回错误码,最终由上层调用者决定如何处理错误。
部分情况下使用终止程序处理非常严重的错误,如内存分配失败。
回顾语言中:break结束循环,return结束当前函数执行,goto能跳过多层循环,throw能跳过多层函数
2.C++异常概念
异常是一种处理错误的方式,当一个函数发现自己无法处理的错误时就可以抛出异常,让函数的直接或间接的调用者处理这个错误
throw: 当问题出现时,程序会抛出一个异常。这是通过使用 throw 关键字来完成的。
try: try 块中的代码标识将被激活的特定异常,它后面通常跟着一个或多个 catch 块。
catch: 在想要处理问题的地方,通过异常处理程序捕获异常.catch 关键字用于捕获异常,可以有多个catch进行捕获(printf是可变参数列表,catch是任意类型列表)。
3.异常的使用
3.1异常的抛出和捕获原则
异常的抛出和匹配原则
- 异常是通过抛出对象而引发的,该对象的类型决定了应该激活哪个catch的处理代码。
- 被选中的处理代码是调用链中与该对象类型匹配且离抛出异常位置最近的那一个。
- 抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个临时对象,异常对象在出其函数作用域前要销毁掉,并且希望异常对象在传播过程中保持不变,所以会生成一个拷贝对象,这个拷贝后的临时异常对象会在异常传播过程中被使用,直到它被一个匹配的catch块捕获,在被catch以后销毁。(这里的处理类似于函数的传值返回)
- catch(…)可以捕获任意类型的异常,问题是不知道异常错误是什么。通常作为捕获异常的最后一道防线。
- 实际中抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,可以抛出派生类对象,使用基类捕获,这个在实际中非常实用.
注意:
多个throw一起执行没意义,因为第一个会直接跳到catch处,后面的throw不执行。栈帧按顺序销毁
- 测试代码:
double Division(int a, int b)
{// 当b == 0时抛出异常if (b == 0){throw "Division by zero condition!";}else{return ((double)a / (double)b);}
}void Func()
{try {int len, time;cin >> len >> time;cout << Division(len, time) << endl;}catch (const char* errmsg) {//抛出的异常在此处就被捕获cout << errmsg << endl;}cout << "yyyyyy" << endl;
}int main()
{try {Func();}//注意第一个catch由于类型不匹配不会捕获异常catch (int errmsg) {cout << errmsg << endl;}catch (const char* errmsg) {cout << errmsg << endl;}catch (...) { // 任意类型,最后一道防线// 抛不规范异常,防止程序终止cout << "未知异常" << endl;}//捕获异常后会继续执行该句cout << "eeeeee" << endl;return 0;
}
在函数调用链中异常栈展开匹配原则:
- 首先检查throw本身是否在try块内部,如果是再查找匹配的catch语句。如果有匹配的,则调到catch的地方进行处理。会直接跳转过去
- 没有匹配的catch则退出当前函数栈,继续在调用函数的栈中进行查找匹配的catch。****从当前层逐层返回检查有没有捕获,多个catch的情况下会跳到离它最近的catch,只执行一个
- 如果到达main函数的栈,依旧没有匹配的,则终止程序。上述这个沿着调用链查找匹配的catch子句的过程称为栈展开。所以实际中我们最后都要加一个catch(…)捕获任意类型的异常,否则当有异常没捕获,程序就会直接终止。
- 找到匹配的catch子句并处理以后,会继续沿着catch子句后面继续执行。
理解类比“throw本身是否在try块内部”:
可以将 try 块视为一个“异常监控区域”。当在 try 块内执行代码时,任何在此区域内抛出的异常都会被监控,并尝试在这个区域内找到处理该异常的方法(即 catch 块)。如果在 try 块之外抛出异常,则相当于异常发生在监控区域之外,需要由上层的监控机制(外层的 try 块)来处理。如果 throw 抛出的异常没有被任何 catch 块捕获,程序会调用 std::terminate 函数,它又调用 std::abort,这会导致程序立即终止,而不进行任何清理操作(如执行析构函数)
作用:检查 throw 是否在 try 块内部是为了确定异常处理的范围和流程,这种机制确保了程序能够有条不紊地处理异常,避免程序因为未处理的异常而崩溃。
3.2异常的重新抛出
有可能单个的catch不能完全处理一个异常,在进行一些校正处理以后,希望再交给更外层的调用链函数来处理,catch则可以通过重新抛出将异常传递给更上层的函数进行处理。比如:在new和delete中间抛异常,直接跳转导致没有delete而造成内存泄漏。
- 测试代码
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];try{int len, time;cin >> len >> time;cout << Division(len, time) << endl;// func()}catch (...) // 异常的重新抛出{cout << "delete []" << array << endl;delete[] array;throw; // 捕获什么,抛什么}//...cout << "delete []" << array << endl;delete[] array;
}
int main()
{try{Func();}catch (const char* errmsg){cout << errmsg << endl;}return 0;
}
3.3异常安全
1.构造函数完成对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象不完整或没有完全初始化
2.析构函数主要完成资源的清理,最好不要在析构函数内抛出异常,否则可能导致资源泄漏(内存泄漏、句柄未关闭等)
3.C++中异常经常会导致资源泄漏的问题,比如在new和delete中抛出了异常,导致内存泄漏,在lock和unlock之间抛出了异常导致死锁,C++经常使用RAII来解决以上问题,关于RAII在后续智能指针进行学习。
3.4 异常规范
- 异常规格说明的目的是为了让函数使用者知道该函数可能抛出的异常有哪些。 可以在函数的后面接throw(类型),列出这个函数可能抛掷的所有异常类型。
- 函数的后面接throw(),表示函数不抛异常。
- 若无异常接口声明,则此函数可以抛掷任何类型的异常。
4.自定义异常体系
一个项目中如果大家随意抛异常,那么外层的调用者基本就没办法很好区分。所以实际中都会定义一套继承的规范体系。这样大家抛出的都是继承的派生类对象,捕获一个基类就可以了
基类中一定要包含的是错误描述和错误编号,方便调试。一般会定义一个或多个虚函数,在派生类对象中重写实现多态,派生类还可以有自己的成员变量。在try catch时捕获父类对象,就可以统一处理不同子类的异常
5.C++标准库的异常体系
C++ 提供了一系列标准的异常,定义在 中,我们可以在程序中使用这些标准的异常。它们是以父子类层次结构组织起来的,如下所示:
实际中我们可以可以去继承exception类实现自己的异常类,因为C++标准库设计的不够好用:
- 异常处理机制存在局限性
性能问题:异常处理机制在运行时会引入额外的开销,特别是在异常传播的过程中,可能会导致栈展开.
异常可能从意想不到的地方抛出,例如std::bad_alloc,这要求开发者编写更多的防御性代码来应对可能的异常情况,增加了代码的复杂性。
- 缺乏对异常的编译时检查
编译器无异常检查:C++编译器不会检查异常是否被正确处理。如果一个函数抛出异常但没有匹配的catch块,程序在运行时会调std::terminate终止。这种缺乏编译时检查的机制可能导致运行时错误。
无异常类型检查:编译器也不会检查函数抛出的异常类型是否与声明的一致。这可能导致程序在运行时遇到未预期的异常类型,增加调试难度。
- 异常体系设计不够完善
异常类层次不够丰富,异常信息不够丰富
- 与其他语言特性存在冲突
与RAII冲突:RAII(Resource Acquisition Is Initialization)是C++中重要的资源管理技术,但在异常处理中可能会出现问题。例如,当构造函数抛出异常时,已部分构造的对象可能处于不一致状态,导致资源泄漏或未定义行为。
与多态冲突:多态允许通过基类指针或引用来操作派生类对象,但在异常处理中,如果基类和派生类都有析构函数,并且派生类的析构函数抛出异常,基类的析构函数可能无法正常执行,导致资源管理混乱。可能会导致未定义行为(可能已经部分释放了资源,但无法保证全部资源都被正确处理)。
- 缺乏对异常处理的指导和规范
异常使用缺乏规范:C++标准库对异常的使用缺乏明确的指导和规范,导致开发者在实践中可能滥用或误用异常机制。(例如:没有强制要求函数声明其可能抛出的异常类型)
错误处理风格不统一:C++允许多种错误处理风格(如返回错误码、异常等),支持C语言,这可能导致代码风格不一致,增加代码维护的难度。
- 对异常的滥用问题
过度使用异常:在一些场景中,异常被过度使用,如将异常用于控制流程,而不是用于处理真正的错误情况。这会导致代码可读性和可维护性下降。
异常替代返回值:在某些情况下,异常被用作返回值的一种替代,而不是用于指示错误。这会使代码逻辑变得混乱,难以理解和维护。
6.异常的优缺点
优点:
- 异常对象定义好了,相比错误码的方式可以清晰准确的展示出错误的各种信息,甚至可以包含堆栈调用的信息,这样可以帮助更好的定位程序的bug。
2.返回错误码的传统方式有个很大的问题就是,在函数调用链中,深层的函数返回了错误,那么我们得层层返回错误,最外层才能拿到错误c.异常会直接跳转到catch捕获的地方处理错误。- 很多的第三方库都包含异常,比如boost、gtest、gmock等等常用的库,那么我们使用它们也需要使用异常
- 部分函数使用异常更好处理,比如构造函数没有返回值,不方便使用错误码方式处理。比如T& operator这样的函数,如果pos越界了只能使用异常或者终止程序处理,没办法通过返回值表示错误。
缺点:
- 异常会导致程序的执行流乱跳,并且非常的混乱,并且是运行时出错抛异常就会乱跳。这会导致我们跟踪调试时以及分析程序时,比较困难。(最大问题)
- 异常会有一些性能的开销。当然在现代硬件速度很快的情况下,这个影响基本忽略不计。
- C++没有垃圾回收机制,资源需要自己管理。有了异常非常容易导致内存泄漏、死锁等异常安全问题。这个需要使用RAII来处理资源的管理问题。学习成本较高。
- C++标准库的异常体系定义得不好,导致大家各自定义各自的异常体系,非常的混乱。
- 异常尽量规范使用,否则后果不堪设想,随意抛异常,外层捕获的用户苦不堪言。所以异常规范有两点:一、抛出异常类型都继承自一个基类。二、函数是否抛异常、抛什么异常,都使用 func() throw();的方式规范化。
总结:异常总体而言,利大于弊,所以工程中我们还是鼓励使用异常的。另外OO的语言基本都是用异常处理错误,这也可以看出这是大势所趋。