第19.3章:C++ 遗留异常
1. 这个特性是什么
C++ 的异常机制就是本书所说的遗留异常。它有三个关键字、一个用作基类的标准库类、一个编译器选项 -fexceptions(默认开启)。这里讲它,是因为你一定会读到它:它在标准库里、在每个老代码库里、在每一场 C++ 面试里。至于它不该是你写的东西,那是 第19.8章 的主题。
throw some_value; // report a failure
try { ... } catch (::std::exception const &e) { } // handle the ones you know
try { ... } catch (...) { } // handle anything that is left
throw; // re-raise the current exception
这四行加上 -fexceptions 就是全部表面。其余都是关于关键字运行时那个运行时到底做了什么的细节。
2. 精确的规则
因为赫布异常是返回路径上的两个字(第3.16章),很容易以为 throw 是同一个形状。它不是,而这些差别正是你读懂遗留代码而不误读所需要知道的:
| 规则 | 它的含义 |
|---|---|
| 任何可拷贝类型都可以被抛出 |
throw 42; 合法。别这么干:一个 int 既没有消息,也没有可供处理块匹配的类型
|
| 对象会被拷贝到运行时拥有的存储里 |
throw local_variable; 抛出的是一个拷贝,而这个拷贝一直活到最后一个处理它的处理块退出。抛拷贝还意味着你的类型必须可拷贝,而且那次拷贝发生在最糟糕的时刻
|
抛出对象,按 const& 捕获 |
catch (T const &e) 绑定那个运行时拷贝而不产生又一次拷贝。catch (T e) 会拷贝它,而且对多态的 T 而言还会把对象切片 —— 见 第19.4章
|
| 处理块按顺序尝试,第一个匹配者胜出 |
所以 catch (::std::exception const &e) 必须写在 catch (::std::invalid_argument const &e) 之后,否则它会把后者吞掉
|
| 匹配也使用基类 |
throw ::std::invalid_argument 会被针对 ::std::logic_error 或 ::std::exception 的处理块捕获,因为它们是它的基类
|
| 离开的途中栈帧会被析构 |
throw 与处理块之间的自动对象会按构造的逆序被析构,抵达那里所经历的过程就是展开
|
| 展开途中析构函数抛出会终止进程 |
运行时手里有两个在飞的异常,无从选择。这就是本书里析构函数都是 noexcept 的原因(第14章)
|
3. 一个用上全部规则的程序
一次真实运行里有两件事值得看:析构函数的执行顺序,以及"基类的处理块会接受派生异常"这一事实。析构函数不能打印 —— 它是 noexcept 的 —— 所以这个程序记录到缓冲区,再由 main 打印出来:
#include <fast_io.h>
#include <stdexcept>
#include <fast_io_dsal/string_view.h>
namespace
{
char log_buffer[256]{};
::std::size_t log_length{};
void record(char const *data, ::std::size_t size) noexcept
{
for (::std::size_t i{}; i != size && log_length != sizeof(log_buffer); ++i)
{
log_buffer[log_length] = data[i];
++log_length;
}
}
struct tracer
{
::fast_io::string_view name;
explicit tracer(::fast_io::string_view n) noexcept
: name(n)
{}
tracer(tracer const &) = delete;
tracer &operator=(tracer const &) = delete;
~tracer()
{
record(name.data(), name.size());
record(";", 1);
}
};
int parse(char const *text) noexcept(false)
{
tracer const outer{"outer"};
tracer const inner{"inner"};
throw ::std::invalid_argument(text);
}
} // namespace
int main()
{
try
{
parse("7 is not a number");
}
catch (::std::logic_error const &e)
{
::fast_io::io::print("logic_error ", ::fast_io::mnp::os_c_str(e.what()), "\n");
}
catch (::std::exception const &e)
{
::fast_io::io::print("exception ", ::fast_io::mnp::os_c_str(e.what()), "\n");
}
::fast_io::io::print("destructors ", ::fast_io::mnp::os_c_str(log_buffer, log_length), "\n");
}
logic_error 7 is not a number
destructors inner;outer;
inner;outer; 就是展开:parse 里的两个 tracer 对象在 throw 和处理块之间按构造逆序被析构了,而那时处理块还一行都没执行。而捕获到 ::std::invalid_argument 的处理块,是写给 ::std::logic_error 这个基类的 —— 这就是 第19.4章 里那棵类型树为何重要的原因。
关于签名有一处要注意:noexcept(false)。那是"这个函数可能抛出"的遗留写法,也是旧机制唯一能表达的出错契约。如果这个函数是普通的 noexcept,抛出就会调用 ::std::terminate 而不是展开 —— 这就是为什么每个遗留代码库里都塞满了契约不可见的函数。
4. 保存异常而不是抛出它
有时你必须捕获一个异常并保留它 —— 为了从另一个线程重新抛出、为了存进 future、为了附在结果上。用于此的类型是 ::std::exception_ptr,在处理块里用 ::std::current_exception() 拿到:
try
{
work();
}
catch (...)
{
::std::exception_ptr saved{::std::current_exception()}; // keeps the exception alive
// ... hand `saved` to another thread or store it ...
::std::rethrow_exception(saved); // re-raise it later
}
exception_ptr 是引用计数的,所以拷贝它还算便宜,而异常对象的寿命与最后一个指向它的指针一样长。它是一次带引用计数的堆分配、一个类型擦除的句柄和一种 ABI 定义的布局 —— 正是赫布异常用两个字替换掉的那类机制,区别在于赫布异常是可以自由拷贝和比较的值。
另外还有 ::std::nested_exception 和 ::std::rethrow_if_nested,用于表达"这个失败是由那个失败引起的"。两者之所以存在,是因为那套机制没有办法携带第二个错误值;而赫布异常的错误域里只要有一个说清失败是什么的错误码就够了(第19.1章)。
5. 读它是你的工作,写它不是
现在你已经具备读遗留代码库所需要的一切:一次 throw 在理解上要你付出什么、处理块会匹配到什么、展开会析构什么、exception_ptr 又会让什么活下去。接下来两页补上随这套机制一起提供的两个类库(第19.4章、第19.5章),然后 第19.6章 讲两种机制之间唯一受支持的交接方式。
这一页你不能带走的是"所以新代码里我可以用它"。在你大多数要发布到的目标上遗留异常根本不存在,而 第19.7章 的构建选项会把这件事变成编译错误而不是意外。原因在 第19.8章。