C++『异常』

✨个人主页: 北 海
🎉所属专栏: C++修行之路
🎃操作环境: Visual Studio 2022 版本 17.6.5

成就一亿技术人

文章目录

  • 🌇前言
  • 🏙️正文
    • 1.异常基本概念
      • 1.1.C语言异常处理方式
      • 1.2.C++异常处理方式
    • 2.异常的使用
      • 2.1.异常的抛出与捕获
      • 2.2.异常的重新抛出
      • 2.3.异常安全
      • 2.4.异常规范
    • 3.异常体系
      • 3.1.C++标准库的异常体系
      • 3.2.自定义异常体系
    • 4.异常的优缺点
  • 🌆总结

🌇前言

异常处理在软件开发中扮演着关键的角色,它为程序员提供了一种有力的手段来处理和响应程序执行过程中可能出现的错误。本文将深入探讨异常的基本概念、异常处理方式、异常的使用技巧和异常体系的设计,以帮助开发者更好地理解和应用异常处理机制

🏙️正文

1.异常基本概念

1.1.C语言异常处理方式

C语言 中,面对异常主要有以下两种处理方式:

  1. 返回错误码
  2. 终止进程

比如 main 函数有一个返回值,只有返回值(错误码)为 0 时才表示程序正常退出,如果发生越界访问、堆栈溢出等行为时,会返回其他数值

部分错误码及其对应的错误信息对照表格如下

代码错误信息
0成功(Success)
1一般错误(General error)
2误用shell命令(Misuse of shell command)
126无法执行(Cannot execute)
127命令未找到(Command not found)
128无效的退出参数(Invalid exit argument)
130Ctrl+C终止(Terminated by Ctrl+C)
255退出状态未知(Unknown exit status)

至于其他代码的具体含义,取决于编译器的不同的实现,比如上面的 3 号错误码在 VS 中就表示 异常退出,具体原因是 越界访问

除了返回错误码外,C语言 还支持通过函数终止进程,说白了就是给进程发送 信号

可以使用 exit(err_code)abord()assert(bool_exp) 等函数终止进程

exit(err_code) 支持在终止进程时设置错误码,可以根据自己的需要建立 [错误码, 错误信息] 的映射关系

abord() 函数则是直接发送 6 号信号来终止进程

至于 assert(bool_exp) 常用于非法情况的检查判断,bool_exp 是一个返回类为 bool 的表达式,如果该表达式为 ,那么 assert 函数就会触发,并终止进程

注意: 使用 assert 需要包含相关头文件

#include <iostream>
#include <cassert>

using namespace std;

int main()
{
	int x = 10;
	int y = 0;

	// 简易整数除法
	[&]()->void
		{
			// 除数(分母)不能为 0
			assert(y);

			cout << x / y << endl;
		}();

	return 0;
}

assert 最大的优点在于 指明终止原因,以及原因出现的具体路径、具体行号,对于程序调试十分友好,需要 注意 的是 assert 只能在 Debug 模式下使用,Release 模式中 assert 会被自动删除

1.2.C++异常处理方式

无论是 错误码 还是 终止进程,都只能提供简略的错误信息,对于 C++ 这种面向对象的语言来说太无力了,需要一种全新的异常处理方式:将异常化做一个对象,配合异常体系解读异常

万物皆可为对象,所以新的异常处理方式非常强大

C++ 中新增了以下三个关键字,用于实现 异常监测、异常抛出、异常捕获

  • try 监测当前代码区域是否存在异常
  • throw 识别到异常后,抛出异常
  • catch 捕获抛出的异常(如果有的话)

注:throw 是一个关键字,可以直接在后面跟异常对象,也可以像函数调用一样传递异常对象,类似于 sizeof 关键字

比如这样就可以使用 C++ 的异常处理方式

void func()
{
	// 出现异常,抛出
	throw "这是一个异常"
	// 或者
	// throw("这是一个异常")
}

int main()
{
	try
	{
		// 监测代码当前代码区域
		func();
	}
	catch(const char* ps)
	{
		// 捕获异常
		// 可以对 ps 进行操作
	}
	return 0;
}

注意: catch 块捕获的异常对象类型,必须与 throw 抛出的异常对象类型匹配上,否则会导致异常无法捕获,导致程序异常终止

