异常知识及其使用
异常的简单概念
在C++中,异常处理是一种机制,用于处理程序运行时发生的意外情况。它允许程序在发生错误时,将控制权转移到一个专门的代码块,而不是让程序直接崩溃。C++的异常处理机制包括以下几个关键概念:
throw
- 用途:用于抛出一个异常。
- 语法:
throw exception;
- 说明:
throw
关键字后面可以跟任何类型的表达式,包括基本数据类型、对象、异常类实例等。当throw
被执行时,程序控制流将立即转移到最近的匹配的catch
块。 - 示例:
throw std::runtime_error("An error occurred");
try
- 用途:标记可能抛出异常的代码块。
- 语法:
try { /* 可能抛出异常的代码 */ }
- 说明:
try
块包含了可能引发异常的代码。如果try
块中的代码抛出了异常,程序将搜索匹配的catch
块来处理这个异常。如果没有匹配的catch
块,异常将被传播到调用栈的上一层。 - 示例:
try {// 代码可能抛出异常 } catch (const std::exception& e) {// 处理std::exception类型的异常 }
catch
- 用途:捕获并处理异常。
- 语法:
catch (exception_type exception_name) { /* 处理异常的代码 */ }
- 说明:
catch
块用于捕获特定类型的异常,并提供处理异常的代码。一个try
块后面可以有多个catch
块,以捕获不同类型的异常。 - 示例:
try {// 可能抛出多种类型的异常 } catch (const std::bad_alloc& e) {// 处理内存分配失败的异常 } catch (const std::exception& e) {// 处理其他标准异常 } catch (...) {// 处理所有未被捕获的异常 }
组合使用:
try
、catch
和throw
通常一起使用,以实现异常的抛出和捕获。throw
用于在检测到错误时立即将错误信息传递出去,try
块用于包围可能抛出异常的代码,而catch
块则用于处理这些异常。- 异常处理允许程序的某一部分专注于检测问题,而另一部分专注于解决问题,从而实现代码的解耦和模块化。
异常的抛出和捕获
异常抛出(throw)
-
引发异常:当程序遇到一个无法处理的情况时,可以通过
throw
关键字抛出一个异常对象。这个对象可以是任何类型,包括自定义的异常类实例、标准异常类实例或者基本数据类型。
double Division(int a, int b)
{//除数为0无意义,需要抛异常if (b == 0){throw (string)"Division by zero condition!";}//这里不需要else,是因为如果有抛异常,一旦throw被执行,throw之后的代码将不会被执行。程序的执行流程会立即跳转到匹配的catch块。return ((double)a) / ((double)b);
}
这里我们本来抛出的异常对象类型是 const char* ,这里我通过强制类型转换,将该抛异常的对象的类型转换成 string 。
-
调用链和匹配:抛出的异常对象的类型将决定哪个
catch
块会捕获这个异常。编译器会在调用链中寻找匹配的catch
块,即类型匹配且最接近抛出点的catch
块。
-
执行流程的改变:一旦
throw
被执行,throw
之后的代码将不会被执行。程序的执行流程会立即跳转到匹配的catch
块。如果当前函数中没有匹配的catch
块,异常会沿着调用栈向上传播,直到找到匹配的catch
块或者程序终止。
- 沿着调⽤链的函数可能提早退出。
- ⼀旦程序开始执⾏异常处理程序,沿着调⽤链创建的对象都将销毁。(不能匹配的栈帧就被直接销毁了,它后面的代码不会执行)
根据上图我们可以看出来:Division()进行抛异常后,除法运算没有被执行,而又因为Division()中的catch不匹配,没有进行catch的能力,所以 cout << "double Division(int a, int b)" << endl; 没有被执行。(控制权从throw位置转移到了catch位置)
我们可以发现:
if (b == 0)
{int* p = new(int);throw (string)"Division by zero condition!";
}
这样写的话,如果在该生命周期没有因为抛异常被catch,造成没有对该new的资源的释放,就会引起内存泄露等问题,解决该问题,我们主要是通过智能指针进行解决。
异常捕获(catch)
-
处理异常:
catch
块用于捕获并处理异常。它可以捕获特定类型的异常,也可以捕获所有类型的异常(使用catch(...)
)。
目的是为了不让程序直接挂掉,也是为了防止当catch和抛出异常不匹配的,导致执行流乱跳,但是问题就是不知道异常错误是什么。
-
对象销毁:当控制权转移到
catch
块时,从抛出异常点到catch
块之间的所有局部对象都会被销毁。这是因为C++的异常处理机制要求在异常传播过程中,所有局部对象都必须被正确地析构。 -
异常对象的拷贝:当抛出一个异常时,如果抛出的是一个非静态局部对象(即在函数内部定义的对象),编译器会创建这个对象的一个拷贝。这个拷贝会被传递给
catch
块,并且在catch
块执行完毕后被销毁。这样做的目的是为了确保异常对象在传递过程中的安全性和完整性。
即使std::string
具有移动构造函数,当抛出一个std::string
对象时,如果没有移动构造函数,编译器仍然会创建一个拷贝。这是因为异常对象需要被传递给catch
块,并且必须在整个传递过程中保持有效。
由于移动构造的存在,所以没有进行临时拷贝,如果没有移动构造,抛出的对象是会被拷贝一份的。(C++11引入了移动语义,允许资源的转移而不是拷贝。如果一个对象具有移动构造函数,那么在右值情况下,可以调用移动构造函数来转移资源,而不是拷贝资源。)
栈展开
- 抛出异常后,程序暂停当前函数的执⾏,开始寻找与之匹配的catch⼦句,⾸先检查throw本⾝是否在try块内部,如果在则查找匹配的catch语句,如果有匹配的,则跳到catch的地⽅进⾏处理。
- 如果当前函数中没有try/catch⼦句,或者有try/catch⼦句但是类型不匹配,则退出当前函数,继续在外层调⽤函数链中查找,上述查找的catch过程被称为栈展开。
- 如果到达main函数,依旧没有找到匹配的catch⼦句,程序会调⽤标准库的 terminate 函数终⽌程序。(这里我们可以用catch(...)来进行间接阻断程序的直接报错)
- 如果找到匹配的catch⼦句处理后,catch⼦句代码会继续执⾏。
其实我们上面的图一示例就是一个栈展开的过程
查找匹配的处理代码
- ⼀般情况下抛出对象和catch是类型完全匹配的,如果有多个类型匹配的,就选择离他位置更近的那个。(这在上面图一也有详细说明)
- 但是也有⼀些例外,允许从⾮常量向常量的类型转换,也就是权限缩⼩;允许数组转换成指向数组元素类型的指针,函数被转换成指向函数的指针;允许从派⽣类向基类类型的转换,这个点⾮常实⽤,实际中继承体系基本都是⽤这个⽅式设计的。
#include <iostream>
#include <string>class Base {};
class Derived : public Base {};void handleBase(const Base& b) {std::cout << "Caught const Base by reference" << std::endl;
}void handleBasePtr(Base* b) {std::cout << "Caught Base*" << std::endl;
}void handleDerived(Derived d) {std::cout << "Caught Derived by value" << std::endl;
}int main() {Derived d;const Base& b = d;try {// 抛出Derived类型的对象throw d;} catch (const Base& e) { // 匹配规则:const转换handleBase(e);} catch (Base* e) { // 匹配规则:数组到指针的转换(这里Base是多态类型,所以适用)handleBasePtr(e);}try {// 抛出Derived类型的对象throw d;} catch (Derived e) { // 精确匹配handleDerived(e);}return 0;
}
- 如果到main函数,异常仍旧没有被匹配就会终⽌程序,不是发⽣严重错误的情况下,我们是不期望程序终⽌的,所以⼀般main函数中最后都会使⽤catch(...),它可以捕获任意类型的异常,但是是不知道异常错误是什么。
一般大型项目程序才会使用异常,下面我们模拟设计一个服务的几个模块,每个模块的继承都是Exception的派生类,每个模块可以添加自己的数据,最后捕获时,我们捕获基类就可以:
#include<thread>//Exception:基类,包含错误信息和ID。
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;
};//SqlException:数据库相关的异常,包含SQL语句。
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;
};//CacheException:缓存相关的异常。
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;}
};//HttpException:HTTP服务相关的异常,包含请求类型。
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;
};//SQLMgr:模拟数据库管理,随机抛出SqlException。
void SQLMgr()
{if (rand() % 7 == 0){throw SqlException("权限不足", 100, "select * from name = '张三'");}else{cout << "SQLMgr 调用成功" << endl;}
}//CacheMgr:模拟缓存管理,随机抛出CacheException,并调用SQLMgr。
void CacheMgr()
{if (rand() % 5 == 0){throw CacheException("权限不足", 100);}else if (rand() % 6 == 0){throw CacheException("数据不存在", 101);}else{cout << "CacheMgr 调用成功" << endl;}SQLMgr();
}//HttpServer:模拟HTTP服务,随机抛出HttpException,并调用CacheMgr。
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) // 这里捕获基类,基类对象和派生类对象都可以捕获{// 多态调用// 如果没有将what()声明为虚函数,那么使用基类指针或引用调用what()时// 将始终调用基类的实现,这将不会反映出派生类的特定错误信息,从而失去多态的优势cout << e.what() << endl;}catch (...){cout << "Unkown Exception" << endl;}}return 0;
}
这种像日志一样记录,我们就可以清楚的知道了时哪一个模块的错误,这样就可以更高效的找到抛异常的地方,更快的解决问题 。
异常重新抛出
有时catch到⼀个异常对象后,需要对错误进⾏分类,其中的某种异常错误需要进⾏特殊的处理,其他错误则重新抛出异常给外层调⽤链处理。捕获异常后需要重新抛出,直接 throw ; 就可以把捕获的对象直接抛出。
// 下面程序模拟展示了聊天时发送消息,发送失败补货异常,但是可能在
// 电梯地下室等场景手机信号不好,则需要多次尝试,如果多次尝试都发
// 送不出去,则就需要捕获异常再重新抛出,其次如果不是网络差导致的
// 错误,捕获后也要重新抛出。
void _SendMsg(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{_SendMsg(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;
}
异常安全问题
- 异常抛出后,后⾯的代码就不再执⾏,前⾯申请了资源(内存、锁等),后⾯进⾏释放,但是中间可能会抛异常就会导致资源没有释放,这⾥由于异常就引发了资源泄漏,产⽣安全性的问题。中间我们需要捕获异常,释放资源后⾯再重新抛出,当然后⾯智能指针章节讲的RAII⽅式解决这种问题是更好的。
- 其次析构函数中,如果抛出异常也要谨慎处理,⽐如析构函数要释放10个资源,释放到第5个时抛出异常,则也需要捕获处理,否则后⾯的5个资源就没释放,也资源泄漏了。《Effctive C++》第8个条款也专⻔讲了这个问题,别让异常逃离析构函数。
#include <exception>double Divide(int a, int b) {if (b == 0) {throw "Division by zero condition!";}return (double)a / (double)b;
}void Func() {//std::vector<int> array(10); // 使用vector自动管理内存int* array = new int[10];try {int len, time;cin >> len >> time;cout << Divide(len, time) << std::endl;}catch (...) {// 捕获异常释放内存cout << "delete []" << array << endl;delete[] array;throw; // 异常重新抛出,捕获到什么抛出什么}cout << "delete []" << array << endl;delete[] array;
}int main() {try {Func();}catch (const char* errmsg) {std::cout << errmsg << std::endl;}catch (const std::exception& e) {std::cout << e.what() << std::endl;}catch (...) {std::cout << "Unknown Exception" << std::endl;}return 0;
}
对于Func函数中:在catch (...)
块中,捕获所有类型的异常,释放动态分配的内存(catch(...)就是为了这个),并重新抛出异常,以便外层catch
块可以进一步处理。
异常规范
-
构造函数和析构函数中的异常:
- 避免在构造函数中抛出异常:如果在构造函数中抛出异常,可能会导致对象处于不完整或未完全初始化的状态,这可能会导致资源泄漏或程序崩溃。
- 避免在析构函数中抛出异常:如果在析构函数中抛出异常,可能会导致资源释放不完全,从而引发资源泄漏。
-
异常安全:
- 在C++中,异常处理需要特别注意资源管理,以避免因异常而导致的资源泄漏问题。例如,在
new
和delete
操作中,如果抛出异常,可能会导致内存泄漏。
- 在C++中,异常处理需要特别注意资源管理,以避免因异常而导致的资源泄漏问题。例如,在
-
异常规范:
- C++委员会提出了一套建议性规范,用于明确函数可能抛出的异常类型。这包括在函数声明后使用
throw(类型)
来列出可能抛出的异常类型,或使用throw()
表示函数不抛出异常。 - 然而,这种规范较为繁琐,且在实际应用中较少使用,因为它只是一个建议,不遵循也不会导致编译错误。
- C++委员会提出了一套建议性规范,用于明确函数可能抛出的异常类型。这包括在函数声明后使用
-
C++11的改进:
- C++11引入了
noexcept
关键字,用于简化异常声明。在不会抛出异常的函数后添加noexcept
,可以明确表示该函数不会抛出异常,从而使异常声明更加简洁。
- C++11引入了
以下是一些示例代码,展示了如何在C++中应用这些最佳实践:
#include <iostream>
#include <new> // 用于std::bad_allocclass Resource {
public:Resource() {// 构造函数中避免抛出异常}~Resource() noexcept {// 析构函数中避免抛出异常// noexcept确保析构函数不会抛出异常}
};void func() noexcept {// 表示这个函数不会抛出异常
}void mayThrow() {throw std::bad_alloc(); // 可能抛出异常
}int main() {Resource r;try {mayThrow();} catch (const std::bad_alloc& e) {std::cout << "Caught bad_alloc: " << e.what() << std::endl;}return 0;
}
在这个示例中:
Resource
类的构造函数和析构函数都被设计为不会抛出异常,以避免资源泄漏和对象状态不一致的问题。func
函数使用noexcept
关键字声明,表示它不会抛出异常。mayThrow
函数可能抛出std::bad_alloc
异常,这需要在调用它的代码中进行处理。
通过遵循这些最佳实践,可以提高C++程序的健壮性和异常安全性。
int i = 0;// 检查表达式是否是noexcept
cout << noexcept(Divide(1, 2)) << endl;
cout << noexcept(Divide(1, 0)) << endl;
cout << noexcept(++i) << endl;
结果:
(0,0,1)
C语言和C++的错误处理区别
1. 错误处理方式
C语言:
- 错误码:C语言通常使用返回值或全局变量来传递错误信息。函数执行完毕后,会返回一个特定的错误码,表示操作是否成功或失败。调用者需要检查这个返回值,并根据错误码查询相应的错误信息。
- 缺点:这种方式需要调用者记住各种错误码的含义,增加了记忆负担,且代码可读性较差。
C++异常处理:
- 异常对象:C++通过抛出异常对象来处理错误。异常对象可以携带丰富的错误信息,包括错误类型、错误消息等。
- 优点:异常处理使得代码更加简洁和易于理解,因为异常对象可以包含详细的错误信息,调用者不需要记住错误码,只需要捕获异常并处理即可。
2. 代码结构和可读性
C语言:
- 错误检查:在C语言中,函数调用后通常需要检查返回值,这可能导致代码中出现大量的
if
语句,降低了代码的可读性。
C++异常处理:
- 分离错误检测和处理:在C++中,错误检测和错误处理可以完全分离。检测到错误的地方只需抛出异常,而处理异常的地方则在
catch
块中定义,这样使得代码更加模块化,提高了代码的可读性和维护性。
3. 异常的传递和处理
C语言:
- 函数调用链:在C语言中,如果一个函数发生错误,它需要返回错误码,调用者需要处理这个错误码,然后可能需要再次调用其他函数来处理错误,这形成了一个错误处理的调用链。
C++异常处理:
- 异常传播:在C++中,如果一个函数抛出异常而没有被捕获,异常会沿着调用栈向上传播,直到被一个
catch
块捕获。这种方式简化了错误处理流程,因为不需要在每个函数调用后都检查错误码。
4. 异常对象的灵活性
C++异常处理:
- 自定义异常类:C++允许开发者定义自己的异常类,这些类可以继承自
std::exception
。这样,异常对象不仅可以包含错误消息,还可以包含其他有用的信息,如错误发生的位置、时间等。
5. 性能考虑
C语言:
- 性能开销:使用错误码的方式通常没有额外的性能开销,因为不需要额外的内存分配和对象复制。
C++异常处理:
- 性能开销:异常处理可能会带来一定的性能开销,因为需要分配内存来创建异常对象,以及可能的栈展开(stack unwinding)操作。因此,在性能敏感的场合,需要谨慎使用异常。
总结来说,C++的异常处理机制提供了一种更加灵活和强大的错误处理方式,它允许开发者将错误检测和处理逻辑分离,提高了代码的可读性和维护性。然而,这种机制也带来了一定的性能开销,因此在设计程序时需要权衡使用。
异常处理的注意事项
-
提早退出:如果一个函数在执行过程中抛出了异常,并且这个异常没有在该函数内部被捕获,那么这个函数将提早退出,并且不会执行任何清理代码。因此,使用异常处理时,需要确保资源的正确管理和释放。
-
栈展开(Stack Unwinding):当异常沿着调用栈传播时,会触发栈展开过程,这个过程会销毁抛出点和捕获点之间的所有局部对象,并调用它们的析构函数。
-
性能考虑:异常处理可能会带来性能开销,因为涉及到栈展开和异常对象的拷贝。因此,在性能敏感的代码中,应该尽量避免使用异常处理。
-
异常安全:在设计程序时,应该考虑到异常安全,确保在异常发生时,程序的状态仍然保持一致,资源得到正确释放。