Cpp 异常处理
C++提供了一套复杂的异常处理机制,虽然很多第三方库都禁止使用异常,但是至少在标准库中广泛采用了异常机制, 因此学习一下相关的语法是有必要的,至于在编程实践中是否采用异常,仍然是值得讨论的。
简单示例
从一个简单的例子开始,这里调用的函数processInput
对于非法的输入参数会抛出异常,在main函数中调用时使用try-catch
语句块捕获异常进行处理。
1 |
|
运行结果为 1
2
3Valueless: 0
Has value: 42
Valueless: 0
基本语法
标准库异常类型
C++标准库提供了一些常见的异常类型,它们都继承自一个异常基类std::exception
,先看一下异常基类在MSVC的实现
1 | struct __std_exception_data { |
异常基类在内部主要存储一个字符串形式的异常说明信息,对外部主要提供:
- 默认构造函数,拷贝构造函数
- 虚析构函数
- 含参构造函数:只接收一个
const char*
字符串,或接收一个const char*
字符串和一个int
整数 - 虚函数
what()
:返回const char*
字符串或 "Unknown exception"(这是唯一的也是最主要的方法,提供异常的说明信息)
从异常基类std::exception
继承的常见异常如图所示
具体含义依次为:
std::bad_alloc
: 表示内存分配失败的异常,通常在使用new
运算符分配内存时抛出。std::bad_cast
: 表示dynamic_cast
运算符转换失败的异常,通常在尝试将指向派生类对象的基类指针转换为派生类指针时抛出。std::bad_typeid
: 表示typeid
运算符失败的异常,通常在尝试对空指针或空引用执行typeid
运算符时抛出。std::logic_error
: 表示在程序逻辑上的错误,通常是由程序员的错误导致的。它有几个派生类,比如:std::domain_error
:当参数的值不在函数的定义域内时引发的异常。std::invalid_argument
:当参数的值无效时引发的异常。std::length_error
:当对象的长度超出其允许的范围时引发的异常。std::out_of_range
:当参数超出有效范围时引发的异常。
std::runtime_error
: 表示与运行时相关的异常,通常是由于程序的执行环境导致的。它也有几个派生类,比如:std::overflow_error
:当算术运算导致上溢时引发的异常。std::range_error
:当尝试使用超出范围的值时引发的异常。std::underflow_error
:当算术运算导致下溢时引发的异常。
上图只是列出了常见的几个异常,并不是完整的异常列表,并且随着C++的不断发展,新的异常类型还在不断加入,例如在C++20中添加了与
std::format
配套的std::format_error
。
注意:
- 异常基类支持的构造有三种:
- 无参数默认构造
- 接收一个
const char*
字符串 - 接收一个
const char*
字符串和一个int
整数
- 继承的异常类的构造必须提供说明异常信息的字符串,包括:
- 接收一个
const char*
字符串 - 接收一个
std::string
字符串
- 接收一个
抛出异常
在普通函数中使用throw
即可抛出异常,例如
1
2
3
4
5
6
7void processInput(int value) {
if (value == 0) { throw std::invalid_argument("Input cannot be zero!"); }
if (value < 0) { throw std::out_of_range("Input cannot be negative!"); }
std::cout << "Input is valid: " << value << std::endl;
}
如果程序执行到了抛出异常语句,那么就不会执行后续的代码,执行流会直接跳出当前函数,逐次返回调用栈的上一层(与此同时自动清理栈中的对象),
直到遇到异常捕获语句或跳出到main
函数之外(这会导致程序直接终止)。
捕获异常
在C++中我们需要使用如下的语法结构来捕获异常 1
2
3
4
5
6
7
8
9
10
11
12try {
/* code that may throw an exception */
}
catch (/* Catch exception type 1 */) {
/* ... */
}
catch (/* Catch exception type 2 */) {
/* ... */
}
catch (...) {
/* ... */
}
解释如下:
try
块:try
块用于标识可能引发异常的代码段。其中的代码在执行时,如果出现异常,程序会跳转到相应的catch
块进行处理catch
块:catch
块用于捕获处理特定类型的异常。可以提供连续多个具体的catch
块处理不同的异常类型,在最后可以加上一个特殊的catch
块捕获其它所有异常- 捕获具体的异常:
在
catch
块中声明要捕获的具体异常类型,如果从try
块中抛出的异常与其匹配,则程序会进入并执行相应的catch
块中的代码 - 捕获所有异常:
使用
catch(...)
可以得到一个特殊的catch
块,表示捕获所有的异常,通常用在最后,可以确保在程序出现未知异常时有备用的处理逻辑,此时无法获取异常信息
- 捕获具体的异常:
在
catch
块捕获的异常类型既可以是值类型,也可以是引用类型,但是C++建议使用引用类型。
注意多个catch
块出现的顺序决定了匹配检测的先后顺序,当前异常对象的类型必须与catch
块的声明类型一致才会匹配成功,
在类型匹配时容许如下的类型转换:
- 允许从非常量到常量的类型转换
- 允许派生类到基类的类型转换
- 数组退化为指向数组的指针
- 函数退化为指向函数的指针
因此处理派生类的catch
块必须出现在捕获基类的catch
块之前,否则会被后者掩盖。catch(...)
因为可以捕获所有异常,必须放在最后,否则后续的catch
块没有意义。
在catch
块中我们既可以输出异常信息,进行必要的处理,然后正常执行try-catch
结构后面的语句,也可以在捕获之后继续向上抛出异常(不会被与它同级的后面的catch
块捕获),此时直接使用throw;
即可重新抛出捕获的对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void func1() { throw std::runtime_error("error from func1"); }
void func2() {
try {
func1();
}
catch (const std::runtime_error &e) {
std::cout << "func2 catch: " << e.what() << "\n";
throw;
}
}
int main() {
try {
func2();
}
catch (const std::runtime_error &e) {
std::cout << "main catch: " << e.what() << "\n";
}
return 0;
}
对于异常的重新抛出:如果传入的是非常量的异常类型,即允许catch
块修改异常的内容,那么在使用引用类型时,对异常的修改会影响到重新抛出的异常,而使用值类型时,对异常的修改不会影响到重新抛出的异常。
例如 1
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
void func1() { throw std::runtime_error("error from func1"); }
void func2() {
try {
func1();
}
catch (std::runtime_error e) {
std::cout << "func2 catch: " << e.what() << "\n";
e = std::runtime_error("error from func2");
throw;
}
}
int main() {
try {
func2();
}
catch (const std::runtime_error &e) {
std::cout << "main catch: " << e.what() << "\n";
}
return 0;
}
运行结果为 1
2func2 catch: error from func1
main catch: error from func1
补充:
- 在别的情况下使用
throw;
会直接触发std::terminate()
,见下文 - 在
catch(...)
语句中虽然没有办法获取具体的异常信息,但是仍然可以使用throw;
将异常重新抛出
未捕获的异常
抛出的异常会沿着函数调用栈一层一层地返回,如果在main
函数中仍然没有被捕获并处理,就会触发std::terminate()
函数,这个函数默认会调用std::abort()
来终止当前的程序进程。
使用如下的源文件进行测试 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void processInput(int value) {
if (value == 0) { throw std::invalid_argument("Input cannot be zero!"); }
if (value < 0) { throw std::exception{}; }
std::cout << "Input is valid: " << value << std::endl;
}
int main() {
processInput(5);
processInput(0);
return 0;
}
对于Linux平台上的GCC工具链(也包括Windows平台上的MinGW),可执行程序在异常退出时会返回如下的异常信息
1
2
3Input is valid: 5
terminate called after throwing an instance of 'std::invalid_argument'
what(): Input cannot be zero!
对于Windows平台上的MSVC工具链,可执行程序在异常退出时则不会返回异常信息:
- 在Release模式下则会直接终止程序,命令行返回非0的返回码,例如
-1073740791
。 - 在Debug模式下编译,可执行程序在异常退出时会弹出如下的窗口
noexcept 标记
在默认情况下,编译器会假定每一个函数都可能抛出异常,并进行相应的准备。
可以使用noexcept
说明符承诺某个函数不会抛出异常,此时编译器就可以在编译期做一些更好的优化。
1
2
3
4
5void func() noexcept;
void func() noexcept{
std::cout << "hello,world\n";
}
这里noexcept
说明符并不被视作函数类型的一部分,但是仍然被视作函数签名的一部分,在函数声明和函数定义时必须保持一致,否则编译报错。
还可以使用条件noexcept
,它会根据某个编译期表达式的结果来决定当前函数是否为noexcept
1
2
3const bool condition = true;
void func() noexcept(condition) { std::cout << "hello,world\n"; }
需要明确的是,不抛出异常只是一个承诺,下面这种明显抛出异常的函数也可以通过编译,编译器只会发出警告
1
void func1() noexcept { throw std::runtime_error("error from func1"); }
如果承诺为noexcept
的函数体内部的某个语句在实际运行中还是抛出了异常,并且函数体内部没有将其捕获处理,
那么并不会继续向上层抛异常,而是会直接触发std::terminate
函数,终止整个程序。因为编译器并没有为标记noexcept
的函数预留关于异常的处理,抛出异常就会选择直接摆烂。
noexcept
解释为当前函数不会抛异常是不太合适的,更贴切的理解是:不允许从当前函数向上传播异常。
noexcept
标记虽然不是函数类型的一部分,但是起着和const
类似的作用——代表着更强的要求:
- 在涉及到虚函数的
noexcept
标记时:- 如果基类提供的版本没有承诺,那么派生类重写的版本既可以保持现状,也可以加上
noexcept
标记; - 如果基类提供的版本承诺不抛出异常,那么派生类重写的版本也必须承诺不抛出异常;
- 如果基类提供的版本没有承诺,那么派生类重写的版本既可以保持现状,也可以加上
- 在涉及到指向函数的指针类型时:
- 一个普通的函数指针可以指向对应类型的函数,无论这个函数是否含有
noexcept
标记; - 一个含有
noexcept
标记的函数指针,只能指向对应类型并且含有noexcept
标记的函数;
- 一个普通的函数指针可以指向对应类型的函数,无论这个函数是否含有
noexcept
标记其实是用来替代C++11之前使用的throw()
标记的,前者的语义是承诺不会抛出异常,后者的语义则是表明可能会抛出哪些异常,例如下面的func1
可能抛出两种异常,func2
不会抛出异常
1
2void func1() throw(int ,double ) {...}
void func2() throw(){...}
throw()
标记设计的非常不合理,在C++20已经被废弃,不建议使用。
进阶
自定义异常类型
从语法的角度,C++允许抛出任何类型的异常,包括内置类型(如整数、字符串、指针等)和自定义类型,如果是数组或函数类型,将会自动退化为对应的指针类型。对于可抛出的自定义类型有一些语法上的要求:提供可访问的析构函数,以及拷贝构造或移动构造函数。
例如抛出一个整数并且被捕获,这段代码是可以编译并正常执行的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void processInput(int value) { throw value; }
int main() {
try {
processInput(5);
processInput(0);
}
catch (int s) {
std::cout << "s = " << s;
}
return 0;
}
但是并不建议抛出这些奇怪的异常,而是建议从std::exception
派生新的自定义异常类型,可以提供构造方法以简化异常的使用,重写what()
方法以提供更完善的异常说明,例如
1
2
3
4
5
6
7
8
9
10class MyException : public std::exception {
public:
const char* what() const noexcept override {
return "My custom exception";
}
};
void func(){
throw MyException{};
}
这个自定义类型提供了固定的异常说明,在抛出异常时不再需要提供字符串,使用默认构造函数即可。
自定义terminate句柄
如前文所说,对于main
函数中仍然未被捕获处理的异常,以及从noexcept
标记的函数中抛出的异常,都会直接调用std::terminate()
函数。
默认情况下,std::terminate()
函数会调用std::abort()
函数强行终止整个程序。
C++允许我们自行提供std::terminate()
所调用的句柄,通常会在调用std::abort()
之前进行一些额外的处理,例如
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void customTerminate() {
std::cerr << "Custom terminate handler called" << std::endl;
std::abort();
}
void throwException() noexcept { // 从 noexcept 函数中抛出异常
throw std::runtime_error("Error in noexcept function");
}
int main() {
std::set_terminate(customTerminate); // 自定义 terminate
try {
throwException();
}
catch (...) {
std::cerr << "Never be printed because throwException() is noexcept\n";
}
return 0;
}
此时运行结果为先打印信息,然后中止程序 1
Custom terminate handler called
事实上,调用std::terminate()
已经意味着程序即将崩溃,即使我们提供自定义句柄也干不了什么有意义的事:不能返回信息,不能抛出异常,必须立刻终止程序。
自定义类型的异常安全
前面我们只考虑了调用函数时的异常处理,对于自定义类型尤其是在构造和析构过程中,也需要考虑异常安全:
- 对于构造函数,异常是必要但危险的,因为没有返回值,只能通过异常表示构造失败,但还是建议不要抛出异常;
- 对于析构函数,异常是非常危险的,因为析构也会在异常传播中被自动调用,强烈建议不要在析构函数中抛出任何异常。
如果在构造函数中抛出异常,那么当前正在构造的对象是未完成的特殊状态,并不会调用当前对象的析构函数, 可以保证已经构造的子对象和成员对象会被正确析构,但是如果获取了堆内存资源等,仍然会产生内存泄漏, 因此建议在构造函数中不要抛出异常。
从语法角度上,C++并不阻止从析构函数中抛出异常,但这是一个非常糟糕的做法! 因为即使从析构函数中抛出异常,也可能不会正常地将异常传播出去,而是会直接终止程序(见下文)。 如果需要在析构函数中执行可能抛出异常的风险语句,有很多更好的替代办法:
- 在析构函数中使用
try-catch
捕获所有异常,保证不会向外抛出异常; - 将可能抛出异常的操作拆分为单独的方法,不在析构函数中调用就可以了。
除此之外,移动构造函数,移动赋值运算符以及
swap()
都不应该抛出异常。
异常机制的底层实现
异常对象不同于函数中的局部对象,它会被存储在一个在栈之外的特殊内存空间中,保证所有可能捕获的catch
块都能访问到。
在函数调用中,如果抛出了异常,会具体执行如下的操作:
- 抛出的异常会沿着函数调用栈上溯,寻找匹配的捕获语句,在这个过程中:
- 不会执行上层函数中尚未执行的后续语句;
- 顺便将存储在栈中的局部对象依次销毁,销毁顺序与构造顺序相反;
- 并不涉及堆上的资源释放,如果没有使用RAII来管理资源,抛出异常时就很可能导致内存泄漏。
- 如果在某一层成功捕获了异常,就会进入
catch
块执行对应语句,然后在退出catch
块时销毁异常对象(如果再次抛出则不会析构),继续执行后续的语句 - 如果异常直到
main
函数都没有被成功捕获,或者从noexcept
函数中抛出了异常,那么程序默认会通过std::terminate()
调用std::abort()
强行终止程序,此时全局的资源不会被妥善处理,全局变量不会被正常析构。
这里自动执行的很多操作事实上也不是安全的,
例如对于栈中的自定义类型对象会自动执行析构,但是万一在析构中又抛出异常了怎么办?
对于这种问题,C++遵循一个简单的原则:
异常传播机制只能同时处理一个异常(因为只有一个函数调用栈),如果在处理一个未捕获异常时又抛出了一个异常,就会直接调用std::terminate()
终止程序。
异常安全原则
我们称一个函数是异常安全的,如果程序在此触发异常,程序可以回退的很干净:不会泄露资源或者不会发生任何数据结构的破坏。进一步将其细分为三个级别:
- 基本级别:可能发生异常,但是在异常发生时,代码保证做了任何必要的清理工作,即程序在合法阶段,但是一些数据结构可能已经被函数更改,不一定是调用之前的状态,但是基本保证符合对象正常的要求;
- 强烈级别:可能发生异常,但是在发生异常时,代码保证函数对数据做的任何修改都可以被回滚。即如果调用成功,就完全成功;如果调用失败,则对象依旧是调用之前的状态;
- 无异常:函数保证不会抛出异常。
补充
函数 try 块
这其实是一个补丁性质的语法,它想要解决这样一个问题:捕获构造函数在成员初始化列表中抛出的异常,以正确销毁构造过程中的当前对象。
例如下面的异常无法被构造函数自身处理 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct Demo {
int n;
explicit Demo(int n) {
if (n < 0) throw std::runtime_error("error");
this->n = n;
}
};
class Test {
public:
explicit Test(int n) : d(n) {}
Demo d;
};
int main() {
auto tmp = Test{-2};
return 0;
}
如果希望让Test
构造函数处理异常,就必须放弃成员初始化列表的写法,将成员的初始化放进try
块中
1
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
struct Demo {
int n;
Demo() = default;
explicit Demo(int n) {
if (n < 0) throw std::runtime_error("error");
this->n = n;
}
};
class Test {
public:
explicit Test(int n) {
try {
d = Demo{n};
}
catch (std::exception &e) {
std::cout << e.what() << std::endl;
throw;
}
}
Demo d;
};
int main() {
auto tmp = Test{-2};
return 0;
}
这里为了让编译通过,还需要给Demo
类型加上默认构造函数。
函数try
块的语法允许我们将整个函数体(包括构造函数的成员初始化列表,以及委托构造函数)放进try-catch
块中,但是有以下语法要求:
catch
块的最后隐含着throw;
,必然将异常上抛;- 对于构造函数,在
catch
块中无法使用this
指针,只能调用静态成员函数或静态数据。
例如 1
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
struct Demo {
int n;
Demo() = default;
explicit Demo(int n) {
if (n < 0) throw std::runtime_error("error");
this->n = n;
}
};
class Test {
public:
explicit Test(int n) try : d{n} {}
catch (std::exception &e) {
std::cout << e.what() << std::endl;
throw;
}
Demo d{};
};
int main() {
auto tmp = Test{-2};
return 0;
}
除了用于构造函数中,函数try
块还可以用于其它普通函数,但是似乎没什么必要,这里不再赘述。