如果正确编写异常处理的代码,try 内的代码发生异常时可以优雅处理,不至于直接引发进程终止,因此 try 内的代码又被称为 保护代码

2.异常的使用

2.1.异常的抛出与捕获

异常的使用比较简单,将之前整数相除的代码改成 C++ 的异常处理方式

void divisor(int x, int y)
{
	if (y == 0)
	{
		// 除 0 错误,抛出异常
		throw("除数(分母)不能为0");
	}

	cout << "结果:" << x / y << endl;
}

int main()
{
	try
	{
		divisor(10, 0);
	}
	catch (const char* s)
	{
		cout << s << endl;
	}

	return 0;
}

通常需要在异常捕获的地方记录日志,方便排查错误

如果传入的数据是正确的,就不会触发异常,程序正常运行

// ...

int main()
{
	try
	{
		divisor(10, 10);
	}
	catch(const char* s)
	{
		cout << s << endl;
	}
	
	return 0;
}

异常在抛出后是必须被捕获的,如果不写 catch 块相关代码或者 catch 块中的类型与抛出的异常类型不匹配,在出现异常后,进程会因异常没有被捕获,而被 abort 函数终止

void divisor(int x, int y)
{
	if (y == 0)
	{
		// 除 0 错误,抛出异常
		throw("除数(分母)不能为0");
	}

	cout << "结果:" << x / y << endl;
}

int main()
{
	try
	{
		divisor(10, 0);
	}
	catch (int s) // 故意写错类型
	{
		cout << s << endl;
	}

	return 0;
}

现在的编译器都很智能,如果你在代码编写阶段一个 catch 块都没写,会直接报语法错误,所以一定要确保抛出的异常,能被正确捕获

catch 块至少得存在一个,也可以存在多个,当同时存在多个 catch 块时,抛出的异常会根据栈帧顺序,被最近的 catch 块捕获

catch 块只能进入一次,异常被捕获后,无法再进入其他 catch

注意: 如果出现多个类型不匹配的 catch 块时,异常会被类型匹配,且最近的 catch 块捕获

void divisor(int x, int y)
{
	if (y == 0)
	{
		// 除 0 错误,抛出异常
		throw("除数(分母)不能为0");
	}

	cout << "结果:" << x / y << endl;
}

void calc()
{
	int x = 10;
	int y = 0;

	try
	{
		divisor(x, y);
	}
	catch (const char* s)
	{
		cout << "void calc()" << endl;
		cout << s << endl;
	}
}

int main()
{
	try
	{
		calc();
	}
	catch (const char* s)
	{
		cout << "int main()" << endl;
		cout << s << endl;
	}

	return 0;
}

divisor 函数捕获异常后,main 函数中不再捕获异常,代码正常运行结束;一般异常捕获这个工作会交给最外层统一处理,比如这里的 main 函数,此时如果出现了异常,代码会直接跳转至 main 函数中,至于中间的栈帧会被 throw 自动清理

void divisor(int x, int y)
{
	if (y == 0)
	{
		// 除 0 错误,抛出异常
		throw("除数(分母)不能为0");
	}

	cout << "结果:" << x / y << endl;
}

void calc()
{
	int x = 10;
	int y = 0;

	divisor(x, y);
}

int main()
{
	try
	{
		calc();
	}
	catch (const char* s)
	{
		cout << "int main()" << endl;
		cout << s << endl;
	}

	return 0;
}

在实际使用中,并不会这样直接抛出一个字符串,而是构建一个 异常信息类,抛出一个 异常对象,类中包罗万象,需要包含最基本的两个信息:错误码、错误信息

// 异常信息类
class Exception
{
public:
	Exception(int errcode, const string& content)
		:_errno(errcode), _content(content)
	{}
	
	void what() const
	{
		// 打印异常信息
		cout << "发生了异常" << endl;
		cout << "\t错误码为: " << _errno << endl;
		cout << "\t错误信息为: " << _content << endl;
	}
private:
	int _errno = 0;
	string _content;
};

这样一来,在出现异常时,可以构建一个异常对象并抛出

为什么要设计错误码?
因为在某些场景中,不方便直接暴露错误,比如消息发送过程中,如果遇到网络问题,检测到错误码为 x,会不断重试,直到发送成功或超时,这样能使用户体验更好

