第19.1章:定义与覆盖错误域

English

1. 先说清楚:你几乎肯定不需要自己的错误域

你已经会抛出和捕获赫布异常(第3.16章):每个错误都是两个字 {domain, code},而 domain 是指向一张函数表的指针,那张表知道 code 是什么意思。这一页讲的就是那张表本身,第一条规则也是最省事的一条:

用一个已经存在的错误域。错误域并不是"某个库的错误类型"。它是一个错误码空间,而一个错误码空间就足以支撑整个程序。运行时自带九个,它们合起来几乎覆盖了普通程序能失败的一切:

错误域类型 错误码空间 适用场景
::std::errc POSIX errno 值 处处适用。这是平台层的默认语言
::std::parse_errc ok, end_of_file, partial, invalid, overflow 任意格式的文本到数值的转换
::std::http_errc HTTP status codes, the wire value itself 客户端与服务器
::std::cmath_errc FE_INVALID, FE_DIVBYZERO, FE_INEXACT, FE_OVERFLOW, FE_UNDERFLOW 数学,答案是一个值加一个标志的场合
::std::tls_alert TLS alert level and description 任何 TLS 连接
::std::nt_errc NTSTATUS STATUS_* Windows 内核与原生 API
::std::win32_errc Win32 GetLastError ERROR_* Win32 API
::std::wine_errc Wine's own host errno space Wine 的 Unix 一侧,那里的值与宿主 libc 的并不相同
::std::com_errc HRESULT COM 以及建在它之上的一切

这套设计之所以成立,靠的是下面那张表里的第四个函数:每个错误域都必须能回答"这是哪一个 ::std::errc?"。正是这一条义务让错误处理可以互通。调用者不必知道是哪个库产生的失败,因为任何错误域的失败都可以和 ::std::errc::no_space_on_device 比较,也都可以按 POSIX 的名字和消息打印出来。这与旧的 C++ std::error_code 世界正好相反:在那里每个库都发明一个 std::error_category,任意两个都无法比较。

所以说:新建错误域是给拥有真实错误码空间的库作者用的 —— 一个协议、一种文件格式、一台设备。如果你的代码像其他所有人一样返回 ::std::errc::invalid_argument 和 ::std::errc::value_too_large,那就结束了,这一页对你也就讲完了。

2. 错误域到底是什么

::std::error_domain_singleton 是一个装着五个函数指针的结构体。整个 ABI 就这么多:两字错误里带着一个指向这种结构的指针,外加一个错误码。


struct error_domain_singleton
{
	void (*do_cleanup)(::std::size_t) noexcept = nullptr;
	bool (*do_equivalent)(::std::size_t, ::std::error_domain_singleton const *,
		::std::size_t) noexcept = nullptr;
	void (*do_query_information)(::std::size_t, ::std::error_query_information,
		::std::error_reporter_encoding, void *,
		::std::error_reporter_io_cookie_function) noexcept = nullptr;
	::std::errc (*do_to_errc)(::std::size_t) noexcept = nullptr;
	void (*do_throw_legacy_exception)(::std::size_t,
		::std::legacy_exception_abi) = nullptr;
};

五个之中可以有四个留成 nullptr。有一个不行。

钩子 它必须做什么 留成 nullptr 时
do_to_errc 把你的错误码转换成最接近的 ::std::errc 必需。如果某个错误域没有 do_to_errc,编译器会拒绝以它为基础的 throw throws;而 e.to_errc() 正是其他所有库从你的失败里恢复的手段
do_query_information 回答关于某个错误码的三个问题之一:name、message 或 name_message 打印这个错误时什么都不打印。值本身仍然可用,只是你读不出它
do_equivalent 说出你的错误码和另一个错误域的错误码是否表示同一种失败 e.equivalent(other) 什么也不比较。改为比较 to_errc(),这也是随运行时提供的错误域的默认做法
do_cleanup 释放错误码拥有的任何东西 你的错误码不拥有任何东西。只要错误码能塞进 ::std::size_t,这一项就该留空 —— 本章所有情形都是这样
do_throw_legacy_exception 把错误码作为平台自身的原生 C++ 异常类型重新抛出 运行时改为把这两个字装进 ::std::cxx_std_error_legacy_exception(第19.6章)

注意这个结构体里没有什么:没有名字、没有消息字符串、没有 what()、没有基类、没有类型。错误码就是一个整数,表就是常量数据,所以一个错误域不带来任何分配、不带来 RTTI、不带来每个错误一个对象。之所以存在查询信息的钩子,是因为文本总得在某个地方生成,而它是惰性生成的,直接写进调用者的输出缓冲区,用调用者的编码。

