异常
异常
1. 传统C语言处理错误方式
传统的C语言处理错误时主要有以下两种方式:
- 直接终止程序:比如在程序内部使用 assert 进行断言,当发生内存错误、越界访问、除0错误等时就直接终止程序;这种方式的缺点是用户难以接受。
- 返回错误码:比如系统中很多库的接口函数都是通过把错误码设置到 errno 中来表示错误;这种方式的缺点是需要程序员自己去查找错误码对应的错误,不过直观。
实际中C语言基本都是使用返回错误码的方式来处理错误,部分情况下会终止程序来处理一些非常严重的错误。
2. 异常的概念及使用
2.1 异常的概念
异常也是一种处理错误的方式,当一个函数发现自己无法处理的错误时就可以抛出异常,将其交由函数的直接调用者来处理:
-
throw
:当问题出现时,程序通过throw
关键字来抛出异常;(抛出异常) -
try
:try
块中可能出现异常的代码,try
块中的代码被称为保护代码,try
后面通常限着一个或多个catch
块;(捕获异常) -
catch
:catch
关键字用于捕获throw
关键字抛出的异常,我们可以在需要处理问题的地方进行捕获,并且在其它地方可以有多个不同类型的catch
块;(捕获异常)
throw
、try
、catch
语句的语法如下:
void func()
{
//当满足某个条件时抛出异常
if ()
{
throw e;
}
}
try
{
// 保护的标识代码
func();
}
catch (ExceptionName e1)
{
// catch 块
}
catch (ExceptionName e2)
{
// catch 块
}
catch (ExceptionName eN)
{
// catch 块
}
2.3 异常的用法
2.3.1 异常的抛出和捕获
异常的抛出和捕获的匹配原则:
-
程序出现问题时,通过抛出 (throw) 一个对象来引发一个异常,该对象的类型以及当前的调用链决定了应由哪个 catch 的处理代码来处理该异常。被选中的处理代码是调用链中与该对象类型匹配且离抛出异常位置最近的那个。根据抛出对象的类型和内容,程序的抛出异常部分会知道异常处理部分到底发生了什么错误。
-
当 throw 执行时,throw 后面的语句将不再被执行。程序的执行从 throw 位置跳到与之匹配的 catch 模块,catch 可能是同一函数中的一个局部的 catch,也可能是调用链中另一个函数中的 catch,控制权从 throw 位置转移到 catch 位置。这里还有两个重要的含义:
- 沿着调用链的函数可能提前退出。
- 一旦程序开始执行异常处理,沿着调用链创建的对象都会销毁。
-
抛出异常对象,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个局部对象,所以会生成一个拷贝对象,这个拷贝的对象会在 catch 子句后销毁。(这里的处理类似于函数的传值回)
2.3.2 栈展开
- 抛出异常后,程序暂停当前函数的执行,开始寻找与之匹配的 catch 子句,
- 首先检查 throw 本身是否在 try 块内,如果在则查找匹配的 catch 语句,如果有匹配,则跳到 catch 的地方进行处理。
- 如果当前函数中没有 try/catch 子句,或者有 try/catch 子句但是类型不匹配,则退出当前函数,继续在外层调用函数链中查找,上述查找的 catch 过程被称为栈展开。
- 如果到达 main 函数,依旧没有找到匹配的 catch 子句,程序会调用标准库的 terminate 函数终止程序。
- 如果找到匹配的 catch 子句处理后,catch 子句代码会继续执行。
具体示例
比如下面的代码中 main
函数中调用了 func3
,func3
中调用了 func2
,func2
中调用了func1
,在 func1
中抛出了一个 string
类型的异常对象。
void func1()
{
throw string("这是一个异常");
}
void func2()
{
func1();
}
void func3()
{
func2();
}
int main()
{
try
{
func3();
}
catch (const string& s)
{
cout << "错误描述:" << s << endl;
}
catch (...)
{
cout << "未知异常" << endl;
}
return 0;
}
2.3.3 catch 的类型匹配问题
-
一般情况下抛出对象和 catch 是类型完全匹配的,如果有多个类型匹配的,则选择离他位置更近的那个。
-
但是也有一些例外,允许从非常量向常量的类型转换,也就是权限缩小;允许数组转换成指向数组类型的指针,函数被转换成函数的指针;允许从派生类向基类类型的转换,这个点非常实用,实际上继承体系基本都是用这个方式设计的。
-
如果到 main 函数,异常仍旧没有被匹配就会终止程序,不是发生严重错误的情况下,我们是不期望程序结束的,所以一般 main 函数中最后都会使用 catch(…),它可以捕获任意类型的异常,但是是不知道异常错误是什么。
try
{
//...
}
catch(...)
{ //三个点表示捕获任意类型异常
//throw 表示抛出任意类型的异常--捕获到什么就抛出什么
throw;
}
2.3.5 异常的重新抛出
有时候单个的 catch 可能不能完全处理一个异常,在进行一些校正处理以后,希望再交给更外层的调用链函数来处理,比如最外层可能需要拿到异常进行日志信息的记录,这时就需要通过重新抛出将异常传递给更上层的函数进行处理。
但如果直接让最外层捕获异常进行处理可能会引发一些问题。比如:
void func1()
{
throw string("这是一个异常");
}
void func2()
{
int* array = new int[10];
func1();
//do something...
delete[] array;
}
int main()
{
try
{
func2();
}
catch (const string& s)
{
cout << s << endl;
}
catch (...)
{
cout << "未知异常" << endl;
}
return 0;
}
其中 func2 中通过 new 操作符申请了一块内存空间,并且在 func2 最后通过 delete 对该空间进行了释放,但由于 func2 中途调用的 func1 内部抛出了一个异常,这时会直接跳转到 main 函数中的 catch 块执行对应的异常处理程序,并且在处理完后继续沿着 catch 块往后执行。
这时就导致 func2 中申请的内存块没有得到释放,造成了内存泄露。这时可以在 func2 中先对 func1 抛出的异常进行捕获,捕获后先将申请到的内存释放再将异常重新抛出,这时就避免了内存泄露。比如:
void func2()
{
int* array = new int[10];
try
{
func1();
//do something...
}
catch (...)
{
delete[] array;
throw; //将捕获到的异常再次重新抛出
}
delete[] array;
}
补充:
-
func2 中的 new 和 delete 之间可能还会抛出其他类型的异常,因此在 fun2 中最好以 catch(…) 的方式进行捕获,将申请到的内存 delete 后再通过 throw 重新抛出。
-
重新抛出异常对象时,throw 后面可以不用指明要抛出的异常对象(正好也不知道以 catch(…) 的方式捕获到的具体是什么异常对象)。
2.3.6 异常安全问题
-
异常抛出后,后面的代码就不再执行,前面申请了资源(内存、锁等),后面进行释放,但是中间可能会抛出异常就会导致资源没有释放,这里由于异常引发了资源泄漏,产生安全性的问题。中间需要捕获异常,释放资源后再重新抛出,当然后面智能指针讲的RAII方式解决这种问题是更好的。
-
其次构造函数中,如果抛出异常也要谨慎处理,比如构造函数要释放10个资源,释放到第5个时抛出异常,则也需要捕获处理,否则后面的5个资源就没释放,也资源泄漏了。《Effective C++》第8个条款也专门讲了这个问题,别让异常逃离析构函数。
double Divide(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 << Divide(len, time) << endl;
}
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;
}
catch (const exception& e)
{
cout << e.what() << endl;
}
catch (...)
{
cout << "Unkown Exception" << endl;
}
return 0;
}
2.3.7 异常规范
C++98:
由于不规范使用异常会带来非常严重的后果,所以 C++98 引入了异常规范,异常规范中建议程序员对每个函数进行异常接口说明,且其目的是让函数使用者知道该函数可能抛出哪些异常,具体规范使用如下:
-
通过在函数的后面接
throw(类型)
,来列出这个函数可能抛出的所有异常类型。 -
如果函数不抛异常,则在函数的后面接
throw()
。 -
若无异常声明,则此函数可以抛掷任何类型的异常,也可以不抛异常。
// 这里表示这个函数会抛出A/B/C/D中的某种类型的异常
void fun() throw(A, B, C, D);
// 这里表示这个函数只会抛出bad_alloc的异常
void* operator new (std::size_t size) throw (std::bad_alloc);
// 这里表示这个函数不会抛出异常
void* operator delete (std::size_t size, void* ptr) throw();
但是由于 C++98 函数异常接口只是建议性做法,而不是语法硬性要求,同时还由于写出一个函数可能抛出的所有异常比较麻烦,所以 C++98 的异常规范在实际开发中几乎没有人遵守,形同虚设。
C++11:
为了让人能够明确进行异常规范说明,C++11 对异常接口说明进行了简化:
-
函数后面不加关键字
noexcept
,表示该函数可能抛出任意类型异常; -
函数后面加关键字
noexcept
,表示该函数不会抛异常。
// C++11 中新增的 noexcept,表示不会抛异常
thread() noexcept;
thread(thread&& x) noexcept;
并且 C++11 还对使用 noexcept 修饰的函数进行了检查,如果该函数被 noexcept 修饰,但是可能会抛出异常,则编译器会报一个警告,但并不影响程序的正确性。
2.4 自定义异常体系
实际上很多公司都会自定义自己的异常体系统进行异常管理。
-
公司中的项目一般会进行模块划分,让不同的程序模块或小程序完成不同的模块,如果不对抛出异常事件进行规划,那么负责模块层抛出异常的程序员将无法知道因为需要抛出各类型的异常。
-
因此实际公司定义了一套集中的规范体系,首先定义一个最基础的异常类,所有人抛出的异常都必须继承该类异常的最低类型,因为异常处理可以仅基类异常抛出的最低类型异常,因此最基础类的异常类型就抛出了。
如下图:
最基础的异常类至少需要包含错误编号和错误描述两个成员变量,甚至还可以包含当前函数栈帧的调用链等信息。该异常类中一般还会提供两个成员函数,分别用来获取错误编号和错误描述。比如:
class Exception
{
public:
Exception(int errid, const char* errmsg)
:_errid(errid)
, _errmsg(errmsg)
{}
int GetErrid() const
{
return _errid;
}
virtual string what() const
{
return _errmsg;
}
protected:
int _errid; //错误编号
string _errmsg; //错误描述
//...
};
其他模块如果要对这个异常类进行扩展,必须继承这个基础的异常类,可以在继承后的异常类中按需添加某些成员变量,或是对继承下来的虚函数 what 进行重写,使其能告知程序员更多的异常信息。比如:
class CacheException : public Exception
{
public:
CacheException(int errid, const char* errmsg)
:Exception(errid, errmsg)
{}
virtual string what() const
{
string msg = "CacheException: ";
msg += _errmsg;
return msg;
}
protected:
//...
};
class SqlException : public Exception
{
public:
SqlException(int errid, const char* errmsg, const char* sql)
:Exception(errid, errmsg)
, _sql(sql)
{}
virtual string what() const
{
string msg = "CacheException: ";
msg += _errmsg;
msg += "sql语句: ";
msg += _sql;
return msg;
}
protected:
string _sql; //导致异常的SQL语句
//...
};
class CacheException : public Exception
{
public:
CacheException(int errid, const char* errmsg)
:Exception(errid, errmsg)
{}
virtual string what() const
{
string msg = "CacheException: ";
msg += _errmsg;
return msg;
}
protected:
//...
};
class SqlException : public Exception
{
public:
SqlException(int errid, const char* errmsg, const char* sql)
:Exception(errid, errmsg)
, _sql(sql)
{}
virtual string what() const
{
string msg = "CacheException: ";
msg += _errmsg;
msg += "sql语句: ";
msg += _sql;
return msg;
}
protected:
string _sql; //导致异常的SQL语句
//...
};
说明一下:
- 异常类的成员变量不能设置为私有,因为私有成员在子类中是不可见的。
- 基类Exception中的what成员函数最好定义为虚函数,方便子类对其进行重写,从而达到多态的效果。
2.5 C++标准库的异常体系
C++标准库也定义了一套自己的异常继承体系,基类是 exception,所以我们日常写程序,需要在主函数捕获 exception 即可,要获取异常信息,调用 what 函数,what 是一个虚函数,派生类可以重写。
下表是对上面继承体系中出现的每个异常的说明:
异常 | 描述 |
---|---|
std::exception | 该异常是所有标准C++异常的父类。 |
std::bad_alloc | 该异常可以通过 new 抛出。 |
std::bad_cast | 该异常可以通过 dynamic_cast 抛出。 |
std::bad_exception | 这是处理C++程序中无法预期的异常的非常常用的异常。 |
std::bad_typeid | 该异常可以通过 typeid 抛出。 |
std::logic_error | 理论上可以通过运行时代码逻辑检测到的异常。 |
std::domain_error | 当使用了一个无效的数学区域时,会抛出该异常。 |
std::invalid_argument | 当使用了无效的参数时,会抛出该异常。 |
std::length_error | 当创建了太长的 std::string 时,会抛出该异常。 |
std::out_of_range | 该异常可以通过方法抛出,例如 std::vector 和 std::bitset ::operator。 |
std::runtime_error | 理论上可以通过读取代码并检测到的异常。 |
std::overflow_error | 当发生数学溢出时,会抛出该异常。 |
std::range_error | 当尝试存储超出范围的值时,会抛出该异常。 |
std::underflow_error | 当发生数学下溢时,会抛出该异常。 |
补充:
- exception 类的 what 成员函数和析构函数都定义成了虚函数,方便子类对其进行重写,从而达到多态的效果。
- 实际中程序员也可以去继承 exception 类来实现自己的异常类,但实际中很多公司都会自己定义一套异常继承体系。