throw(Exception(3, "除数(分母)不能为0"));

// 现在引用的是临时对象
catch(const Exception& e);

注意: catch 块捕捉时,不可以直接使用左值引用,因为抛出的是一个局部对象

当出现未知异常时,如何解决?

通过 catch(...) 捕获,支持捕获任意类型的异常

void calc()
{
	// 故意抛出一个未知异常
	throw(10);
}

int main()
{
	try
	{
		calc();
	}
	catch (const Exception& e)
	{
		cout << "int main()" << endl;
		e.what();
	}
	catch (...)
	{
		cout << "int main()" << endl;
		cout << "未知异常" << endl;
	}

	return 0;
}

catch(...) 就相当于异常捕获的底线,如果前面的 catch 块都无法捕获异常,此时就轮到 catch(...) 登场,避免程序因异常无法捕获而终止

异常支持使用父类指针/引用捕获子类对象,假设当前项目中存在:网络异常、数据异常、SQL异常 等多种异常信息类,如果想让最外层的 catch 块捕获所有异常对象,可以让这些异常信息类都继承自同一个父类,同时重写父类中的虚函数,再通过父类指针/引用捕获

#include <iostream>
#include <string>
#include <windows.h>

using namespace std;

// 父类
class Exception
{
public:
	Exception(int errcode, const string& content)
		:_errno(errcode), _content(content)
	{}

	virtual string what() const
	{
		return to_string(_errno) + " : " + _content;
	}

protected:
	int _errno = 0;
	string _content;
};

// 网络子类
class HttpException : public Exception
{
public:
	HttpException(int errcode, const string& content, const string& url, const string& type)
		:Exception(errcode, content)
		, _url(url)
		, _type(type)
	{}

	// 重写
	virtual string what() const
	{
		return to_string(_errno) + " : " + _content + " ---> " + _type + " " + _url;
	}

private:
	string _url; // 资源路径
	string _type; // 请求类型
};

// 内存子类
class CacheException : public Exception
{
public:
	CacheException(int errcode, const string& content)
		:Exception(errcode, content)
	{}

	// 重写
	virtual string what() const
	{
		return to_string(_errno) + " : " + _content;
	}
};

// SQL子类
class SqlException : public Exception
{
public:
	SqlException(int errcode, const string& content, const string& sql)
		:Exception(errcode, content)
		,_sql(sql)
	{}

	// 重写
	virtual string what() const
	{
		return to_string(_errno) + " : " + _content + " ---> " + _sql;
	}

private:
	string _sql; // SQL语句
};

void SqlServe()
{
	int n = rand();
	if (n % 5 == 3)
		throw(SqlException(3, "数据表不存在", "select * from t2"));
	else if (n % 5 == 4)
		throw(SqlException(4, "权限不足", "drop table t1"));

	// 操作完成
	cout << "请求已完成" << endl << "-------------------" << endl;
}

void CacheServe()
{
	int n = rand();
	if (n % 5 == 2)
		throw(CacheException(2, "数据不存在"));

	// 进入下一层
	SqlServe();
}

void HttpServe()
{
	int n = rand();
	if (n % 5 == 0)
		throw(HttpException(0, "请求的资源不存在", "/index.html HTTP/1.1", "GET"));
	else if (n % 5 == 1)
		throw(HttpException(1, "没有访问权限", "/image.html HTTP/1.1", "POST"));

	// 进入下一层
	CacheServe();
}

int main()
{
	srand((size_t)time(nullptr));

	while (true)
	{
		Sleep(1000);
		try
		{
			cout << "开始请求" << endl;
			HttpServe();
		}
		catch (const Exception& e)
		{
			// 异常处理
			cout << e.what() << endl << "===================" << endl;
		}
		catch (...)
		{
			cout << "未知异常" << endl;
		}
	}

	return 0;
}

这里用到了 继承 + 多态 相关知识,当子类对象赋值给父类指针/引用时,会触发 切片 机制,这个过程是天然发生的,所以但凡是从该父类派生出的子类对象,都可以被正常接收

这种玩法在实际开发中非常实用,项目组可以根据自己的需求,设计继承体系,以及异常体系

注意: 如果同时存在类型为父类及子类的 catch 块,异常会被较近的 catch 块捕捉

2.2.异常的重新抛出

