前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【C++】异常

【C++】异常

作者头像
野猪佩奇`
发布2023-04-27 19:59:57
3730
发布2023-04-27 19:59:57
举报

一、传统C语言处理错误方式

传统的C语言处理错误时主要有以下两种方式:

  1. 直接终止程序:比如在程序内部使用 assert 进行断言,当发生内存错误、越界访问、除0错误等时就直接终止程序;这种方式的缺点是用户难以接受。
  2. 返回错误码:比如系统中很多库的接口函数都是通过把错误码设置到 errno 中来表示错误;这种方式的缺点是需要程序员自己去查找错误码对应的错误,不过直观。

实际中C语言基本都是使用返回错误码的方式来处理错误,部分情况下会终止程序来处理一些非常严重的错误。


二、异常的概念

异常也是一种处理错误的方式,当一个函数发现自己无法处理的错误时就可以抛出异常,将其交由函数的直接或间接调用者来处理

  • throw:当问题出现时,程序通过 throw 关键字来抛出异常;(抛出异常)
  • try:try 块中放置可能抛出异常的代码,try 块中的代码被称为保护代码,try 后面通常跟着一个或多个 catch 块。(标识异常)
  • catch:catch 关键字用于捕获 throw 关键字抛出的异常,我们可以在想要处理问题的地方进行捕获,并且在同一个地方可以有多个不同类型的 catch 块;(捕获异常)

throw、try、catch 语句的语法如下:

代码语言:javascript
复制
void func() {
	//当满足某个条件时抛出异常
	if () {
		throw e;
	}
}

try {
	// 保护的标识代码
	func();
}
catch (ExceptionName e1) {
	// catch 块
}
catch (ExceptionName e2) {
	// catch 块
}
catch (ExceptionName eN) {
	// catch 块
}

三、异常的使用

1、异常的抛出与捕获

异常抛出和捕获的匹配原则如下:

  • 异常是通过抛出对象来引发的,该对象的类型决定了应该激活哪个 catch 块的处理代码;(注意:异常只需要被捕获一次,所以同一个位置不允许有参数类型相同的多个 catch 块)
  • 在函数的调用堆栈中,当函数抛出异常时,程序会首先在当前函数中查找异常处理代码,即检查 throw本身是否在 try 块内部;如果当前函数没有相应的异常处理语句,那么异常就会向上层函数继续传递,直到找到合适的异常处理机制或者程序终止;
  • 如果异常传递到 main 函数的栈帧中仍然没有相应的异常处理语句,或者异常处理中没有与抛出对象类型匹配的 catch 块,程序会直接终止
  • 如果程序没有异常,则程序会按正常逻辑执行,且遇到 catch 语句时会直接跳过;如果程序有异常,则程序抛出异常后会直接跳转到与该对象类型匹配且离抛出异常位置最近的一个 catch 块中处理异常,处理完毕后会继续执行 catch 后面的语句
image-20230418203143980
image-20230418203143980

如下:程序的调用逻辑是 main->func->division,当 division 抛出异常时,由于division本身不在 try 块中,所以异常会到 func 函数的栈帧中查找异常处理语句,而 func 函数中也没有,所以异常会继续到 main 函数中查找;同时,由于 division 函数抛出的异常的类型为 string,所以它会匹配 const char* errmsg 的 catch 块:

image-20230418203806285
image-20230418203806285

注意:如果 division 中抛出了异常,而 division 本身及其上层的函数都没有对异常进行捕获,即没有 try/catch 语句;或者说有 try/catch 语句但是没有与抛出类型匹配的 catch 块,程序都会直接终止:

image-20230418204133745
image-20230418204133745
image-20230418204315183
image-20230418204315183

注:实际中抛出和捕获的匹配原则有个例外,并不都是类型完全匹配,可以抛出派生类对象,然后使用基类的引用捕获,这个在实际中非常实用,具体做法我们会在下文给出例子。

2、异常的重新抛出

在有些时候,程序直接抛出异常可能会导致发生一些意想不到的错误,比如内存泄露,因为程序抛出异常后会直接跳转到对应 catch 块处理异常,处理完毕后也会直接执行 catch 块后面的代码,而不会回来继续执行抛出异常位置后面的代码;如下 :

image-20230418211559999
image-20230418211559999

面对这种情况,我们可以直接在 division 函数中处理异常并释放资源,但我们通常会选择捕获异常后不处理异常,只释放资源,然后将异常重新抛出,这样可以使得程序的异常都在某一个地方集中进行捕获,方便记录日志与集中处理;如下:

代码语言:javascript
复制
double Division(int a, int b) {
	// 当b == 0时抛出异常
	if (b == 0)
		throw "Division by zero condition!";
	else {
		return ((double)a / (double)b);
	}
}

void Func() {
	// 这里如果发生除0错误会抛出异常,但下面的资源没有得到释放
	// 所以这里捕获异常后并不处理异常,而只释放资源,异常还是交给外面处理,这里捕获了再重新抛出去。
	int* p = new int[10];
	try {
		int len, time;
		cin >> len >> time;
		cout << Division(len, time) << endl;
	}
	catch (const char* errmsg) {
		cout << "delete[] ->" << p << endl;
		delete[] p;
		throw errmsg;
	}
    
    //如果上面没有抛异常,这里再正常释放
    delete[] p;
}

int main() {
	try {
		Func();
	}
	catch (const char* errmsg) {
		cout << errmsg << endl;
	}
	catch (int errid) {
		cout << errid << endl;
	}

	return 0;
}
image-20230418211340419
image-20230418211340419

3、抛出与捕获任意类型异常

在上面异常重新抛出的场景中,由于 func() 函数中还可能会调用其他函数,而其他函数也可能会抛出异常,并且它们抛出的对象的类型可能与 division 并不相同,那么此时如果我们要实现捕获异常释放资源重新抛出就需要写多个不同参数类型的 catch 块,这显然很麻烦,所以 C++ 还支持捕获与抛出任意类型的异常:

代码语言:javascript
复制
try {
    //...
}
catch(...) {  //三个点表示捕获任意类型异常
    //throw 表示抛出任意类型的异常--捕获到什么就抛出什么
    throw;
}

同时,之前我们提到,如果到达 main 函数的栈帧后依旧没有匹配的 catch 块或依然没有对异常进行捕获,那么程序会直接终止,这显然是不好的;所以实际中通常我们都会在最后加一个 catch(…) 来捕获任意类型的异常,以此来处理未知异常,放在程序被直接终止;如下:

代码语言:javascript
复制
double Division(int a, int b) {
	// 当b == 0时抛出异常
	if (b == 0)
		throw "Division by zero condition!";
	else {
		return ((double)a / (double)b);
	}
}

void Func() {
	// 这里如果发生除0错误会抛出异常,但下面的资源没有得到释放
	// 所以这里捕获异常后并不处理异常,而只释放资源,异常还是交给外面处理,这里捕获了再重新抛出去。
	int* p = new int[10];
	try {
		int len, time;
		cin >> len >> time;
		cout << Division(len, time) << endl;
	}
	catch (...) {  //捕获任意类型异常
		cout << "delete[] ->" << p << endl;
		delete[] p;
		throw;  //捕获到什么,就抛出什么
	}
    
    //如果上面没有抛异常,这里再正常释放
    delete[] p;
}

int main() {
	try {
		Func();
	}
	catch (const char* errmsg) {
		cout << errmsg << endl;
	}
	catch (int errid) {
		cout << errid << endl;
	}
	catch (...) {
		cout << "未知异常" << endl;
	}

	return 0;
}
image-20230418212924474
image-20230418212924474
image-20230418213210744
image-20230418213210744

4、异常安全

C++ 的异常存在如下的安全问题:

  • 构造函数完成对象的构造和初始化工作,所以最好不要在构造函数中抛出异常,否则可能会导致对象不完整或没有完全初始化;
  • 析构函数完成资源的清理工作,最好也不要在析构函数内抛出异常,否则可能导致资源泄漏 (内存泄漏、句柄未关闭等);
  • C++ 中异常经常会导致资源泄漏的问题,比如在 new 和 delete 中抛出了异常,导致内存泄漏;亦或是在 lock 和 unlock 之间抛出了异常,导致死锁。C++ 通常使用 RAII 来解决以上问题,关于 RAII 我们会在智能指针章节进行讲解。

5、异常规范

由于不规范使用异常会带来许多非常严重的后果,所以 C++98 引入了异常规范,异常规范中建议程序员对每个函数进行异常接口说明,其目的是让函数使用者知道该函数可能抛出的异常有哪些,如下:

  • 通过在函数的后面接 throw(类型),来列出这个函数可能抛掷的所有异常类型;
  • 如果函数不抛异常,则在函数的后面接 throw();
  • 若无异常接口声明,则此函数可能抛掷任何类型的异常,也可能不抛异常。
代码语言:javascript
复制
// 这里表示这个函数会抛出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 对异常接口说明进行了简化

函数后面不加关键字 noexcept,表示该函数可能会抛出任意类型异常;

函数后面加关键字 noexcept,表示该函数不会抛异常。

代码语言:javascript
复制
// C++11 中新增的 noexcept,表示不会抛异常
thread() noexcept;
thread(thread&& x) noexcept;

并且 C++11 还对使用 noexcept 修饰的函数进行了检查,如果该函数被 noexcept 修饰,但是可能会抛出异常,则编译器会报一个警告,但并不影响程序的正确性:

image-20230418220134724
image-20230418220134724

四、C++ 标准库的异常体系

C++ 提供了一系列标准的异常,定义在 exception 中,我们可以在程序中使用这些标准的异常;它们是以父子类层次结构组织起来的,如下所示:

image-20230418221611891
image-20230418221611891

其中,我们比较常见类有 bad_alloc – new 空间失败时抛出此异常;runtime_error – 一些运行时错误,比如除0错误,空指针解引用等;out_of_range – 通常是越界访问;overflow_error – 通常是栈溢出。

虽然我们可以直接使用 C++ 标准提供的这些异常,也可以去继承 exception 类来实现自己的异常类,但在实际开发中很多企业都会像上面一样自己定义一套单独的异常继承体系,因为C++标准库设计的不够好用。再加上我们平时自己写代码基本不会使用异常,所以对于 C++ 标准异常我们作为了解内容即可。

使用 C++ 标准库中的异常类来捕获异常:

代码语言:javascript
复制
int main() {
	try {
		vector<int> v(10, 5);
		// 这里如果系统内存不够也会抛异常
		v.reserve(1000000000);

		// 这里越界会抛异常
		v.at(10) = 100;
	}
	catch (const exception& e) // 这里捕获父类对象就可以--多态
	{
		cout << e.what() << endl;
	}
	catch (...) {
		cout << "Unkown Exception" << endl;
	}
	return 0;
}
image-20230418222556264
image-20230418222556264
image-20230418222639527
image-20230418222639527

五、自定义异常体系

在实际开发中很多企业都会自定义自己的异常体系进行规范的异常管理,以此来避免在项目中大家随意抛异常,从而导致无法对异常进行集中分类处理;所以在实际中都会定义一套继承的规范体系,这样大家抛出的异常都是派生类对象,那么在捕获将参数类型定义为基类类型即可。

image-20230418223257434
image-20230418223257434

下面我们以服务器开发中通常使用的异常继承体系为例:

代码语言:javascript
复制
//基类
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;  //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;  //网络异常的独有信息
};

//SQL查询
void SQLMgr()
{
	srand(time(0));
	if (rand() % 7 == 0)
	{
		throw SqlException("权限不足", 100, "select * from name = '张三'");
	}
	//throw "xxxxxx";
}

//缓存访问
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();
}

int main()
{
	while (1)
	{
		Sleep(1000);
		try {
			HttpServer();
		}
		catch (const Exception& e) // 这里捕获父类对象就可以
		{
			// 多态
			cout << e.what() << endl;
		}
		catch (...)
		{
			cout << "Unkown Exception" << endl;
		}
	}

	return 0;
}
image-20230418224141106
image-20230418224141106
image-20230418224224530
image-20230418224224530

上面程序的分析如下:

  • 存在一个基类 Exception,该类中有两个成员变量,分别用来保存错误编号和错误的描述信息,还有一个 what 虚函数;
  • 其他子类 SqlException、CacheException、HttpServerException 都继承自父类 Exception,并且子类会根据自己的需要增加成员变量,比如 SqlException 中增加了一个 _sql,用来保存失败的 SQL 查询语句;并且子类都重写了父类的 what 方法,通过 what 方法,返回自己的错误编号、错误描述信息以及该类特有的一些信息,比如属于哪一类异常,比如 SQL 查询语句和网络请求类型;
  • 存在三个函数 SQLMgr、CacheMgr 和 HttpServer,分别对应 SQL 查询、缓存访问和网络请求,这些函数都可能会抛出异常,例如权限不足、数据不存在等。在主函数中使用了 try-catch 语句来捕获这些异常,如果捕获到了异常,则调用 e.what() 方法输出具体的异常信息。函数的调用逻辑是 main -> HttpServe -> CacheMgr -> SQLMgr。

这里有两个地方需要注意:

  1. 为什么在 main 函数中调用父类对象的 what 方法就可以捕获其他三个子类的异常对象,并且输出的还是对应子类的异常信息?这是因为父类中 what 是虚函数,而所有的子类都对 what 进行了重写;同时,main 函数中的 catch 的形参是父类类型的引用;当捕获到子类的对象时这里就会触发多态,去调用子类对象中的 what 方法。
  2. 为什么要用一个变量来表示错误编号?这是为了方便对不同异常进行分类,从而对某些异常进行特殊处理;比如,当我们坐火车发送消息时,由于火车信号不好,经常会网卡,所以就很可能导致本次 http 请求失败抛出异常;但是对于这种异常我们需要间隔一定时间再次发起 http 网络请求,因为此刻信号说不定又能够支持我们发送消息了。这就是为什么当网络不好时使用qq/微信发送消息会有一个圆圈一直在转。

通过像上面这样来设计异常处理程序,我们可以在程序出错时可以快速定位问题,特别是在复杂的系统中,异常往往是难以避免的。通过准确地捕获异常,我们可以及时发现错误并进行修复,提高程序的稳定性和可靠性。同时,将不同类型的异常分别封装为不同的子类,也可以更加清晰地表达异常的类型和具体信息,为后续的维护和优化带来方便


六、异常的优缺点

C++ 异常的优点

  1. 相比传统C语言返回错误码的方式,异常可以更加清晰准确的展示出错误的各种信息,甚至可以包含堆栈调用的信息,这样可以帮助我们更好的定位程序 bug;
  2. 返回错误码的传统方式还有一个很大的问题,即在函数调用链中,如果深层的函数返回了错误,那么我们必须层层返回错误,最外层才能拿到错误;而如果是异常,我们就可以直接将其抛出,此时程序会自动跳转到异常捕获的地方处理异常;
  3. 很多的第三方库都包含异常,比如 boost、gtest、gmock 等等常用的库,我们使用它们时也需要使用异常;
  4. 部分函数使用异常更好处理,比如构造函数没有返回值,不方便使用错误码方式处理;又比如 T& operator[]() 这样的函数,如果下标越界了只能终止程序或者抛出异常,而没办法通过返回值来表示错误。

C++ 异常的缺点

  1. 当运行时出错时,抛出异常会导致程序的执行流乱跳,并且非常的混乱,导致我们跟踪调试以及分析程序时比较困难;
  2. 异常会有一些性能的开销,因为异常返回的是一个局部对象 – 现在的容器都实现了移动构造,且编译器也会进行优化 (识别为右值,直接进行移动构造),所以这点基本可以忽略不计;
  3. C++没有垃圾回收机制,资源需要自己管理,有了异常非常容易导致内存泄漏、死锁等异常安全问题 – 这个问题需要使用 RAII 来处理资源的管理问题;
  4. C++标准库的异常体系定义得不好,导致大家各自定义自己的异常体系,非常的混乱;
  5. 异常如果不规范使用会造成非常严重的后果,随意抛异常会让外层捕获异常的用户苦不堪言 – 尽量遵从异常规范,比如抛出异常类型都继承自一个基类,不抛异常的函数都是用 noexcept 修饰等。

总结:异常总体而言利大于弊,所以在工程开发中我们是鼓励使用异常的;另外面向对象的语言基本都是用异常处理错误,这也是大势所趋。(注:我们进行个人开发时基本不会用到异常,所以现在对异常有一个了解即可,要想真正的学习异常还是得在公司里面进行实际开发才行)


本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2023-04-21,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、传统C语言处理错误方式
  • 二、异常的概念
  • 三、异常的使用
    • 1、异常的抛出与捕获
      • 2、异常的重新抛出
        • 3、抛出与捕获任意类型异常
          • 4、异常安全
            • 5、异常规范
            • 四、C++ 标准库的异常体系
            • 五、自定义异常体系
            • 六、异常的优缺点
            相关产品与服务
            容器服务
            腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
            领券
            问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档