C++提供了一套复杂的异常处理机制,虽然很多第三方库都禁止使用异常,但是至少在标准库中广泛采用了异常机制, 因此学习一下相关的语法是有必要的,至于在编程实践中是否采用异常,仍然是值得讨论的。

简单示例

从一个简单的例子开始,这里调用的函数processInput对于非法的输入参数会抛出异常,在main函数中调用时使用try-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
27
#include <iostream>
#include <stdexcept>

void 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;
}

int main() {
try {
processInput(5);
processInput(-1); // throw std::out_of_range
}
catch (const std::invalid_argument &ex) {
std::cerr << "Invalid argument caught: " << ex.what() << std::endl;
}
catch (const std::out_of_range &ex) {
std::cerr << "Out of range caught: " << ex.what() << std::endl;
}
catch (const std::exception &ex) {
std::cerr << "Exception caught: " << ex.what() << std::endl;
}
return 0;
}

运行结果为

1
2
3
Valueless: 0
Has value: 42
Valueless: 0

基本语法

标准库异常类型

C++标准库提供了一些常见的异常类型,它们都继承自一个异常基类std::exception,先看一下异常基类在MSVC的实现

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
35
36
37
38
39
struct __std_exception_data {
char const *_What;
bool _DoFree;
};

class exception {
public:
exception() noexcept : _Data() {}

explicit exception(char const *const _Message) noexcept : _Data() {
__std_exception_data _InitData = {_Message, true};
__std_exception_copy(&_InitData, &_Data);
}

exception(char const *const _Message, int) noexcept : _Data() {
_Data._What = _Message;
}

exception(exception const &_Other) noexcept : _Data() {
__std_exception_copy(&_Other._Data, &_Data);
}

exception &operator=(exception const &_Other) noexcept {
if (this == &_Other) { return *this; }

__std_exception_destroy(&_Data);
__std_exception_copy(&_Other._Data, &_Data);
return *this;
}

virtual ~exception() noexcept { __std_exception_destroy(&_Data); }

_NODISCARD virtual char const *what() const {
return _Data._What ? _Data._What : "Unknown exception";
}

private:
__std_exception_data _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
7
void 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
12
try {
/* 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
#include <iostream>
#include <stdexcept>

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
#include <iostream>
#include <stdexcept>

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
2
func2 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
#include <iostream>
#include <stdexcept>

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
3
Input 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
5
void func() noexcept;

void func() noexcept{
std::cout << "hello,world\n";
}

这里noexcept说明符并不被视作函数类型的一部分,但是仍然被视作函数签名的一部分,在函数声明和函数定义时必须保持一致,否则编译报错。

还可以使用条件noexcept,它会根据某个编译期表达式的结果来决定当前函数是否为noexcept

1
2
3
const 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
2
void 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
#include <iostream>

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
10
class 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
#include <cstdlib>
#include <exception>
#include <iostream>

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
#include <iostream>
#include <stdexcept>

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
#include <iostream>
#include <stdexcept>

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
#include <iostream>
#include <stdexcept>

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块还可以用于其它普通函数,但是似乎没什么必要,这里不再赘述。