第19.3章:C++ 遗留异常

English

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章。