C++ 异常
发布时间:
1、异常的概念
异常是 C++ 中处理错误的机制。当函数遇到自身无法处理的错误时,会抛出异常给直接调用者。若直接调用者也无法处理,异常便会沿着调用链向上层传递,直至被能处理该错误的调用者捕获并处理。
2、相关关键字
| 关键字 | 作用 | 使用场景 | 特点 |
|---|---|---|---|
| throw | 抛出异常 | 当程序中出现问题,函数无法处理时 | 无,直接抛出指定类型的异常对象,异常对象类型可为内置 / 自定义类型 |
| try | 界定可能抛出异常的代码范围 | 将可能会抛出异常的代码放在 try 块中 | 后面通常跟随一个或多个 catch 块,用于捕获 try 块中抛出的异常 |
| catch | 捕获异常 | 在需要对异常进行处理的位置 | 可设置多个 catch 块,每个块可捕获特定类型的异常 |
3、throw / try / catch 基本语法
throw 异常对象;
try
{
// 可能抛出异常的代码
}
2
3
4
catch (异常类型 异常对象名)
{
// 处理异常的代码
}
2
3
4
4、异常的抛出和匹配原则
- (1)对象类型决定处理代码:异常通过抛出对象引发,异常对象的类型决定了应该激活哪个 catch 的处理代码。
- (2)就近匹配原则:在调用链中,被选中的 catch 处理代码是与异常对象类型匹配且离抛出异常位置最近的那一个。
- (3)异常对象拷贝机制:抛出异常对象后,会生成一个该异常对象的拷贝。这个拷贝的临时对象在被 catch 处理后销毁(其机制类似于函数的传值返回)。
- (4)通用捕获情况:catch 能够捕获任意类型的异常,不过使用该方式无法明确具体的错误信息。
- (5)派生类对象捕获例外:实际应用中,抛出和捕获的匹配原则存在例外情况——可以抛出派生类对象,使用基类的 catch 块进行捕获,这种方式在实际编程中非常实用。
4、函数调用链中异常栈展开的匹配原则
沿着调用链查找匹配 catch 子句的过程被称作栈展开。 - (1)首先检查 throw 操作是否处于 try 块内部。若处于 try 块内,则开始查找与之匹配的 catch 语句。若存在匹配的 catch 语句,程序将跳转到该 catch 块进行异常处理。 - (2)若在当前 try 块对应的所有 catch 语句中,没有找到与抛出异常相匹配的 catch 块,程序会退出当前函数栈。然后,在调用当前函数的函数栈中继续查找匹配的 catch 语句。 - (3)若沿着调用链不断查找,直至到达 main 函数栈,依然没有找到匹配的 catch 语句,程序将终止运行。 - (4)为了防止因存在未捕获的异常而导致程序直接终止,在实际编程中,通常需要在异常处理的最后添加 catch(...) {} 语句,用以捕获任意类型的异常。 - (5)当程序找到匹配的 catch 子句并完成异常处理后,会继续执行该 catch 子句后面的代码。
示例
#include <iostream>
double divide(double dividend, double divisor)
{
if (divisor == 0)
{
// 若除数为 0,抛出一个字符串类型的异常
throw "Error: Division by zero!";
}
// 若除数不为 0,进行除法运算并返回结果
return dividend / divisor;
}
int main()
{
double num1, num2;
std::cout << "请输入被除数: ";
std::cin >> num1;
std::cout << "请输入除数: ";
std::cin >> num2;
try
{
double result = divide(num1, num2);
std::cout << "结果: " << result << std::endl;
}
catch (const char* error)
{
// 捕获抛出的字符串类型异常,并输出错误信息
std::cerr << error << std::endl;
}
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
请输入被除数:1
请输入除数:0
Error:Divesion by zero!
2
3
5、异常的重新抛出
单个 catch 块可能无法彻底处理异常。鉴于此,在 catch 块捕获到异常后,可先进行校正处理,再重新抛出异常传递给更上层函数处理。
校正处理可能涵盖 - 资源清理(如释放动态分配的内存、关闭文件句柄或网络连接等)、 - 数据修复(将数据恢复到某个已知的正确状态)、 - 日志记录(记录异常的详细信息,以便后续分析)等操作。
#include <iostream>
double Division(int a, int b)
{
if (b == 0)
{
throw "Division by zero condition!";
}
return (double)a / (double)b;
}
// 中间函数,捕获异常并进行校正处理后重新抛出
void Func()
{
int* array = new int[10];
try {
int len, time;
std::cout << "请输入两个整数(用空格分隔): ";
std::cin >> len >> time;
std::cout << "除法结果: " << Division(len, time) << std::endl;
}
catch (...)
{
// 资源清理
std::cout << " Func 函数中捕获到异常,释放数组内存: delete [] " << array << std::endl;
delete[] array;
throw;
}
std::cout << "正常情况释放数组内存: delete [] " << array << std::endl;
delete[] array;
}
// 更上层函数,调用 Func 函数
void Upper()
{
try
{
Func();
}
catch (...)
{
// 日志记录
std::cout << " Upper 函数中重新捕获到异常,输出提示信息后,再次重新抛出" << std::endl;
throw;
}
}
int main()
{
try
{
Upper();
}
catch (const char* errmsg)
{
std::cout << " main 函数中最终捕获到异常: " << errmsg << std::endl;
}
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
请输入两个整数(用空格分隔):10
Fune 函数中捕获到异常,释放数组内存:delete [] 000002A0837054B0
Upper 函数中重新捕获到异常,进行一些额外处理后,再次重新抛出
main 函数中最终捕获到异常:Division by zero condition!
2
3
4
Division 函数中,"Division by zero condition!" 是一个字符串字面量,C++ 中字符串字面量的类型就是 const char* 。
const char* errmsg 声明了一个名为 errmsg 的参数,errmsg 会被初始化为抛出的异常对象,即指向 "Division by zero condition!" 字符串的指针。虽然 errmsg 是一个指针,但从功能上它可以看作是对抛出的异常对象的一种引用。通过该指针,我们可以访问和操作异常对象所代表的字符串内容。
C++ 标准库的异常体系
C++ 提供了一系列标准的异常,定义在
| 异常 | 描述 |
|---|---|
| std::exception | 该异常是所有标准 C++ 异常的父类。 |
| std::bad_alloc | 该异常可以通过 new 抛出。 |
| std::bad_cast | 该异常可以通过 dynamic_cast 抛出。 |
| std::bad_exception | 这在处理 C++ 程序中无法预期的异常时非常有用。 |
| std::bad_typeid | 该异常可以通过 typeid 抛出。 |
| std::logic_error | 理论上可以通过读取代码来检测到的异常。 |
| std::domain_error | 当使用了一个无效的数学域时,会抛出该异常。 |
| std::invalid_argument | 当使用了无效的参数时,会抛出该异常。 |
| std::length_error | 当创建了太长的 std::string 时,会抛出该异常。 |
| std::out_of_range | 该异常可以通过方法抛出,例如 std::vector 和 std::bitset<>::operator。 |
| std::runtime_error | 理论上不可以通过读取代码来检测到的异常。 |
| std::overflow_error | 当发生数学上溢时,会抛出该异常。 |
| std::range_error | 当尝试存储超出范围的值时,会抛出该异常。 |
| std::underflow_error | 当发生数学下溢时,会抛出该异常。 |
自定义异常体系
C++标准库/自定义异常体系的使用总结
- 实际开发
- 标准库异常体系:一些小型项目或对异常处理要求不是特别定制化的场景中较为常用。如,使用标准库容器(如 std::vector )、智能指针等时,它们抛出的标准库异常(如 std::out_of_range )能直接处理常见错误,方便快捷,且无需额外定义异常类。同时,在一些跨团队协作项目中,如果大家遵循统一规范,使用标准库异常体系可以让代码具有更好的通用性和可维护性。
- 自定义异常体系:在大型项目、企业级开发中更为常用。很多公司会自己定义一套异常继承体系以规范异常管理。大型项目业务逻辑复杂,标准库异常难以精准描述业务层面错误。此外,在一些对代码架构和异常管理要求严格的项目中,自定义异常体系有助于规范异常抛出和捕获 。
- 个人练习:前期多使用标准库异常体系,随着能力提升和练习项目复杂度增加,自定义异常体系使用会增多。
示例
#include <iostream>
#include <string>
#include <thread>
#include <chrono>
#include <cstdlib>
#include <ctime>
class Exception
{
public:
Exception(const std::string& errmsg, int id)
: _errmsg(errmsg)
, _id(id)
{}
virtual std::string what() const
{
return _errmsg;
}
protected:
std::string _errmsg;
int _id;
};
class SqlException : public Exception
{
public:
SqlException(const std::string& errmsg, int id, const std::string& sql)
: Exception(errmsg, id)
, _sql(sql)
{}
virtual std::string what() const
{
std::string str = "SqlException:";
str += _errmsg;
str += "->";
str += _sql;
return str;
}
private:
const std::string _sql;
};
class CacheException : public Exception
{
public:
CacheException(const std::string& errmsg, int id)
: Exception(errmsg, id)
{}
virtual std::string what() const
{
std::string str = "CacheException:";
str += _errmsg;
return str;
}
};
class HttpServerException : public Exception
{
public:
HttpServerException(const std::string& errmsg, int id, const std::string& type)
: Exception(errmsg, id)
, _type(type)
{}
virtual std::string what() const
{
std::string str = "HttpServerException:";
str += _type;
str += ":";
str += _errmsg;
return str;
}
private:
const std::string _type;
};
void SQLMgr()
{
std::srand(std::time(0));
if (std::rand() % 7 == 0)
{
throw SqlException("权限不足", 100, "select * from name = '张三'");
}
//throw "xxxxxx";
}
void CacheMgr()
{
std::srand(std::time(0));
if (std::rand() % 5 == 0)
{
throw CacheException("权限不足", 100);
}
else if (std::rand() % 6 == 0)
{
throw CacheException("数据不存在", 101);
}
SQLMgr();
}
void HttpServer()
{
// ...
std::srand(std::time(0));
if (std::rand() % 3 == 0)
{
throw HttpServerException("请求资源不存在", 100, "get");
}
else if (std::rand() % 4 == 0)
{
throw HttpServerException("权限不足", 101, "post");
}
CacheMgr();
}
int main()
{
while (1)
{
std::this_thread::sleep_for(std::chrono::seconds(1));
try
{
HttpServer();
}
catch (const Exception& e) // 这里捕获父类对象就可以
{
// 多态
std::cout << e.what() << std::endl;
}
catch (...)
{
std::cout << "Unkown Exception" << std::endl;
}
}
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
异常的优点
- 相比返回错误码方式,异常对象定义好后能清晰准确地展示错误信息,甚至包含堆栈调用信息,利于更好地定位程序的 bug。
- 返回错误码方式在函数调用链中,深层函数返回错误时,需层层返回错误,最外层才能拿到错误。而异常体系中,不管是哪个调用函数出错,抛出的异常会直接跳到 main 函数中 catch 捕获的地方,main 函数可直接处理错误。
// 传统错误码方式
int ConnnectSql()
{
// 用户名密码错误
if (...)
return 1;
// 权限不足
if (...)
return 2;
return 0;
}
int ServerStart()
{
if (int ret = ConnnectSql() < 0)
return ret;
int fd = socket();
if (fd < 0)
return errno;
return 0;
}
int main()
{
if (ServerStart() < 0)
// 处理错误
;
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
- 很多第三方库都包含异常,如 boost、gtest、gmock 等常用库,使用它们需要使用异常。
- 部分函数使用异常更好处理,例如构造函数没有返回值,不方便使用错误码方式处理;像 T& operator[] 这样的函数,若 pos 越界,只能使用异常或者终止程序,无法通过返回值表示错误。
异常的缺点
- 异常会使程序的执行流乱跳,且非常混乱,运行时出错抛异常就会乱跳,这会增加跟踪调试和分析程序的难度。
- 异常会有一些性能开销,但在现代硬件速度较快的情况下,该影响基本可忽略不计。
- C++ 没有垃圾回收机制,资源需自己管理,有了异常容易导致内存泄漏、死锁等异常安全问题,需要使用 RAII 处理资源管理问题,学习成本较高。
- C++ 标准库的异常体系定义得不好,导致大家各自定义异常体系,比较混乱。
- 异常需规范使用,否则后果严重,随意抛异常会让外层捕获的用户难以处理。
总结
异常总体而言利大于弊,所以在工程中鼓励使用异常。
另外,面向对象的语言基本都用异常处理错误,这是大势所趋。