第7.7章: 编译期的赫布异常
概述
赫布异常是本教程中唯一的错误机制,因此它们在编译期代码中同样工作 也就不足为奇了。这里多了一条规则,而正是这条规则让本页有价值:
-
constexpr/consteval函数可以像运行时函数一样 被声明为throws。 -
在常量求值内部,赫布异常的行为就像一个流向调用者的错误值
——
try { } catch throws (::std::error e) { }可以捕获它, 程序继续求值。 - 如果没有任何东西捕获它,这个常量表达式就无效:编译器 发出诊断,编译失败。一个未达成的期望变成构建错误, 而不是运行时的意外。
最后一点正是本页存在的理由:借助赫布异常,你可以用程序运行时所用的 同一批函数,在构建期校验常量、表和算法, 并在检查失败时得到编译错误。
1. 一个可能失败的 constexpr 函数
constexpr 与 throws 是彼此独立的标注:前者
说“这也可以在编译期运行”,后者说“这可以报告错误”。
两者同时出现没有问题:
#include <fast_io.h>
using namespace ::fast_io::io;
constexpr int checked_div(int a, int b) throws
{
if (b == 0)
{
throw throws ::std::errc::invalid_argument;
}
return a / b;
}
// the happy path is a constant expression
static_assert(checked_div(10, 2) == 5);
int main()
{
println(checked_div(9, 3), "\n"); // also usable at runtime
}
不需要任何特别的东西:同一个函数在有效输入上既是编译期常量表达式, 在运行时也是一个普通的可能失败的函数。
2. 在编译期捕获
处理程序在常量求值期间是允许的,因此包装函数可以决定 失败意味着什么:
constexpr int checked_div(int a, int b) throws
{
if (b == 0)
{
throw throws ::std::errc::invalid_argument;
}
return a / b;
}
constexpr int safe_div(int a, int b) noexcept
{
try
{
return checked_div(a, b);
}
catch throws (::std::error) // recoverable: turn it into a value
{
return -1;
}
}
static_assert(safe_div(10, 2) == 5); // no failure
static_assert(safe_div(1, 0) == -1); // failure, caught at compile time
两个断言都由编译器求值。safe_div 是
noexcept 的,恰恰因为它把错误吸收掉了——这正是
第6.16章的规则在编译期的应用。
consteval 函数的工作方式相同:
consteval int compile_time_div(int a, int b)
{
try
{
return checked_div(a, b);
}
catch throws (::std::error)
{
return -1;
}
}
static_assert(compile_time_div(9, 3) == 3);
static_assert(compile_time_div(9, 0) == -1);
3. 未捕获的赫布异常就是编译错误
当赫布异常逃出常量表达式时,编译器就停下。没有 运行时可供陷入陷阱,于是构建失败,诊断信息会指出抛出 位置:
consteval int must_be_positive(int v) throws
{
if (v <= 0)
{
throw throws ::std::errc::invalid_argument;
}
return v;
}
constexpr int dimensions{must_be_positive(0)}; // ERROR: nothing caught it
error: constexpr variable 'dimensions' must be initialized by a constant expression
note: herbception thrown during constant evaluation was not caught
这正是你希望校验器具备的行为:源码里一个错误的常量,就是包含它的那一行上的
构建失败,并且带着你选择的错误码。对比运行时 assert:
它只在程序被运行时才触发,而且只在程序走到那条代码路径时
才触发。
// fine: the value is valid
constexpr int width{must_be_positive(80)};
static_assert(width == 80);
4. 构建期测试套件
这一切最有价值的用法是编译期测试:一个
constexpr bool 函数,对某个组件进行检验并通过
static_assert 报告结果。没有测试框架,没有运行时,没有构建目标
——组件坏了就编译不过。
#include <fast_io.h>
using namespace ::fast_io::io;
constexpr int checked_div(int a, int b) throws
{
if (b == 0)
{
throw throws ::std::errc::invalid_argument;
}
return a / b;
}
constexpr bool self_test() noexcept
{
// 1. the happy paths
try
{
if (checked_div(10, 2) != 5)
{
return false;
}
if (checked_div(-9, 3) != -3)
{
return false;
}
}
catch throws (::std::error)
{
return false; // should not have failed
}
// 2. the failure path, including the exact error it reports
try
{
(void)checked_div(1, 0);
return false; // should have failed
}
catch throws (::std::error e)
{
return e == ::std::errc::invalid_argument;
}
}
static_assert(self_test(), "checked_div is broken");
int main()
{
println("self test passed at compile time\n");
}
注意 self_test 是 noexcept 的,并且捕获了一切:一个
自身可能失败的测试就不再是编译期检查,而是编译期崩溃。
5. 向编译器询问错误契约
由于规范是类型的一部分,它可以在编译期间被查询。 有两种工具:
noexcept(expression)—— 你在 第6.16章中见过的标准工具: “这次调用是否不可能失败?”-
赫布异常 traits
::std::is_herbceptions_throws_*—— 更细粒度的 工具,用来区分“是否可以构造/调用”与 “是否可以通过 throws 通道构造/调用”。
struct ok_default { ok_default() throws {} };
struct plain { };
// is the default constructor declared 'throws'?
static_assert(::std::has_herbceptions_throws_constructor_v<ok_default>);
static_assert(!::std::has_herbceptions_throws_constructor_v<plain>);
// can we construct it, and can that construction report?
struct from_int { explicit from_int(int) throws {} };
static_assert(::std::is_herbceptions_throws_constructible_v<from_int, int>);
// copies that can report
struct reporting_copy { reporting_copy(reporting_copy const &) throws {} };
static_assert(::std::is_herbceptions_throws_copy_constructible_v<reporting_copy>);
static_assert(!::std::is_herbceptions_throws_copy_constructible_v<plain>);
// callables
constexpr int risky(int) throws { return 0; }
constexpr int pure(int) noexcept { return 0; }
static_assert(::std::is_herbceptions_throws_invocable_v<decltype(risky), int>);
static_assert(!::std::is_herbceptions_throws_invocable_v<decltype(pure), int>);
noexcept(expr) 回答的是你通常关心的
问题(“这次调用我必须处理吗?”)。这些 traits 是为那些必须根据操作是否报告
错误来选择实现的泛型代码而存在的——这恰恰就是像 fast_io
这样的 freestanding 库在内部所做的事。你自己很少会需要它们,
但你会读到它们。
6. 这一切为何能成立
常量求值没有栈展开、没有堆、没有异常对象——而
赫布异常也不需要这些。错误就是一个
{domain, code} 对,因此求值器只需要携带两个整数和一个
“失败”位;域查找(::std::error_domain<T>::domain())在常量求值期间
被拦截,这正是被抛出的值能成为常量表达式、而不是仅限运行时对象的原因。
运行时也具备同样的性质,这就是为什么在编译期与运行时之间移动代码时
没有任何变化:checked_div 的错误路径
真的就是一个返回。
要点
-
constexpr/consteval函数可以是throws;这两个标注彼此独立。 -
try { } catch throws (::std::error e) { }在常量求值期间 可用,因此 constexpr 包装函数可以从编译期失败中恢复。 -
常量表达式中未捕获的赫布异常会使构建失败,并报告
herbception thrown during constant evaluation was not caught—— 用它来校验常量。 -
一个
constexpr bool self_test() noexcept加上static_assert(self_test())就是一个真正的编译期测试套件,使用 与程序相同的函数。 -
noexcept(expr)与::std::is_herbceptions_throws_*让 泛型代码在生成调用之前就能询问某次调用可能如何失败。 - 这一切之所以可能,是因为赫布异常只是两个机器字,没有 展开、没有分配,也没有异常对象。