异常抛出后,可能会导致某些栈帧中的代码没有被执行,从而引发内存泄漏等问题,比如下面场景中就出现了内存泄露问题

// 异常信息类
class Exception
{
public:
	Exception(int errcode, const string& content)
		:_errno(errcode), _content(content)
	{}

	string what() const
	{
		return to_string(_errno) + " : " + _content;
	}
public:
	int _errno = 0;
	string _content;
};

void divisor(int x, int y)
{
	if (y == 0)
	{
		// 除 0 错误,抛出异常
		Exception e(3, "除数(分母)不能为0");
		throw(e);
	}

	cout << "结果:" << x / y << endl;
}

void calc()
{
	int x = 10, y = 0;

	int* arr = new int[10];

	divisor(x, y);

	delete[] arr;
	cout << "delete[] arr: " << arr << endl;
}

int main()
{
	try
	{
		calc();
	}
	catch (const Exception& e)
	{
		cout << "int main()" << endl;
		cout << e.what() << endl;
	}
	catch (...)
	{
		cout << "int main()" << endl;
		cout << "未知异常" << endl;
	}

	return 0;
}


可以看到,动态开辟的空间并没有被正确释放,这是因为异常抛出后,throw 会清理 calc 的栈帧,导致其中的代码没有被执行,要想正确的释放内存,需要在 calc 函数中主动捕获异常,将空间释放后,重新抛出异常

注:throw 表示捕获到什么异常,就抛出什么异常

void calc()
{
	int x = 10, y = 0;

	int* arr = new int[10];

	try
	{
		divisor(x, y);
	}
	catch (...)
	{
		delete[] arr;
		cout << "delete[] arr: " << arr << endl;

		throw;
	}

	delete[] arr;
	cout << "delete[] arr: " << arr << endl;
}

现在空间被释放了,同时异常正常交给了最外层处理,不过这种写法的代码不容易维护,好在 C++ 中诞生了 智能指针,能自动释放空间,这也是下一篇博客的内容

为什么异常要在统一的地方进行处理?

  • 统一记录日志
  • 针对某些错误进行额外处理

2.3.异常安全

异常在使用时需要注意以下几点

1.最好不要在构造函数中抛出异常,因为对象的构造和初始化是需要时间的,如果在构造途中抛出了异常,会导致对象构造不完整

2.最好不要在析构函数中抛出异常,析构函数清理资源的过程同样需要时间,析构途中抛出异常可能会引发内存泄漏

3.在使用诸如 new/deletemalloc/freefopen/fcloselock/unlock 等资源管理配套函数时,需要特别注意资源泄漏或者死锁问题,在发生捕获到异常后,需要先把资源释放了,再考虑异常处理

2.4.异常规范

异常就像一只薛定谔的猫,你永远不知道别人是否抛出、何时抛出,为了让异常的使用更加规范,C++98 标准规定:

  1. 可以在函数的后面接 throw(type1, type2, type3 ...),列出这个函数可能抛掷的所有异常类型
  2. 函数的后面接 throw( ),表示该函数不会抛出异常
  3. 若无异常接口声明,则此函数可以抛掷任何类型的异常

比如这样编写函数:

void func1() throw(int, char, string); // 可能抛出这三种类型的异常

void func2() throw(); // 该函数不会抛出异常

void func3(); // 该函数可以抛出任何类型的异常

在标准库函数中,就采用了这种规范写法

C++98 中的异常规范过于繁琐,由于异常规范并非强制性语法,实际使用过程中有很多人都不会遵守,于是在 C++11 中对异常规范进行了相关更新,化繁为简,只需使用一个 noexcept 关键字表明该函数不会抛出异常

void func2() noexcept; // 该函数不会抛出异常

推荐使用 C++11 中新方案,对于不会抛出异常的函数,使用 noexcept 关键字修饰

  • noexcept 可以检测当前函数中是否发生了 throw 抛出异常的行为

如果加了 noexcept 关键字后,函数仍然抛出异常,是否会报错?
答案是会的,会直接被 abort 函数终止进程,所以可以放心使用 noexcept 关键字;即便是在异常抛出与异常捕获的中间函数中使用 noexcept 修饰,在异常抛出后,进程也会被终止;总之就是加了 noexcept 修饰后,所有该函数涉及的操作,都不会出现异常

