目录
1. 异常的基本使用
1.1 异常的概念
1.2 异常的抛出和匹配原则
1.3 函数调用链中异常栈展开匹配原则
1.4 异常的重新抛出
1.5 异常的安全问题
1.6 C++98和C++11的异常规范
2. 自定义异常体系
2.1 异常继承体系
2.2 异常体系中的重新抛出
3. C++标准库的异常体系
4. C++异常的优缺点和总结
5. 笔试选择题
答案及解析
本篇完。
1. 异常的基本使用
C语言传统处理错误的方式:
① 终止程序
比如空指针解引用,除0等异常发生时,程序会直接终止,但是这种方式对于用户来说难以接受,会导致整个进程挂掉。
② 返回错误码
比如打开文件,还有Linux中创建线程等C函数接口,调用后会返回一个返回值,如果发生错误会将错误码返回并放入到全局的errno中。程序员需要自己去查找对应的错误,非常不直观。
1.1 异常的概念
异常也是一种处理错误的方式。
当一个函数发生异常后,就会将错误抛出,让该函数的直接或间接的调用者处理这个错误。throw:当问题出现时,程序会抛出一个异常,通过throw关键字完成。
try:try 块中的代码标识将被激活的特定异常,激活的异常才会被抛出,后面跟着的一个或者多个catch块才能捕获该异常。
catch:在想要处理异常的地方,通过catch关键字捕获异常,然后执行相应的代码,可以有多个catch进行捕获。
写一个函数用来执行两个数相除,当除0时抛异常:
double Division(int a, int b)
{
if (b == 0)
{
throw "Divide by Zero Error";
}
else
{
return ((double)a / (double)b);
}
}
- throw抛出的异常必须是一个对象,可以是自定义类型的对象,也可以是内置类型的对象。
调用这个函数然后试着捕捉异常:
#include <iostream>
using namespace std;
double Division(int a, int b)
{
if (b == 0)
{
throw "Divide by Zero Error";
}
else
{
return ((double)a / (double)b);
}
}
void Func()
{
cout << Division(3, 0) << endl;
}
int main()
{
try
{
Func();
}
catch(const char* errmsg)
{
cout << errmsg << endl;
}
catch (...)
{
cout << "unknown exception" << endl;
}
return 0;
}
try块中的代码抛出异常,catch块捕获异常并处理。
- try块中抛出异常后,只有一个catch块会捕获异常,也只执行一个catch块代码,执行完后跳过所有catch块继续执行后面的代码。
- catch捕获异常时,会根据( )中的"形参"匹配抛出对象的类型。
- 如果没有合适的catch匹配,"形参"为(…)的catch就会捕获抛出的任意异常。
1.2 异常的抛出和匹配原则
先把异常的抛出和匹配原则列出来,下面有代码例子。
① 异常是通过抛出对象而引发的,该对象的类型决定了应该激活哪个catch的处理代码。
② 被选中的处理代码是调用链中与该对象类型匹配且离抛出异常位置最近的那一个。
③ 抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个临时对象, 所以会生成一个拷贝对象,这个拷贝的临时对象会在被catch以后销毁。(这里的处理类似 于函数的传值返回)
④ catch(...)可以捕获任意类型的异常,问题是不知道异常错误是什么。
⑤ 实际中抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,可以抛出的派生类对象, 使用基类捕获,这个在实际中非常实用,我们后面会详细讲解这个。
① 异常是通过抛出对象而引发的,该对象的类型决定了应该激活哪个catch的处理代码。
上面代码发生除0错误时,抛出的异常改成int类型对象:
#include <iostream>
using namespace std;
double Division(int a, int b)
{
if (b == 0)
{
//throw "Divide by Zero Error";
throw 7;
}
else
{
return ((double)a / (double)b);
}
}
void Func()
{
cout << Division(3, 0) << endl;
}
int main()
{
try
{
Func();
}
catch(const char* errmsg)
{
cout << errmsg << endl;
}
catch (int errid)
{
cout << "错误码: " << errid << endl;
}
catch (...)
{
cout << "unknown exception" << endl;
}
return 0;
}
抛出的异常只匹配了catch(const int errid)块。
② 被选中的处理代码是调用链中与该对象类型匹配且离抛出异常位置最近的那一个。
一般是这样的:
#include <iostream>
using namespace std;
double Division(int a, int b)
{
if (b == 0)
{
//throw "Divide by Zero Error";
throw 7;
}
else
{
return ((double)a / (double)b);
}
}
void Func()
{
try
{
cout << Division(3, 0) << endl;
}
catch (const char* errmsg)
{
cout << errmsg << endl;
}
catch (int errid)
{
cout << "(Func中捕获)错误码: " << errid << endl;
}
}
int main()
{
try
{
Func();
}
catch(const char* errmsg)
{
cout << errmsg << endl;
}
catch (int errid)
{
cout << "错误码: " << errid << endl;
}
catch (...)
{
cout << "unknown exception" << endl;
}
return 0;
}
③ 抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个临时对象, 所以会生成一个拷贝对象,这个拷贝的临时对象会在被catch以后销毁。(这里的处理类似 于函数的传值返回)
④ catch(...)可以捕获任意类型的异常,问题是不知道异常错误是什么。
⑤ 实际中抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,可以抛出的派生类对象, 使用基类捕获,这个在实际中非常实用,我们后面会详细讲解这个。
演示下④:
上面代码其它地方不变,throw一个键值对:
1.3 函数调用链中异常栈展开匹配原则
1. 首先检查throw本身是否在try块内部,如果是再查找匹配的catch语句。如果有匹配的,则
调到catch的地方进行处理。
2. 没有匹配的catch则退出当前函数栈,继续在调用函数的栈中进行查找匹配的catch。
3. 如果到达main函数的栈,依旧没有匹配的,则终止程序。上述这个沿着调用链查找匹配的
catch子句的过程称为栈展开。所以实际中我们最后都要加一个catch(...)捕获任意类型的异
常,否则当有异常没捕获,程序就会直接终止。
4. 找到匹配的catch子句并处理以后,会继续沿着catch子句后面继续执行。
1.4 异常的重新抛出
有可能单个的catch不能完全处理一个异常,在进行一些校正处理以后,希望再交给更外层的调用链函数来处理,catch则可以通过重新抛出将异常传递给更上层的函数进行处理。
看一段类似上面的代码,只是在Func函数里改动了,main函数最后打印个return 0;:
#include <iostream>
using namespace std;
double Division(int a, int b)
{
if (b == 0)
{
throw "Divide by Zero Error";
}
else
{
return ((double)a / (double)b);
}
}
void Func()
{
// 这里可以看到如果发生除0错误抛出异常,另外下面的array没有得到释放。
int* array = new int[10];
cout << Division(3, 0) << endl;
cout << "delete []" << array << endl;
delete[] array;
}
int main()
{
try
{
Func();
}
catch (const char* errmsg)
{
cout << errmsg << endl;
}
catch (int errid)
{
cout << "错误码: " << errid << endl;
}
catch (...)
{
cout << "unknown exception" << endl;
}
cout << "return 0;" << endl;
return 0;
}
这种情况就会导致内存泄漏,所以就要使用异常的重新抛出,改下Func函数:
void Func()
{
// 这里可以看到如果发生除0错误抛出异常,另外下面的array没有得到释放。
// 所以这里捕获异常后并不处理异常,异常还是交给外面处理,这里捕获了再重新抛出去。
int* array = new int[10];
try
{
cout << Division(3, 0) << endl;
}
catch (...)
{
cout << "delete []" << array << endl;
delete[] array;
throw; // 捕获什么抛出什么
}
cout << "delete []" << array << endl;
delete[] array;
}
1.5 异常的安全问题
如果上面的Func函数new了三个空间呢,这样delete三次可以吗?:
void Func()
{
int* array = new int[10];
int* array2 = nullptr;
try
{
array2 = new int[10];
try
{
cout << Division(3, 0) << endl;
}
catch (...)
{
cout << "delete []" << array << endl;
delete[] array;
cout << "delete2 []" << array2 << endl;
delete[] array2;
throw; // 捕获什么抛出什么
}
}
catch (...)
{
// 捕获new的异常
}
cout << "delete []" << array << endl;
delete[] array;
cout << "delete2 []" << array2 << endl;
delete[] array2;
}
答案是不行的,new失败也是抛异常,如果前面new的成功,后面new失败了呢,前面的谁释放?
所以这里可以类似这样解决:
void Func()
{
int* array = new int[10];
int* array2 = nullptr;
try
{
array2 = new int[10];
try
{
cout << Division(3, 0) << endl;
}
catch (...)
{
cout << "delete []" << array << endl;
delete[] array;
cout << "delete2 []" << array2 << endl;
delete[] array2;
throw; // 捕获什么抛出什么
}
}
catch (...)
{
// 捕获new的异常
}
cout << "delete []" << array << endl;
delete[] array;
cout << "delete2 []" << array2 << endl;
delete[] array2;
}
这只是new两次就已经这么挫了,三次或更多次呢?,所以不适合这样解决,要使用到下一篇学的智能指针RAII解决。
异常安全:
构造函数完成对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象不
完整或没有完全初始化析构函数主要完成资源的清理,最好不要在析构函数内抛出异常,否则可能导致资源泄漏(内
存泄漏、句柄未关闭等)
C++中异常经常会导致资源泄漏的问题,比如在new和delete中抛出了异常,导致内存泄
漏,在lock和unlock之间抛出了异常导致死锁,C++经常使用RAII来解决以上问题。
1.6 C++98和C++11的异常规范
在C++中异常经常会导致资源泄漏,比如在new和delete中抛出了异常,导致内存泄漏等等。
尤其是调用非常复杂的时候,后面写程序的人使用了前面程序的接口,但是并不知道前面程序的接口会抛异常,或者是抛什么异常,此时后面的程序员就无法处理抛出的异常,就可能导致资源泄漏等问题。
为了减少因为异常而导致的资源泄漏等问题,C++98时,C++委员会提出了一套建议性规范:
① 在函数的后面接throw(类型),列出这个函数可能抛出的所有异常类型。
void func() throw(A, B, C, D);
// 表示这个函数会抛出A/B/C/D中的某种异常。
void* operator new (std::size_t size) throw (std::bad_alloc);
// 这里表示这个函数只会抛出bad_alloc的异常
② 函数的后面接throw(),表示这个函数不抛异常。
void* operator delete(std::size_t size, void* ptr) throw();
③ 若无异常接口声明,则此函数可能抛出任何类型的异常。
void* operator delete(std::size_t size, void* ptr);
这个规范出发点是好的,可以让我们明确会抛出什么异常,进行相应的处理。
但是这个形式非常繁琐,throw(异常类型),有些异常类型是非常复杂的,为了写这个可能发生的异常类型,需要花费更多代价。
所以这个建议性的规范很少有人在用,因为它只是一个建议,而且有没有可能你跟我说你不会抛异常,但是你抛了呢?所以不使用也不会报错。
为了让异常声明更加简洁,C++11对此做出了相应的改进:
- 在不会抛出异常的函数后加关键字:noexcept。
thread() noexcept;
thread(thread&& x) noexcept;
上面代码表示这个两个函数不会抛出异常。这样一来确实简洁了许多,只是在会抛异常的函数中,需要我们自己搞明白会抛什么异常,这很考验写代码的人的素质,因为也只是个建议。所以这还是很少人用,但是建议大家尽量都跟着规范走。
2. 自定义异常体系
这里带着大家看看公司中自定义的异常体系的框架是怎么弄的,在其中讲讲异常的安全和规范等。
因为C++提供的异常体系不是很好用,实际使用中很多公司都会自定义自己的异常体系进行规范的异常管理,因为一个项目中如果大家随意抛异常,那外层的调用者基本就没办法玩了,所以实际中都会定义一套继承的规范体系。这样大家抛出的都是继承的派生类对象,捕获一个基类就可以了。
前面的⑤:实际中抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,可以抛出的派生类对象, 使用基类捕获,这个在实际中非常实用。
这也是继承和多态的一个重要应用、
2.1 异常继承体系
服务器开发中通常使用的异常继承体系的父类:
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; // 错误码
};
创建一个异常的基类,如上所示,包含异常信息和异常编号两个成员,注意到一个虚函数what。
做一个项目就会分很多个组,比如网络组,缓存组,数据库组等等,每个组都写一个异常的类,
这些类就会继承上面的类,服务器开发中通常使用的异常继承体系:
#include <iostream>
#include <string>
#include <windows.h> // Sleep
#include <time.h> // time
using namespace std;
// 服务器开发中通常使用的异常继承体系
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; // 错误码
};
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;
}
protected:
const string _sql;
};
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;
}
protected:
// stack<string> _stPath; // 加上堆栈信息可以查看上下文
};
class HttpServerException : public Exception // 网络层
{
public:
HttpServerException(const string& errmsg, int id, const string& type)
:Exception(errmsg, id)
, _type(type)
{}
virtual string what() const
{
string str = "HttpServerException:";
str += _type;
str += ":";
str += _errmsg;
return str;
}
protected:
const string _type;
};
void SQLMgr() // 下面的缓存层没出问题就调数据库层
{
srand(time(0));
if (rand() % 7 == 0)
{
throw SqlException("权限不足", 100, "select * from name = '张三'");
}
cout << "本次请求成功" << endl; // 最后调用的,前面都没出异常
}
void CacheMgr() // 下面的网络层没出问题就调缓存层
{
srand(time(0));
if (rand() % 5 == 0)
{
throw CacheException("权限不足", 200);
}
else if (rand() % 6 == 0)
{
throw CacheException("数据不存在", 201);
}
SQLMgr();
}
void HttpServer() //网络层
{
srand(time(0)); // 模拟
if (rand() % 3 == 0)
{
//throw HttpServerException("请求资源不存在", 100, "get");
throw HttpServerException("网络错误", 100, "get");
}
else if (rand() % 4 == 0)
{
throw HttpServerException("权限不足", 101, "post");
}
CacheMgr();
}
int main()
{
while (1)
{
Sleep(1000);
try
{
HttpServer(); // 先调用网络层
}
catch (const Exception& e) // 这里捕获父类对象就可以
{
// 多态
cout << e.what() << endl;
// 记录日志
}
catch (...) // 守住底线
{
cout << "unknown exception" << endl;
}
}
return 0;
}
抛出的异常是不同类型的对象,但是它们都是派生类,继承基类Exception。
当某个函数中抛出异常以后,后面的代码就不再执行了,直接去匹配相应的catch,当前栈帧中匹配不到就跳到上一层栈帧中去匹配。
在main函数中,将try块和catch块放在死循环中,每隔1s执行一次。
catch的类型和所有抛出的异常对象都不是一个类型,但是它是所有异常对象基类的引用。
此时就实现了多态,调用的只是基类Exception中的成员函数what(),但是执行的逻辑就是派生类中what()的逻辑。
从这里也可以看出异常存在的意义:
拿我们经常使用的微信来举例,当网络不好的时候,消息就会发不出去,此时程序就会抛一个异常,表示当前网络状态不佳。如果这个异常没有捕获,而且按照C语言对错误的处理方式,此时微信就崩了,直接退出。采用C++的异常处理机制,会将抛出的这个异常捕获,然后进行处理,比如尝试多次发送等等操作。重点是微信程序不会退出,仍然可以继续运行。
在实际应用中,很多情况下我们是不希望程序产生异常就让它结束的,而是让它继续运行,并且将异常处理。
2.2 异常体系中的重新抛出
上面异常体系中的网络层:
void HttpServer() //网络层
{
srand(time(0)); // 模拟
if (rand() % 3 == 0)
{
//throw HttpServerException("请求资源不存在", 100, "get");
throw HttpServerException("网络错误", 100, "get");
}
else if (rand() % 4 == 0)
{
throw HttpServerException("权限不足", 101, "post");
}
CacheMgr();
}
网络错误一次就抛出异常吗?不应该再重试几次吗,此时异常的重新抛出就派上用场了:
void SeedMsg(const string& s)
{
// 要求出现网络错误重试三次
srand(time(0));
if (rand() % 3 == 0)
{
throw HttpServerException("网络错误", 100, "get");
}
else if (rand() % 4 == 0)
{
throw HttpServerException("权限不足", 101, "post");
}
cout << "发送成功: " << s << endl;
}
void HttpServer()
{
// 要求出现网络错误,重试3次
string str = "等下吃什么";
int n = 3;
while (n--)
{
try
{
SeedMsg(str); // 重试3次
// 没有发生异常
break;
}
catch (const Exception& e)
{
if (e.getid() == 100 && n > 0)// 网络错误 且 重试3次内
{
continue;
}
else
{
throw e; // 重新抛出
}
}
}
}
int main()
{
while (1)
{
Sleep(1000);
try
{
HttpServer(); // 先调用网络层
}
catch (const Exception& e) // 这里捕获父类对象就可以
{
// 多态
cout << e.what() << endl;
// 记录日志
}
catch (...) // 守住底线
{
cout << "unknown exception" << endl;
}
}
return 0;
}
3. C++标准库的异常体系
C++标准库采用的就是抛派生类对象异常,捕获基类对象的方式,也是利用了继承和多态。
C++ 提供了一系列标准的异常,定义在exception中,我们可以在程序中使用这些标准的异常。它们是以父子类层次结构组织起来的,如下所示:
可以看到,C++标准库中,也是通过捕获基类对象来打印各种派生类的异常信息的,但是它的设计并不够好,信息表达不清楚等等,所以实际中很多公司都会像上面一样自定义一套异常继承体系。
4. C++异常的优缺点和总结
C++异常优点:
① 异常对象定义好了,相比错误码的方式可以清晰准确的展示出错误的各种信息,甚至可以包含堆栈调用的信息,这样可以帮助更好的定位程序的bug。
② 返回错误码的传统方式有个很大的问题就是,在函数调用链中,深层的函数返回了错误,那么我们得层层返回错误,最外层才能拿到错误,而C++异常会直接销毁栈帧去异常的上一层匹配catch。
③ 很多的第三方库都包含异常,比如boost、gtest、gmock等等常用的库,那么我们使用它们也需要使用异常。
④ 部分函数使用异常更好处理,比如构造函数没有返回值,不方便使用错误码方式处理。比如T& operator这样的函数,如果pos越界了只能使用异常或者终止程序处理,没办法通过返回值表示错误。
C++异常缺点:
① 异常会导致程序的执行流乱跳,并且非常的混乱,并且是运行时出错抛异常就会乱跳。这会导致我们跟踪调试时以及分析程序时,比较困难。
② 异常会有一些性能的开销。当然在现代硬件速度很快的情况下,这个影响基本忽略不计。
③ C++没有垃圾回收机制,资源需要自己管理。有了异常非常容易导致内存泄漏、死锁等异常安全问题。这个需要使用RAII来处理资源的管理问题。
④ C++标准库的异常体系定义得不好,导致大家各自定义各自的异常体系,非常的混乱。⑤ 异常的规范只是建议,异常尽量规范使用,否则后果不堪设想,随意抛异常,外层捕获的用户苦不堪言。所以异常规范有两点:一、抛出异常类型都继承自一个基类。二、函数是否抛异常、抛什么异常,都使用 func() throw();的方式规范化。
总结:C++目前异常的缺点只有①比较麻烦,②也有RAII解决了,其它都是小问题,所以异常总体而言,利大于弊,所以工程中我们还是鼓励使用异常的。另外其它OO(Object Oriented)语言基本都是用异常处理错误,这也可以看出这是大势所趋。
5. 笔试选择题
1. 对关于异常说法不正确的是()
A.异常是程序处于一种非法的情况,严重时可能会导致程序崩溃
B.throw可以抛出任意类型的异常
C.异常产生的后果不严重时可以不用处理
D.对于throw所抛出的异常,必须要进行捕获,否则代码最后会崩溃
2. 下列关于异常处理的描述中,理解不正确的是: ()
A.C++语言的异常处理机制通过3个保留字throw、try和catch实现
B.任何需要检测的语句必须在try语句块中执行,并由throw语句抛出异常
C.throw语句抛出异常后,catch利用数据类型匹配进行异常捕获
D.一旦catch捕获异常,不能将异常用throw语句再次抛出
3. 如何捕获异常可以使得下面代码通过编译? ()
class A
{
public:
A() {}
};
void foo()
{
throw new A;
}
A.catch (A x)
B.catch (A * x)
C.catch (A & x)
D.以上都不是
4. 对于异常的捕获列表说法不正确的是()
A.在捕获列表中可以使用基类的引用捕获所有子类的异常对象
B.捕获列表中捕获到的是异常本身
C.catch(...)可以捕获到任意类型的异常
D.捕获列表是按照抛出异常的类型进行捕获的
答案及解析
1. C
A:正确,异常是程序可能会有安全隐患,异常一旦发生,程序就是非法情况
B:正确,throw抛出的是某种类型的异常,捕获时要按照类型进行捕获
C:错误,只要程序中有异常存在,就必须要处理
D:正确
2. D
A:正确,参考异常的抛出与捕获
B:正确,对于有可能会抛出异常的代码,都应该放在try中尝试进行捕获,只有放在try中,throw 语句抛出的异常 才可能会捕获到
C:正确,所有异常都是按照类型进行捕获的
D:错误,有时捕获异常并不是为了处理异常,而是要做一些其他事情,做完后需要将异常重新抛出,交给该异常的处理位置去处理
3. B
异常是按照类型来捕获的,throw后抛出的是A*类型的异常,因此要按照指针方式进行捕获
4. B
A:该条描述不是很严谨,应该是用基类的const类型引用,可以捕获所有的子类的异常对象
B:错误,捕获列表中捕获到的是异常的一份拷贝,因为异常对象在出其函数作用域前要销毁掉
C:正确,catch(...)是万能的捕获方式,任意类型的异常都可以捕获到
D:正确,异常是按照类型捕获的
本篇完。
下一篇:(智能指针RAII)auto_ptr+unique_ptr+shared_ptr+weak_ptr。(使用和模拟实现)