在介绍异常之前,我觉得很有必要带大家了解一下运行时错误和c++异常出现之前的处理运行时错误的方式。这样子能更深入的了解异常的作用和工作原理
运行阶段错误
我们知道,程序有时候会遇到运行阶段错误,导致程序无法正常运行下去
C++在运行时可能会出现多种错误,这些错误被称为运行时错误或异常。
以下是一些常见的C++运行时错误:
-
数组越界:当程序尝试访问数组的索引超过数组范围时,会发生数组越界错误。这可能导致程序崩溃或产生未定义行为。
-
空指针引用:当程序试图访问一个空指针指向的内存位置时,会发生空指针引用错误。这通常是因为没有正确初始化指针或者指针被删除后仍然被引用。
-
除以零:当程序试图执行除以零的操作时,会发生除以零错误。这通常会导致程序崩溃或产生未定义行为。
-
内存泄漏:当程序分配了内存但没有正确释放时,会发生内存泄漏。如果内存泄漏问题严重,会导致程序在运行过程中耗尽可用内存而崩溃。
-
类型转换错误:当程序试图执行非法或不兼容的类型转换时,会发生类型转换错误。这可能导致程序产生不正确的结果或崩溃。
-
文件操作错误:当程序试图打开一个不存在的文件、读取写入超过文件范围的数据或者在不允许的情况下对文件进行操作时,会发生文件操作错误。
为了处理这些运行时错误,可以使用C++的异常机制。通过捕获和处理异常,可以使程序在出现错误时能够优雅地处理,并在需要时进行错误恢复或退出。
异常出现之前的处理错误的方式
讨论异常之前,先来看看程序员可使用的一些处理运行时错误的方法
先来看一个除0错误
cout<<7/x;
如果x=0,这条语句就变成了7/0,但是我们知道0是不能做被除数的
如果我们不做任何检查和处理,看看编译器会怎么处置这么一种情况
对于被0除的情况,很多新式编译器通过生成一个表示无穷大的特殊浮点值来处理,cout将这种值显示为Inf,inf,INF或者类似的东西;
而其他编译器可能生成在发生被0除时崩溃的程序。
那我们怎么来防止这种错误的发生呢?有两种最常用的方法
调用abort()
我们先来了解一下abort函数吧!
abort()简介
在C++中,abort()
函数用于终止程序的执行。
abort()
函数位于头文件<cstdlib>中,函数原型如下:
void abort (void);
调用abort()
函数会导致程序立即终止,并生成一个异常终止信号并将其发送到标准错误流。
程序的终止是非正常的,它不会执行任何的析构函数、清理操作等。
通常情况下,abort()
函数被用于处理严重错误或异常情况,强制终止程序以避免进一步的损害。
示例使用abort()
函数:
#include <cstdlib>
int main() {
int a = 0;
if (a == 0) {
abort(); // 如果 a 等于 0,强制终止程序
}
return 0;
}
在上面的示例中,如果a
的值等于0,则调用abort()
函数终止程序的执行。
运行结果如下
解决问题
对于上面那个问题,处理的方式之一就是,如果x==0,就调用abort函数
#include<iostream>
using namespace std;
#include <cstdlib>
int main() {
int a;
scanf("%d", &a);
if (a == 0) {
abort(); // 如果 a 等于 0,强制终止程序
}
cout<< 7 / a;
return 0;
}
我们输入1,运行结果是
我们输入0,运行结果是
返回错误码
一种比异常终止更灵活的方法是,使用函数的返回值来指出问题。
比如ostream类的get()成员通常返回下一个输入字符的ASCII码,但到文件尾时,将返回特殊值EOF.
我们还可以看个更通俗易懂的例子
#include <iostream>
int divide(int a, int b) {
if (b == 0) {
// 返回错误码 1,表示除数为零错误
return 1;
}
// 执行除法操作并返回结果
return a / b;
}
int main() {
int a = 10;
int b = 0;
int result = divide(a, b);
if (result == 0) {
// 处理除法操作成功的情况
std::cout << "除法操作结果:" << result << std::endl;
} else {
// 处理除数为零错误
std::cout << "除数为零错误!" << std::endl;
}
return 0;
}
在上面的示例中,divide()
函数用于执行两数相除操作,并检查除数是否为零。如果除数为零,则返回错误码1,否则返回相除的结果。在main()
函数中,首先将10除以0,然后根据返回的错误码来判断函数执行的状态。如果返回的错误码为0,则说明除法操作成功,可以打印结果。否则,说明除数为零错误,可以相应地处理错误情况。
需要注意的是,上面的示例只是简单示例,实际应用中可能有更复杂的错误码及错误处理机制。
解决问题
对于上面那个问题,我们直接定义一个判断函数即可
#include<iostream>
using namespace std;
int A(int a)
{
if (a == 0)
return 0;
else
return 1;
}
int main() {
int a;
scanf("%d", &a);
int b = A(a);
if(b==1)
cout<< 7 / a;
else
cout << "a不能为0" << endl;
}
这样子就解决了
C++异常概念
相信看完上面的例子,你已经知道异常的作用大概是什么了
异常是相对较新的C++功能,有些老式编译器可能没有实现。另外有些编译器默认关闭这种特性,你可能需要使用编译器选项来启用它
异常是面向对象语言常用的一种处理错误的方式,当一个函数发现自己无法处理的错误时就可以抛出异常,让函数直接或间接的调用者处理这个错误。
对异常的处理有3部分组成
- 引发异常(throw)
- 使用处理程序捕获异常(catch)
- 使用try块(try)
throw
当程序出现问题时,可以通过throw关键字抛出一个异常。
throw关键字表示引发异常,紧随其后的值指出了异常的特征,它的类型也叫异常类型
异常类型可以是任何类型,但是通常是类类型
throw语句实际上是跳转,即命令程序跳到另一条语句(跳到catch块)。
throw通常会被放在一个函数里,而这个函数通常被放在try块里面,执行throw语句类似于执行返回语句,因为它也会终止函数的执行,
但throw不是将控制权返回给调用程序,而是导致程序沿函数调用序列后退,直到找到包含try块的函数,把控制权交回给这个函数。然后再在有控制权的这个函数里寻找与引发异常类型匹配的异常处理程序(即catch块)
throw异常;
catch
如果try块中发生错误,则可以在catch块中定义对应要执行的代码块。
程序使用异常处理程序(也叫catch块)来捕获异常,异常处理程序位于要处理问题的程序中。
异常处理程序以关键字catch开头,随后是位于括号的类型声明,它指出了这个catch块要响应的异常类型;然后是一个用花括号括起的代码块,指出要采取的措施。
catch(这个catch块对应的异常类型)
{
处理异常的措施
}
这样子感觉有点像函数定义啊,但是它不是函数定义
catch关键字和异常类型用作标签,指出当异常被引发时(即执行了throw语句后),程序应该跳到这个位置执行。
try
try块是由关键字try指示的,关键字try后面是一个由花括号括起的代码块,表明需要注意这些代码引发的异常(即表明这个代码块里有throw语句)
try
{
//里面含有throw语句
}
该代码块在执行时将进行异常错误检测,try块里面通常含有throw语句,后面通常跟着一个或多个catch块。
如下所示:
try
{
含throw语句
//被保护的代码
}
catch (ExceptionName e1)
{
//catch块
}
catch (ExceptionName e2)
{
//catch块
}
catch (ExceptionName eN)
{
//catch块
}
使用介绍
我们还是以上面那个除0错误为例来展示异常机制
#include<iostream>
using namespace std;
int A(int a)
{
if (a == 0)
throw"a不能为0";
else
return a;
}
int main() {
int a;
scanf("%d", &a);
try
{
int b = A(a);
}
catch (const char* s)
{
cout << s << endl;
}
cout << 7 / a;
}
我们来看看这里面的机制是怎么样的呢?
如果输入0,a被传进入A函数,触发了throw语句,throw将其后面的字符串抛出,回到try块下面开始寻找与字符串类型相符合的catch块,然后执行其中的内容
我们如果输入1,a被传进A函数,并没有触发throw语句,所以程序将跳过try块后面的catch块,直接处理程序后面的第一条语句
执行throw语句类似于执行返回语句,因为它也会终止函数的执行,
但throw不是将控制权返回给调用程序,而是导致程序沿函数调用序列后退,直到找到包含try块的函数,把控制权交回给这个函数。然后再在有控制权的这个函数里寻找与引发异常类型匹配的异常处理程序(即catch块)
在这个例子中throw将程序控制权返回给main函数,程序将在main函数寻找与引发异常类型匹配的异常处理程序(即catch块)
异常的用法
异常的抛出和捕获
异常的抛出和捕获的匹配原则:
准则一
异常是通过抛出异常类型(通常是对象)而引发的,该异常类型(通常是对象)决定了应该激活哪个catch的处理代码,如果抛出的异常对象没有捕获,或是没有匹配类型的捕获,那么程序会终止报错。
相匹配的例子
#include<iostream>
using namespace std;
int A(int a)
{
if (a == 0)
throw"a不能为0";
else
return a;
}
int main() {
int a;
scanf("%d", &a);
try
{
int b = A(a);
}
catch (const int* s)
{
cout << "第一个" << endl;
}
catch (const char* s)
{
cout << "第二个" << endl;
}
cout << 7 / a;
}
没有相匹配的例子
#include<iostream>
using namespace std;
int A(int a)
{
if (a == 0)
throw"a不能为0";
else
return a;
}
int main() {
int a;
scanf("%d", &a);
try
{
int b = A(a);
}
catch (const int* s)
{
cout << "第一个" << endl;
}
catch (const float* s)
{
cout << "第二个" << endl;
}
cout << 7 / a;
}
准则二
被选中的处理代码(catch块)是调用链中与该对象类型匹配且离抛出异常位置最近的那一个。
看个例子
#include<iostream>
using namespace std;
int A(int a)
{
if (a == 0)
throw"a不能为0";
else
return a;
}
int main() {
int a;
scanf("%d", &a);
try
{
int b = A(a);
}
catch (const char* s)
{
cout << "第一个" << endl;
}
catch (const char* s)
{
cout << "第二个" << endl;
}
cout << 7 / a;
}
异常只能被第一个捕获
准则三
抛出异常对象后,编译器总会生成一个异常对象的拷贝,即使异常规范和catch块指定的是引用,这个拷贝的临时对象会在被catch以后销毁。(类似于函数的传值返回)
class AA{...}
...
void A()
{
AA a;
if(...)
{
throw a;
}
...
}
try
{
A();
}
catch(AA&t)
{...}
t将指向a的副本而不是a本身。这是件好事,因为函数A执行完后a将不复存在。
准则四
catch(...)可以捕获任意类型的异常,但捕获后无法知道异常错误是什么。
catch(...)通常被放在众多catch块的最后面,防止程序因为没有相匹配的异常类型而导致停止运行
#include<iostream>
using namespace std;
int A(int a)
{
if (a == 0)
throw"a不能为0";
else
return a;
}
int main() {
int a;
scanf("%d", &a);
try
{
int b = A(a);
}
catch (char a)
{
cout << "第一个" << endl;
}
catch (...)
{
cout << "未知错误" << endl;
}
cout << 7 / a;
}
准则五
实际异常的抛出和捕获的匹配原则有个例外,捕获和抛出的异常类型并不一定要完全匹配,可以抛出派生类对象,使用基类进行捕获,这个在实际中非常有用。但是也会带来一些问题。
#include<iostream>
using namespace std;
class AA
{};
class BB:public AA
{};
class CC:public BB
{};
int A(int a)
{
CC m;
if (a == 0)
throw m;
else
return a;
}
int main() {
int a;
scanf("%d", &a);
try
{
int b = A(a);
}
catch (AA&t)//改成BB&t或者CC&t也可以匹配到
{
cout << "第一个" << endl;
}
cout << 7 / a;
}
使用基类引用则可以捕获抛出的该基类和所有派生类的异常,而使用派生类引用则只能捕获它所属类及从这个类派生来的类对象。
而引发的异常对象将先和第一个与之匹配的catch块捕获。这意味着catch块的排列顺序应该和派生顺序相反。
如果有一个异常类继承层次结构,应该这样排列catch块:将捕获位于层次结构最下面的异常类的catch语句放在最前面,将捕获基类异常的catch语句放在最后面
#include<iostream>
using namespace std;
class AA
{};
class BB:public AA
{};
class CC:public BB
{};
int A(int a)
{
BB m;
if (a == 0)
throw m;
else
return a;
}
int main() {
int a;
scanf("%d", &a);
try
{
int b = A(a);
}
catch (CC&t)
{
cout << "第一个" << endl;
}
catch (BB& t)//改成BB&t或者CC&t也可以匹配到
{
cout << "第二个" << endl;
}
catch (AA& t)
{
cout << "第三个" << endl;
}
cout << 7 / a;
}
栈解退
假设 try块没有直接调用引发异常的函数,而是调用了对引发异常的函数进行调用的函数,则程序流程将从引发异常的函数跳到包含try块和处理程序的函数。
这涉及到栈解退(unwinding the stack),下面进行介绍。
c++函数调用和返回
首先来看一看C++通常是如何处理函数调用和返回的。
C++通常通过将信息放在栈中来处理函数调用。
具体地说,程序将调用函数的指令的地址(返回地址)放到栈中。当被调用的函数执行完毕后,程序将使用该地址来确定从哪里开始继续执行。
另外,函数调用将函数参数放到栈中。在栈中,这些函数参数被视为自动变量。如果被调用的函数创建了新的自动变量,则这些变量也将被添加到栈中。如果被调用的函数调用了另一个函数,则后者的信息将被添加到栈中,依此类推。
当函数结束时,程序流程将跳到该函数被调用时存储的地址处,同时栈顶的元素被释放。
因此,函数通常都返回到调用它的函数,依此类推,同时每个函数都在结束时释放其自动变量。如果自动变量是类对象,则类的析构函数(如果有的话)将被调用。
栈解退
现在假设函数由于出现异常(而不是由于返回)而终止,则程序也将释放栈中的内存,但不会在释放栈的第一个返回地址后停止,而是继续释放栈,直到找到一个位于try块中的返回地址。随后,控制权将转到块尾的异常处理程序(即catch块),而不是函数调用后面的第一条语句。
这个过程被称为栈解退。
引发机制一个非常重要的特性是,和函数返回一样,对于栈中的自动类对象,类的析构函数将被调用。然而,函数返回仅仅处理该函数放在栈中的对象,而throw语句则处理try块和throw之间整个函数调用放在栈中的对象。(功能基本相同,范围不同)
如果没有栈解退这种特性,则引发异常后,对于中间函数调用放在栈中的自动类对象,其析构函数将不会被调用。
栈解退的基本过程
- 当异常被抛出后,首先检查throw本身是否在try块内部,编译器就要先找到try块在的那个函数栈里,如果在则查找匹配的catch语句,如果有匹配的,则跳到catch的地方进行处理。
- 如果当前函数栈没有找到try块,则退出当前函数栈,继续在上一个调用函数栈中进行查找try块。依次类推,直到找到了try语句,然后在try块下面,找到匹配的catch子句并处理以后,会沿着catch子句后面继续执行。
- 如果到达main函数的栈,依旧没有找到try块,则终止程序。
比如下面的代码中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;
}
当func1中的异常被抛出后:
首先会检查throw本身是否在try块内部,这里由于try块不在func1函数内,因此会退出func1所在的函数栈,继续在上一个调用函数栈中进行查找,即func2所在的函数栈。
由于func2中也没有找到try块,因此会继续在上一个调用函数栈中进行查找,即func3所在的函数栈。
func3中也没有找到try块,于是就会在main所在的函数栈中进行查找,最终在main函数栈中找到了try块,然后在try块后面寻找匹配的catch。
这时就会跳到main函数中对应的catch块中执行对应的代码块,执行完后继续执行该代码块后续的代码。
异常的重新抛出
有时候单个的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(...)的方式捕获到的具体是什么异常对象)。
异常安全
将抛异常导致的安全问题叫做异常安全问题,对于异常安全问题下面给出几点建议:
- 构造函数完成对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象不完整或没有完全初始化。
- 析构函数主要完成对象资源的清理,最好不要在析构函数中抛出异常,否则可能导致资源泄露(内存泄露、句柄未关闭等)。
- C++中异常经常会导致资源泄露的问题,比如在new和delete中抛出异常,导致内存泄露,在lock和unlock之间抛出异常导致死锁,C++经常使用RAII的方式来解决以上问题。
异常规范
为了让函数使用者知道某个函数可能抛出哪些类型的异常,C++标准规定:
在函数的后面接throw(type1, type2, ...),列出这个函数可能抛掷的所有异常类型。
在函数的后面接throw()或noexcept(C++11),表示该函数不抛异常。
若无异常接口声明,则此函数可以抛掷任何类型的异常。(异常接口声明不是强制的)
比如:
//表示func函数可能会抛出A/B/C/D类型的异常
void func() throw(A, B, C, D);
//表示这个函数只会抛出bad_alloc的异常
void* operator new(std::size_t size) throw(std::bad_alloc);
//表示这个函数不会抛出异常
void* operator new(std::size_t size, void* ptr) throw();
自定义异常体系
实际中很多公司都会自定义自己的异常体系进行规范的异常管理。
公司中的项目一般会进行模块划分,让不同的程序员或小组完成不同的模块,如果不对抛异常这件事进行规范,那么负责最外层捕获异常的程序员就非常难受了,因为他需要捕获大家抛出的各种类型的异常对象。
因此实际中都会定义一套继承的规范体系,先定义一个最基础的异常类,所有人抛出的异常对象都必须是继承于该异常类的派生类对象,因为异常语法规定可以用基类捕获抛出的派生类对象,因此最外层就只需捕获基类就行了。
最基础的异常类至少需要包含错误编号和错误描述两个成员变量,甚至还可以包含当前函数栈帧的调用链等信息。该异常类中一般还会提供两个成员函数,分别用来获取错误编号和错误描述。比如:
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语句
//...
};
说明一下:
异常类的成员变量不能设置为私有,因为私有成员在子类中是不可见的。
基类Exception中的what成员函数最好定义为虚函数,方便子类对其进行重写,从而达到多态的效果。
异常的优缺点
异常的优点:
- 异常对象定义好了,相比错误码的方式可以清晰准确的展示出错误的各种信息,甚至可以包含堆栈调用等信息,这样可以帮助更好的定位程序的bug。
- 返回错误码的传统方式有个很大的问题就是,在函数调用链中,深层的函数返回了错误,那么我们得层层返回错误码,最终最外层才能拿到错误。
- 很多的第三方库都会使用异常,比如boost、gtest、gmock等等常用的库,如果我们不用异常就不能很好的发挥这些库的作用。
- 很多测试框架也都使用异常,因此使用异常能更好的使用单元测试等进行白盒的测试。
- 部分函数使用异常更好处理,比如T& operator这样的函数,如果pos越界了只能使用异常或者终止程序处理,没办法通过返回值表示错误。
异常的缺点:
C++异常的缺点包括以下几点:
-
性能开销:在抛出异常时,C++需要执行一些额外的操作,比如堆栈展开和资源清理。这些操作可能会导致性能下降,尤其是在频繁出现异常的情况下。
-
不适合底层开发:在底层开发中,对性能要求非常高,因此异常处理可能不适用。异常处理需要一定的处理逻辑和系统开销,这对于低级别的系统编程来说可能是不可接受的。
-
可能引发资源泄露:如果异常没有被正确处理,可能会导致资源泄露。如果在异常发生时没有及时释放资源,可能会导致内存泄露、文件句柄泄露等问题。
-
可能引发不确定行为:在异常发生时,如果没有适当的处理,程序可能会进入不确定的状态。这可能导致程序崩溃、数据损坏或其他不可预测的行为。
-
不够直观:异常处理可能使代码变得复杂,不够直观。异常的处理逻辑通常分散在代码中的多个地方,这可能使代码难以阅读和维护。
-
可能导致资源泄漏:当程序在异常处理过程中退出时,可能会导致未释放的资源,导致资源泄漏。这是因为异常处理通常是在函数调用堆栈展开时进行的,当异常处理结束后,函数调用堆栈将不再展开,从而导致资源泄漏。
-
异常会导致程序的执行流乱跳,并且非常的混乱,这会导致我们跟踪调试以及分析程序时比较困难。
总的来说,C++异常处理机制在某些情况下可能会导致性能下降、不确定行为和资源泄露等问题。因此,在使用异常处理时需要谨慎,并在适当的情况下选择合适的替代方案。