注:如果使用 throw() 修饰,仍然抛出异常后,进程不会终止,所以还是推荐使用 noexcept

3.异常体系

3.1.C++标准库的异常体系

C++ 标准库中提供了一套 异常体系,其中包含了各种常见异常,我们也可以继承 std::exception 父类,重写其中的虚函数,实现其他方面的异常

异常描述
std::exception该异常是所有标准C++异常的父类
std::bad_alloc该异常可以通过new抛出
std::bad_cast该异常可以通过dynamic_cast抛出
std::bad_typeid该异常可以通过typeid抛出
std::bad_exception这在处理C++程序中无法预期的异常时非常有用
std::logic_error理论上可以通过读取代码来检测到的异常
std::runtime_error理论上不可以通过读取代码来检测到的异常
std::domain_error当使用了一个无效的数学域时,会抛出该异常
std::invalid_argument当使用了无效的参数时,会抛出该异常
std::length_error当创建了太长的std::string时,会抛出该异常
std::out_of_range该异常可以通过方法抛出,例如std::vector和std::bitset<>::operator
std::overflow_error当发生数学上溢时,会抛出该异常
std::range_error当尝试存储超出范围的值时,会抛出该异常
std::underflow_error当发生数学下溢时,会抛出该异常

3.2.自定义异常体系

虽然 C++ 标准库中提供了标准异常体系,但实际上大多数公司会根据实际项目定义自己的异常体系,比如之前的 SqlException 等异常信息类,就属于自定义异常体系

为什么要自定义异常体系?
因为公司中的项目一般都会进行模块划分,不同的模块用于实现不同的功能,如果不通过自定义异常体系来规范异常行为,会导致整个项目的异常处理及其麻烦,有了自定义异常体系后,只需要通过一个父类指针/引用,即可捕获不同子类对象异常,统一进行处理

4.异常的优缺点

异常的优点

  • 可以展示更丰富的错误信息,更好的定位程序 Bug
  • 错误码是层层返回的,不方便定位问题,而异常是则直接被捕获的
  • 很多的第三方库都包含了异常,需要与其进行兼容,比如 boostgtestgmock
  • 部分函数使用异常更好表示错误,比如 T& operator[](size_t pos) 如果越界了就抛异常,而不是返回 T() 或 断言

异常的缺点

  • 执行流跨度过大,并且非常混乱,导致跟踪调试程序时比较困难
  • 异常有一些性能上的开销(当代硬件速度很快,可以忽略不计)
  • C++ 没有垃圾回收机制,资源需要自己管理,可以使用 RAII 来处理资源管理问题
  • C++ 标准库的异常体系定义不够好,导致出现了各种异常体系,比较混乱
  • 异常尽量规范使用,否则后果不堪设想
    1. 抛出异常类型都继承自一个基类
    2. 函数是否抛异常可以使用 noexcept 注明

总体而言,异常 利大于弊,在工程项目中鼓励使用异常,OO 语言基本都是使用异常处理错误,这是大势所趋

🌆总结

以上就是本次关于C++『异常』的全部内容了,异常处理是软件开发中重要的错误管理工具,本文深入探讨了异常的基本概念、C++中的处理方式、使用技巧和异常体系设计。尽管异常提供了丰富的错误信息,但其使用需要谨慎考虑执行流、性能开销等因素。在面对项目需求时,程序员应权衡利弊,以确保异常处理在提高代码可维护性和可靠性方面发挥最佳效果

星辰大海

相关文章推荐

C++ 进阶知识

C++11『lambda表达式 ‖ 线程库 ‖ 包装器』

C++11『右值引用 ‖ 完美转发 ‖ 新增类功能 ‖ 可变参数模板』

C++11『基础新特性』

C++11『右值引用与移动语义』

C++11『基础新特性』

C++ 哈希的应用【布隆过滤器】

C++ 哈希的应用【位图】

C++【哈希表的完善及封装】

C++【哈希表的模拟实现】

C++【初识哈希】

C++【一棵红黑树封装 set 和 map】

文章出处登录后可见!

已经登录?立即刷新

共计人评分,平均

到目前为止还没有投票!成为第一位评论此文章。

(0)
青葱年少的头像青葱年少普通用户
上一篇 2023年12月12日
下一篇 2023年12月12日

相关推荐