Ch19.1: Defining and Overriding Error Domains

中文(简体)

1. First: You Almost Certainly Do Not Need a Domain of Your Own

You already know how to throw and catch a herbception (Ch3.16): every error is two words, {domain, code}, and the domain is a pointer to a table of functions that knows what the code means. This page is about the table itself, and the first rule is the one that saves the most work:

Use a domain that already exists. A domain is not "the error type of one library". It is a code space, and one code space is enough for an entire program. The runtime ships nine of them, and between them they cover essentially everything a normal program can fail at:

Domain type Code space Where it applies
::std::errc POSIX errno values Everywhere. This is the default tongue of the platform layer
::std::parse_errc ok, end_of_file, partial, invalid, overflow Text-to-value conversion, in every format
::std::http_errc HTTP status codes, the wire value itself Clients and servers
::std::cmath_errc FE_INVALID, FE_DIVBYZERO, FE_INEXACT, FE_OVERFLOW, FE_UNDERFLOW Maths, where the answer is a value and a flag together
::std::tls_alert TLS alert level and description Any TLS connection
::std::nt_errc NTSTATUS STATUS_* Windows kernels and native APIs
::std::win32_errc Win32 GetLastError ERROR_* Win32 API
::std::wine_errc Wine's own host errno space Wine's Unix side, where the values are not the host libc's
::std::com_errc HRESULT COM and everything built on it

The reason this works is the fourth function in the table below: every domain must be able to answer "which ::std::errc is this?" That single obligation is what makes error handling interoperable. The caller does not need to know which library produced the failure, because a failure from any domain can be compared against ::std::errc::no_space_on_device and can be printed as a POSIX name and message. This is the opposite of the old C++ std::error_code world, where every library invented a std::error_category and no two of them could be compared.

So: a new domain is for an author of a library that owns a real code space — a protocol, a file format, a device. If your code returns ::std::errc::invalid_argument and ::std::errc::value_too_large like everybody else, you are done, and the page is over for you.

2. What a Domain Actually Is

::std::error_domain_singleton is a struct of five function pointers. That is the whole ABI: the two-word error carries a pointer to one of these, and a code.


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

Four of the five may be left nullptr. One may not.

Hook What it must do If it is nullptr
do_to_errc Translate your code into the nearest ::std::errc Mandatory. The compiler rejects a throw throws whose domain has no do_to_errc, and e.to_errc() is how every other library recovers your failure
do_query_information Answer one of three questions about a code: name, message or name_message Printing the error prints nothing. The value still works; you just cannot read it
do_equivalent Say whether your code and another domain's code mean the same failure e.equivalent(other) compares nothing. Compare to_errc() instead, which is the default in the shipped domains
do_cleanup Free whatever the code owns Your codes own nothing. This is what you want whenever the code fits in ::std::size_t, which is every case in this chapter
do_throw_legacy_exception Re-throw the code as a native C++ exception of the platform's own type The runtime boxes the two words into ::std::cxx_std_error_legacy_exception instead (Ch19.6)

Note what is not in that struct: no name, no message string, no what(), no base class, no type. A code is an integer and the table is constant data, so a domain costs no allocation, no RTTI and no per-error object. The information hook exists because text has to be produced somewhere, and it produces it lazily, straight into the caller's output buffer, in the caller's encoding.

That last part is why the query hook takes an encoding and a callback rather than returning a char const *: the same code can be printed as UTF-8, UTF-16, UTF-32, GB18030 or EBCDIC into whatever stream asked, and the table is written once.

3. The Specialization: error_domain<T>

Users never touch the singleton directly. throw throws value asks two questions of ::std::error_domain<T>: which singleton, and what code. Specialize it once, next to the error type:


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

Both functions must be static, inline, constexpr and noexcept: domain() is intercepted by the constant evaluator, so a throw throws inside a constexpr function works, and it is called on every throw, so it must not do anything.

There is one more member you may provide, and no more: static T from_std_error(::std::error const &). If it exists, ::std::herbception_cast<T>(e) uses it to turn a caught ::std::error back into your enum. Provide it when the translation from errc is lossy and you want the original code back. Also, a domain may name a domain_alias_type when several types share one singleton — then is_code_of<T>() and == compare against the alias instead of the type. Neither is needed for a first domain.

4. A Domain of Your Own

Here is the whole thing: a CSV reader with four failures. The type, two tables of text, one singleton, one specialization, and nothing else.


#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

Read the last two lines together. equivalent is true because do_equivalent asked do_to_errc about both sides, and is_code_of<::std::errc>() is false because the domain is still yours: equivalent is about meaning, identity is about the pointer. Both answers are useful, and they are different questions.

The boolalpha is not decoration: println of a bool prints 0 and 1 unless you say otherwise.

The report(cookie, pieces, 3) call is the whole printing protocol. The cookie is an opaque handle to the caller's output; the scatters are {pointer, length} pairs, so no temporary string is built and no char const * with an assumed NUL is involved. Give three pieces for name_message, and one each for name and message — that is the entire contract, and the same code then prints to a console, a file, a socket or a buffer.

5. Replacing a Shipped Domain

Sometimes the shipped table is wrong for your environment: a freestanding runtime with its own error numbering, a bare-metal board whose errno values are not the host's, a test harness that wants to prove the error path was taken, a platform where the message text must be replaced by your own language. The predefined domains live behind five-line extern "C" entry points, and on ELF they are weak symbols — nm -D on our runtime shows them as W. Define the same symbol in your own binary and yours is the one that is used:


#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

Nothing else changed: the program still throws ::std::errc, and the compiler still emits a two-word channel. Only the table behind the pointer moved. That is the advantage of putting the behaviour in a table instead of in the type: a portal can replace the environment's error model without touching a single line of the code that reports errors.

Do this when the environment genuinely differs, and do not do it to "customise the wording" of an ordinary program — the shipped domains carry the platform's own messages in the platform's own language, which is almost always the right answer.

6. Windows: One Runtime for Two C++ ABIs

The same table-based design is why the Windows runtime is a plain herbceptions.dll that both toolchains can link. Its export table is C names only:


__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

No mangled C++ names, no std::string, no std::exception in the interface: ::std::error is two words, so the boundary is two words. A MinGW-GCC-built binary and an MSVC-built binary can both link the same DLL and throw into each other's code. That is exactly what the Itanium/MSVC split cannot do for std::exception — which is why the runtime also carries ::std::legacy_exception_abi, with itanium, microsoft and platform values, so a legacy-exception bridge knows which ABI it is bridging to (Ch19.6).

7. Checklist Before You Add a Domain