最后这一点解释了为什么查询钩子接收一个 encoding 和一个回调,而不是返回 char const *:同一段文本可以按 UTF-8、UTF-16、UTF-32、GB18030 或 EBCDIC 打印进任何提出要求的流,而表只写一遍。

3. 特化:error_domain<T>

用户从不直接碰这个单例。throw throws value 只向 ::std::error_domain<T> 问两个问题:用哪个单例,错误码是什么。在错误类型旁边特化一次即可:


namespace std
{
template <>
class error_domain<::csv_errc>
{
public:
	static inline constexpr ::std::error_domain_singleton const *domain() noexcept;
	static inline constexpr ::std::size_t code(::csv_errc e) noexcept;
};
} // namespace std

两个函数都必须是 static、inline、constexpr 且 noexcept:domain() 会被常量求值器拦截,所以 constexpr 函数里的 throw throws 也能用;而它每次抛出都会被调用,所以它不能做任何事。

还有一个成员你可以提供,但也仅此一个:static T from_std_error(::std::error const &)。如果它存在,::std::herbception_cast<T>(e) 就会用它把捕获到的 ::std::error 转回你的枚举。当从 errc 的转换是有损的、而你想要回原始错误码时,就提供它。另外,当一个单例被多个类型共享时,错误域可以声明一个 domain_alias_type,此时 is_code_of<T>() 和 == 会比较这个别名而不是类型本身。第一个错误域两者都不需要。

4. 你自己的一门错误域

全部内容就是这样:一个带四种失败的 CSV 读取器。一个类型、两张文本表、一个单例、一个特化,别的什么都没有。


#include <fast_io.h>
#include <fast_io_dsal/string_view.h>

enum class csv_errc : ::std::uint_least32_t
{
	ok = 0,
	unterminated_quote = 1,
	field_too_long = 2,
	too_many_fields = 3
};

namespace csv_details
{
inline constexpr ::fast_io::string_view names[]{
	"csv:ok",
	"csv:unterminated_quote",
	"csv:field_too_long",
	"csv:too_many_fields"};
inline constexpr ::fast_io::string_view messages[]{
	"the record parsed",
	"a quoted field reached the end of the record",
	"a field is longer than the declared limit",
	"the record has more fields than the header"};

inline constexpr ::std::error_domain_singleton singleton{
	.do_equivalent = [](::std::size_t code, ::std::error_domain_singleton const *other,
						::std::size_t other_code) noexcept -> bool {
		return singleton.do_to_errc(code) == other->do_to_errc(other_code);
	},
	.do_query_information = [](::std::size_t code, ::std::error_query_information query,
							   ::std::error_reporter_encoding, void *cookie,
							   ::std::error_reporter_io_cookie_function report) noexcept {
		if (code >= ::std::size(names))
		{
			return;
		}
		::std::io_scatter_t const pieces[3]{
			{names[code].data(), names[code].size()},
			{static_cast<void const *>(": "), 2},
			{messages[code].data(), messages[code].size()}};
		switch (query)
		{
		case ::std::error_query_information::name:
			report(cookie, pieces, 1);
			break;
		case ::std::error_query_information::message:
			report(cookie, pieces + 2, 1);
			break;
		default:
			report(cookie, pieces, 3);
			break;
		}
	},
	.do_to_errc = [](::std::size_t code) noexcept -> ::std::errc {
		switch (static_cast<csv_errc>(code))
		{
		case csv_errc::unterminated_quote:
			return ::std::errc::invalid_argument;
		case csv_errc::field_too_long:
		case csv_errc::too_many_fields:
			return ::std::errc::value_too_large;
		default:
			return ::std::errc{};
		}
	}};
} // namespace csv_details

namespace std
{
template <>
class error_domain<::csv_errc>
{
public:
	static inline constexpr ::std::error_domain_singleton const *domain() noexcept
	{
		return __builtin_addressof(::csv_details::singleton);
	}
	static inline constexpr ::std::size_t code(::csv_errc e) noexcept
	{
		return static_cast<::std::size_t>(static_cast<::std::uint_least32_t>(e));
	}
};
} // namespace std

int main()
{
	try
	{
		throw throws csv_errc::unterminated_quote;
	}
	catch throws (::std::error e)
	{
		::fast_io::io::print("name         ", ::fast_io::mnp::name(e), "\n"
			"message      ", ::fast_io::mnp::message(e), "\n"
			"name_message ", e, "\n"
			"as posix     ", ::fast_io::mnp::posix_equivalent_name_message(e), "\n"
			"equivalent   ", ::fast_io::mnp::boolalpha(e.equivalent(::std::errc::invalid_argument)), "\n"
			"is struct    ", ::fast_io::mnp::boolalpha(e.is_code_of<::std::errc>()), "\n");
	}
}

