第19.1章:定义与覆盖错误域
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. 添加错误域之前的检查清单
::std::errc能表达它吗?能,就用::std::errc,结束。-
是解析吗?有
::std::parse_errc。HTTP?有::std::http_errc。数学?有::std::cmath_errc。TLS?有::std::tls_alert。 - 你写的库是否拥有别人没有的错误码空间 —— 一个协议、一种格式、一台设备?那就整个库共用一个错误域,不是每个类一个,也不是每个函数一个。
-
先实现
do_to_errc,并让它完备:每个错误码都必须映射到某个合理的值,因为世界其他地方看到的就是那个映射。 -
错误码保持稠密整数,表保持
constexpr。一个内部带堆分配的错误域,就是一个在报告失败的途中自己也会失败的错误域。 - 除非错误码真的拥有内存,否则把
do_cleanup留成 null。 -
测试
e.equivalent(::std::errc::...)的回答是否和你声称的一致,并测试你发布的每一种编码下打印都正常。