目录
在编写C++程序时,我们很难避免代码不出现错误,所以在C++中我们就对不可避免的错误采用了异常的机制,本期我们将展开C++异常的学习。
C语言传统的处理错误的机制
终止程序(assert断言),终止程序,对于用户而言,无法接受,而且用户也不会知道代码为什么会终止。
返回错误码。错误码有很多,需要程序员自己通过错误文档查看定位错误,效率太低。
C++全新版本处理错误的异常机制
不同于C语言传统的处错误的机制,在C++中,引入了异常机制,来处理程序中常见的错误。
异常:异常是一种处理错误的机制,当一个函数发现自己无法处理的错误就会将该错误抛出,由该函数的直接调用者或者间接调用者来处理这个错误。
在异常机制中主要有三个关键字,try,catch,throw。
- try:try块中用于识别被激活的特定异常,在其之后通常有着一个或者多个catch模块。
- throw:当程序出现错误时,程序会抛出一个异常,而throw关键字用于抛出异常。
- catch:catch关键字用于捕获异常。
try中用于放置可能出现异常的代码,catch用于捕获异常,try中的代码称作保护代码。try和catch代码如下。
int main()
{
try
{
//可能出现异常的代码
}
catch (const std::exception&)
{
//捕获异常
}
catch (const std::exception&)
{
//捕获异常
}
catch (const std::exception&)
{
//捕获异常
}
}
异常的使用
异常的抛出和捕获原则
- 异常是通过抛出对象引发的,这个抛出对象可能是内置类型也可能是自定义类型,但是大多数情况下是自定义类型(这个后续我们会讲到),所以将来会调用到最匹配的catch块。
代码如下。
void func()
{
while(1)
{
int a, b;
cin >> a >> b;
if (b == 0)
{
//throw "除零错误";
throw 1;
}
else
{
a / b;
}
}
}
int main()
{
try
{
func();
}
catch (const char* ch)
{
cout << ch;
}
catch (const int& num)
{
cout << num;
}
return 0;
}
运行结果如下。
如图,throw什么对象,就调用最匹配的catch块。
- 当throw抛出异常时,如果有两个catch块都匹配,那么会去调用最近的catch块。
void func()
{
while(1)
{
int a, b;
cin >> a >> b;
try
{
if (b == 0)
{
throw "除零错误";
//throw 1;
}
else
{
a / b;
}
}
catch (const char* ch)
{
cout << "较近的catch块" << ":" << ch << endl;
}
}
}
int main()
{
try
{
func();
}
catch (const char* ch)
{
cout << "较远的cath块" << ":" << ch << endl;
}
catch (const int& num)
{
cout << num;
}
return 0;
}
- 抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个临时对象,所以会生成 一个拷贝对象(一般情况下是移动构造,资源转移),这个拷贝的临时对象会在被catch以后销毁。(这里的处理类似于函数的传值返回,catch中的参数就是形参) 。
大家可以去C++11右值引用那部分知识去了解。
- catch(...)可以捕获任意类型的异常,但是问题就是不知道捕获的异常是什么。因为如果抛出的异常没有匹配的catch块去进行捕捉的话就会进报错,导致程序无法正常运行,为了防止有人进行老六操作(恶意抛奇怪的异常,或者自己无意间抛了异常),C++又引入了catch(..)的概念,不管你抛什么对象,只要没有匹配的catch块进行捕捉,那么就可以使用catch(...)进行捕捉。
代码如下。
void func()
{
while (1)
{
int a, b;
cin >> a >> b;
if (b == 0)
{
throw "除零错误";
}
else
{
a / b;
}
throw true;
throw 'a';
}
}
int main()
{
try
{
func();
}
catch (const char* ch)
{
cout << ch << endl;
}
catch (const int& num)
{
cout << num;
}
catch (...)
{
cout << "未知异常" << endl;
}
return 0;
}
当我们没有使用catch(...)代码,此时只要抛出的异常没有匹配的catch模块进行捕捉,此时就会报错,图示如下。
当我们使用了catch(...)模块进行捕捉时,catch(...) 就会捕捉一些没有匹配的catch块的对象,虽然不知道是什么异常,但是可以保证代码的正常运行。
- 实际中抛出和捕获的匹配 ,并不都是类型完全匹配。可以抛出派生类的对象,使用基类捕获(继承中的切片,子类可以传给父类,但是父类不能传给子类),C++异常库中也是采用这种方式。这个后续我们会讲到。
有一个问题,当我们抛出异常之后,throw之后的代码还会执行吗?
运行结果如下。
我们发现,抛出异常之后的代码是没有被执行的。
如果我们在catch之后输出上段代码呢?
当catch捕获了异常之后,会接着执行catch之后的代码。
总而言之,throw之后的代码不会被执行,一旦throw了异常,代码下次执行就会在对应的catch语句之后执行。
也正因为如此,会引入很多的安全问题,这便是我们接下来要讲的知识点。
异常的重新抛出
在日常的编程过程中,我们一般会把代码的编写分成很多层,那么就不可避免的在每一层都有很多的异常,难道每一层的异常都要在每一层进行捕获吗?并不是,图示如下。
网络服务层,缓存层,数据持久化层都会有异常,但是他们抛出异常之后,为了统一化,最终都是由业务逻辑层进行异常的捕获。
我们为什么要引入上述概念呢?
因为在有些场景下,我们必须要在当前函数体内捕获异常,但是因为为了统一,我们必须在最外层才能捕获异常,所以在当前函数体内捕获了异常之后需要重新进行抛出。
对于上述代码,在发生了除零错误之后,throw抛出了异常,这就会导致throw之后的delete语句没有正常运行,导致内存泄漏,所以为了防止内存泄漏的产生,我们会在当前函数中进行异常的捕获,并且在catch块中,进行堆中所申请的内存的释放,从而避免内存泄漏的产生。但是我们上述已经讲过了,我们说一般情况下,我们不会在当前函数中进行异常的捕获,而是会在最外层调用函数中进行异常的捕获,所以我们在当前函数中捕获了异常之后,就需要将异常重新进行抛出。
代码如下图所示。
这便是异常的重新抛出,总的来说就是为了方便管理异常的抛出。
异常导致的安全问题
throw的关键点在于throw之后,throw之后的代码不会再去执行,所以throw之后可能会遇到各种各样的问题,所以在使用throw抛出异常时我们需要注意以下几点。
构造函数完成对象的构造和初始化,最好不要在构造函数中抛出异常,否则可能导致对象不完整或没有完全初始化。
析构函数主要完成资源的清理,最好不要在析构函数内抛出异常,否则可能导致资源泄漏。
C++中异常经常会导致资源泄漏的问题,比如在new和delete中抛出了异常,导致内存泄漏,在lock和 unlock之间抛出了异常导致死锁,怎么解决上述问题呢,死锁我们已经讲过了使用lockguard和unique_lock解决,但是对于解决死锁和内存泄漏,在后续我们会使用RAII的方法来解决这些问题,在智能指针中再为大家讲解这些问题。
自定义异常体系
其实当我们真正使用异常时,我们可能不会去抛出内置类型的对象,int,double,char,也不会抛出stl库中的自定义对象,string,vector之类,也不会抛出程序员自己创建的自定义对象, 那么真正在使用异常时,我们究竟抛出什么样的对象呢?参考下图。
通常我们会创建一个Exception基类,然后这个基类会派生出很多子类,子类可能徽派生出很多子类,此时我们就可以使用C++的继承中切片的语法,将子类传递给父类,这样就可以使用一个基类,接收多个子类对象。这也正是C++库中使用的抛异常,捕获异常的方法。
示例代码如下。
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
{
string str = "SqlException:";
str += _errmsg;
str += "->";
str += _sql;
return str;
}
private:
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;
}
};
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;
}
private:
const string _type;
};
void SQLMgr()
{
srand(time(0));
if (rand() % 7 == 0)
{
throw SqlException("权限不足", 100, "select * from name = '张三'");
}
}
void CacheMgr()
{
srand(time(0));
if (rand() % 5 == 0)
{
throw CacheException("权限不足", 100);
}
else if (rand() % 6 == 0)
{
throw CacheException("数据不存在", 101);
}
SQLMgr();
}
void HttpServer()
{
// ...
srand(time(0));
if (rand() % 3 == 0)
{
throw HttpServerException("请求资源不存在", 100, "get");
}
else if (rand() % 4 == 0)
{
throw HttpServerException("权限不足", 101, "post");
}
CacheMgr();
}
void ServerStart()
{
while (1)
{
this_thread::sleep_for(chrono::seconds(1));
try {
HttpServer();
}
catch (const Exception& e) // 通过父类对象捕获所有子类对象,实现异常捕获的统一化
{
// 多态
cout << e.what() << endl;
}
catch (...)
{
cout << "Unkown Exception" << endl;
}
}
}
上述代码我们创建了Exception基类,SqlException,CacheException,HttpSeverException这三个子类去继承Exception基类,然后分别创建了三个函数,实现这三层代码的逻辑,最终抛出的异常全部由main函数中的Exception基类对象捕获,实现了捕获异常的统一,同时三个派生类中的what函数分别对基类中的what虚函数进行了重写,且catch中存放的是基类对象的引用,当后续传过来子类对象调用what这个函数时,就构成了多态,所以就可以调用传过来的对象的what函数,得知每个子类对象的异常是什么。
在C++库中,也是使用了一个exception基类来实现自定义异常体系。
异常规范
- 异常规格说明的目的是为了让函数使用者知道该函数可能抛出的异常有哪些。 可以在函数的后面接 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 new (std::size_t size, void* ptr) throw();
C++98和C++11对是否会抛出异常的代码是不同的,如上图所示。
以上便是异常规范,但是上述异常规范很难在真正项目中使用到,有这样一种情况,万一在函数中说了此函数不会抛出异常,但是保不准万一哪个老六跑了异常,最中进行异常甄别的时候,是无法进行甄别的,而且这种现象也是避免不了的。所以异常规范是一种理想化的状态,在真正的项目中是很难进行执行的。
异常的优缺点
优点
- 使用异常可以清楚的知道程序究竟是因为什么问题产生的异常,而且捕获异常之后,代码不会终止运行。
- 传统的返回错误码的方式,如果函数涉及了多次调用,那么返回错误码时,就会很麻烦,需要逐层函数返回,但是异常不用,即使函数调用了很多层,就算最里面的函数发生了异常,抛出异常时,最外层的函数也很容易就会捕捉到异常。
- 部分函数不适合使用返回错误码的方式返回错误,如构造函数,析构函数没有返回值,所以无法返回错误码,再比如,vector的operator[],返回的是当前位置的元素的引用(T&),这些函数都是无法使用传错误码返回的,所以使用异常是一种不错的方法。
缺点
- 使用异常,当throw抛出异常,那么进行捕获异常时,函数会乱跳,使得代码逻辑显得比较混乱。
- 当throw抛出异常后,throw之后的代码是不会被执行的,所以如果进行调试在throw之后的代码打了断点,会发现代码仍然是没有办法停下来的,也就意味着很难对出现异常的代码进行调试。
- 使用异常时,也是因为throw之后的代码不会执行,所以遇到lock,unlock,new,delete这些函数和关键字,如果在这些函数或者关键字之间抛出了异常,就有可能导致死锁和内存泄漏。
但总体而言,使用异常:利>弊,所以我们还是推荐大家在编程的过程中多多使用异常,但是要注意异常规范,得了解异常的抛出和捕获的原则,只有这样才能保证异常抛出和捕获的合理性。