name         csv:unterminated_quote
message      a quoted field reached the end of the record
name_message csv:unterminated_quote: a quoted field reached the end of the record
as posix     [posix](22)Invalid argument
equivalent   true
is struct    false

最后两行要一起读。equivalent 是 true,因为 do_equivalent 向两边的 do_to_errc 都问了一遍;而 is_code_of<::std::errc>() 是 false,因为错误域仍然是你自己的:equivalent 讲的是含义,身份讲的是那个指针。两个答案都有用,而且它们是两个不同的问题。

boolalpha 不是装饰:不加它,println 打印 bool 会给出 0 和 1。

report(cookie, pieces, 3) 这句调用就是整个打印协议。cookie 是调用者输出的不透明句柄;scatter 是 {指针, 长度} 对,所以不会构造任何临时字符串,也不涉及假定有结尾 NUL 的 char const *。name_message 给三段,name 和 message 各给一段 —— 契约仅此而已,而同一份代码于是可以打印到控制台、文件、套接字或缓冲区。

5. 替换随运行时提供的错误域

有时现成的表对你的环境来说是错的:一个有自己的错误编号的 freestanding 运行时、一块 errno 值与宿主不同的裸机板子、一个想证明错误路径确实走过了的测试框架、一个必须把消息文本换成自己语言的平台。预定义的错误域藏在几行 extern "C" 入口后面,而在 ELF 上它们是弱符号 —— 对我们的运行时执行 nm -D 会看到 W。在你自己二进制里定义同一个符号,被使用的就是你的那份:


#include <fast_io.h>
#include <fast_io_dsal/string_view.h>

/* one environment, one table: the default posix domain does not apply here */
namespace
{
inline constexpr ::fast_io::string_view host_messages[]{
	"no error",
	"operation not permitted",
	"no such file or directory"};

inline constexpr ::std::error_domain_singleton host_posix{
	.do_query_information = [](::std::size_t code, ::std::error_query_information,
							   ::std::error_reporter_encoding, void *cookie,
							   ::std::error_reporter_io_cookie_function report) noexcept {
		if (code >= ::std::size(host_messages))
		{
			return;
		}
		::std::io_scatter_t const piece{host_messages[code].data(), host_messages[code].size()};
		report(cookie, __builtin_addressof(piece), 1);
	},
	.do_to_errc = [](::std::size_t code) noexcept -> ::std::errc {
		return static_cast<::std::errc>(code);
	}};
} // namespace

extern "C" ::std::error_domain_singleton const *__cxa_error_domain_posix() noexcept
{
	return __builtin_addressof(host_posix);
}

int main()
{
	try
	{
		throw throws ::std::errc::no_such_file_or_directory;
	}
	catch throws (::std::error e)
	{
		::fast_io::io::print("message ", ::fast_io::mnp::message(e), "\n");
	}
}

message no such file or directory

别的什么都没变:程序照样抛 ::std::errc,编译器照样发出一段两字的通道。移动的只是指针背后的那张表。这就是把行为放进表而不是放进类型的好处:环境需要时,可以替换掉整个环境的错误模型,而不必改一行报错的代码。

当环境确实不同时才这么做;不要为了给普通程序的措辞"定制"而这么做 —— 随运行时提供的错误域带着平台自己的语言写成的平台自己的消息,而这几乎总是正确答案。

6. Windows:一个运行时,两套 C++ ABI

同样的表驱动设计,也是 Windows 运行时只是一个普通的 herbceptions.dll、两套工具链都能链接它的原因。它的导出表里只有 C 名字:


__cxa_error_code_msvc_exception_ptr
__cxa_error_code_msvc_exception_ptr_cast
__cxa_error_code_msvc_exception_ptr_unbox
__cxa_error_domain_cmath
__cxa_error_domain_com
__cxa_error_domain_http
__cxa_error_domain_msvc_exception_ptr
__cxa_error_domain_nt
__cxa_error_domain_parse
__cxa_error_domain_posix
__cxa_error_domain_tls
__cxa_error_domain_win32
__cxa_error_domain_wine
__cxa_error_query_information_clone
__cxa_error_query_information_create
__cxa_error_query_information_free

接口里没有经过名字修饰的 C++ 名字、没有 std::string、没有 std::exception:::std::error 就是两个字,所以边界也就是两个字。用 MinGW-GCC 构建的二进制和用 MSVC 构建的二进制可以链接同一个 DLL,并互相把错误抛进对方的代码。这恰恰是 Itanium/MSVC 的分裂对 std::exception 做不到的事 —— 也正因为如此,运行时还带着 ::std::legacy_exception_abi,取值有 itanium、microsoft 和 platform,好让遗留异常的桥知道自己在往哪套 ABI 上搭(第19.6章)。

7. 添加错误域之前的检查清单