异常
传统错误处理机制是assert
断言和errno
错误码。两种方式都有很大的局限性:
错误处理机制 | 局限性 |
---|---|
断言 | 强制终止程序,用户难以接受 |
错误码 | 返回值传递错误码,占用函数返回位置;无法直接展示信息,需查错误码表 |
1. 异常的概念
异常是面向对象语言对错误的处理机制。更加灵活和全面。
当一个函数发现自己无法处理错误时就可以抛出异常,让函数的调用者处理这个错误。
关键字 | 含义 |
---|---|
throw | 使用throw 可以抛出一个异常 |
catch | 在处理问题的地方,使用catch 用来捕获并处理异常。 |
try | try 块中的代码将被激活的特定异常,它后面通常跟着一个或多个catch 块。 |
异常机制可以帮助我们将异常检测和处理机制解耦,这样异常检测部分可以不必知道异常如何处理。
int Division(int a, int b)
{
if (b == 0)
throw "Division by zero condition";
else
return a / b;
}
void Func()
{
int a, b;
cin >> a >> b;
cout << Division(a, b) << endl;
}
int main()
{
try
{
Func();
}
catch (const char* errmsg)
{
cerr << errmsg << endl;
}
catch (...)
{
cerr << "Unkown Exception" << endl;
}
return 0;
}
2. 异常的使用
2.1抛出和捕获规则
- 异常时通过抛出对象而引发的,该对象的类型决定了应该激活哪个catch的处理代码。
- 被选中的处理代码的调用链是,找到于该类型匹配且离抛出异常位置最近的那一个catch。
- 抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个临时对象,所以会生成一个拷贝对象。
catch(...)
可以捕获任意类型的对象,主要是用来捕获没有显示捕获类型的异常,因为如果没有匹配的catch会终止程序。相当于条件判断中的else。问题是不知道异常错误是什么。- 实际中抛出和捕获的类型不一定类型完全匹配,可以抛出派生类对象,使用基类来捕获,这个在实际生活中很实用。主要原因是:派生类可以赋值给基类。
实际工程中也都是用自定义异常类,用父类对象捕获子类对象,这非常实用。
try
{
throw memory_exception; // 子类
}
catch (const exception& e) // 父类
{
}
函数调用链展开异常匹配原则
- 首先检查
throw
本身是否在try
块内部,如果是,再在当前函数栈中查找匹配的catch
语句。如果有匹配的直接跳到catch
的地方执行。 - 如果没有匹配的
catch
块,则退出当前函数栈,在调用函数的栈中查找匹配的catch
。 - 如果到达
main
函数的栈,都没有匹配的catch
,就会终止程序。上述沿着调用链查找匹配的catch
块的过程叫栈展开。所以实际要最后要加一个catch(...)
来捕获任意类型的异常,防止程序终止。 - 找到匹配的
catch
会直接跳到catch
语句执行,执行完后,会继续沿着catch
语句后面执行。
异常的重新抛出
有可能单个的catch不能完全处理一个异常,在进行一些校正处理以后,希望再交给更外层的调用链函数来处理,catch则可以通过重新抛出将异常传递给更上层的函数进行处理。
一般在大型项目中都是在最外层统一进行处理异常,但资源的释放需要在当前层完成,为防止异常跳过资源释放,可以在当前层捕获并释放再抛出,再在最外层重新捕获并处理。
void Func()
{
try {
some();
}
catch (...) {
delete resource; // 释放资源
throw; // 重新抛出
}
}
int main()
{
try {
Func();
}
catch(...) {
HandlerException(); // 处理异常
}
}
2.2 异常安全和规范
C++ 中经常会出现异常导致资源泄漏的问题。比如:
在new
和delete
中间抛出异常,可能导致内存泄漏。在lock
和unlock
中间抛出异常,可能导致死锁。
异常规范
针对异常,C++标准也有规范,
- 如果函数不会抛异常可以在其声明后加上关键字
noexcept
。
加上noexcept的效果:
- 在函数声明后加上
throw(...)
表明该函数可能抛出的异常类型。
// 表示函数可能抛出的异常有指定类型
void Func() throw(int, char, string, vector);
// 表示函数只可能会抛出std::bad_alloc一种异常
void New(size_t size) throw (std::bad_alloc);
// 表示函数不会抛出异常
void Delete(size_t size, void* ptr) throw();
编译器不对这两条声明作检查。
异常安全
异常处理在 C++ 中是一项重要的任务,但它可能导致一些异常安全问题。这些问题可能影响程序的正确性和资源管理。以下是一些常见的异常安全问题和解决方法:
- 在构造函数中抛出异常
构造函数负责对象的构造和初始化。如果在构造函数中抛出异常,可能导致对象状态不完整或者没有完全初始化。这会引发对象管理和状态不一致的问题。
解决方法:
尽量避免在构造函数中抛出异常。
如果必须在构造函数中处理可能引发异常的操作,考虑使用初始化列表、工厂模式或者智能指针等方式来确保异常安全。
- 在析构函数中抛出异常
析构函数主要负责资源的清理工作。如果在析构函数中抛出异常,可能导致资源无法正确释放,进而引发内存泄漏或者其他资源泄漏问题。
解决方法:
尽量避免在析构函数中抛出异常。
如果需要在析构函数中进行可能引发异常的操作,确保在异常处理过程中不会引发更严重的问题,比如使用安全的资源管理技术。
- 资源泄漏问题
C++ 异常经常会导致资源泄漏问题,比如在 new 和 delete 中抛出异常导致内存泄漏,或者在 lock 和 unlock 中抛出异常导致死锁。
解决方法:
使用异常捕获,在捕获异常后释放资源并重新抛出异常,以确保资源得到释放。
使用 RAII(资源获取即初始化)的思想解决资源管理问题。定义一个资源管理类,在其析构函数中释放资源,利用栈对象的生命周期来管理资源的获取和释放。
异常安全问题是 C++ 中需要特别注意的一点,合理的异常处理策略可以提高程序的稳定性和可靠性。
总结:
- 不要在构造析构函数内抛出异常,以防对象初始化或释放不全。
- 对于异常导致的资源泄漏、死锁等问题,通常使用锁守卫和智能指针来初始化和析构。
3. 自定义异常体系
实际项目中不可随便抛任意类型的异常,这样会增加处理难度。很多公司都会定义自己的异常类体系进行规范的异常管理。
实际工程中也都是自定义异常类体系,用父类对象捕获子类对象,这非常实用。
class Exception
{
public:
Exception(const string& errmsg, int id)
:_errmsg(errmsg), _id(id)
{}
virtual string What() const {
return _errmsg;
}
protected:
string _errmsg;
int _id;
};
class SqlException : public Exception
{
public:
SqlException(const string& errmsg, int id, const string sql)
: Exception(errmsg, id), _sql(sql)
{}
virtual string What() const {
return "SqlException: " + _sql + " : " + _errmsg;
}
private:
string _sql;
};
class CacheException : public Exception
{
public:
CacheException(const string& errmsg, int id)
: Exception(errmsg, id)
{}
virtual string What() const {
return "CacheException: " + _errmsg;
}
};
class HttpException : public Exception
{
public:
HttpException(const string& errmsg, int id, const string& type)
: Exception(errmsg, id), _type(type)
{}
virtual string What() const {
return "HttpException: " + _type + " : " + _errmsg;
}
private:
string _type;
};
模拟网络服务体系:
void SqlMgr()
{
//...
if (rand() % 7 == 0)
throw SqlException("sql sytanx wrong!", 100, "some error sql");
if (rand() % 8 == 0)
throw SqlException("permission denied!", 101, "select * from secret");
//...
throw "Unkown Exception";
}
void CacheMgr()
{
//...
if (rand() % 5 == 0)
throw CacheException("insufficient space!", 100);
if (rand() % 6 == 0)
throw CacheException("permission denied!", 101);
//...
SqlMgr();
}
void HttpMgr()
{
//...
if (rand() % 3 == 0)
throw HttpException("resource is not exist!", 100, "post");
if (rand() % 4 == 0)
throw HttpException("permission denied!", 101, "post");
//...
CacheMgr();
}
void ServerStart()
{
srand((unsigned int)time(nullptr));
while (1)
{
this_thread::sleep_for(chrono::seconds(1));
try {
HttpMgr();
}
catch (const Exception& e) {
cout << e.What() << endl;
}
catch (...) {
cout << "Unkown Exception" << endl;
}
}
}
int main()
{
ServerStart();
return 0;
}
4. 标准库的异常体系
C++标准库也提供了标准异常类std::exception。
继承体系结构图如下:
异常 | 描述 |
---|---|
std::exception | 该异常是所有标准异常的父类 |
std::bad_alloc | new出现异常,内存不足 |
std::bad_cast | dynamic_cast出现异常 |
std::bad_exception | 出现无法预期的异常 |
std::bad_typeid | typeid出现异常 |
std::logic_error | 逻辑错误 |
std::domain_error | 无效的数学域异常 |
std::invalid_argument | 无效的参数异常 |
std::length_error | td::string出现异常,字符串太长 |
std::out_of_range | 越界访问 |
std::runtime_error | 运行时异常 |
std::overflow_error | 溢出异常 |
std::range_error | 存储超出范围异常 |
std::underflow_error | 数学下溢异常 |
5. 异常的优缺点
异常的优点
- 清晰准确地展示出各种错误信息: 异常对象可以清晰地描述错误,包括错误的详细信息和堆栈跟踪,这有助于更精确地定位和解决问题。
- 不需要层层返回,一次性到达异常处理的地方: 异常处理机制允许我们在错误发生时直接跳转到异常处理代码,而不需要在每个函数调用中返回错误代码,简化了代码结构。
- 不占用函数的返回位置: 对于那些不方便返回错误码的函数,异常提供了一个很好的替代方案,不影响函数的返回值。
异常的缺点
- 异常可能导致执行流乱跳,易混乱: 异常会改变正常的程序执行路径,导致代码的执行流变得难以预测和跟踪,增加了代码的复杂性。
- 异常容易导致资源泄漏,需要智能指针管理资源: 由于C++没有垃圾回收,异常可能导致资源未被释放,如内存泄漏或未关闭的文件和锁。使用智能指针(如
std::unique_ptr
和std::shared_ptr
)可以自动管理资源,增加异常安全性。 - 标准库的异常体系定义得不好,导致没有规范的异常体系: C++标准库的异常体系可能不完善,这导致开发者需要自己定义和管理异常类型,增加了开发的复杂性。
对比表格:
异常的优点 | 异常的缺点 |
---|---|
清晰准确地展示出各种错误信息 | 异常可能导致执行流乱跳,易混乱 |
不需要层层返回,一次性到达异常处理的地方 | 异常容易导致资源泄漏,需要智能指针管理资源 |
不占用函数的返回位置 | 标准库的异常体系定义得不好,导致没有规范的异常体系。 |