第7.7章: 编译期的赫布异常

English

重要。请先阅读 第3.16章 (语句)与 第6.16章(每个函数上的 规范)。本页假定你已掌握这两者,并展示赫布异常在 constexpr 与 consteval 代码中的含义。

概述

赫布异常是本教程中唯一的错误机制,因此它们在编译期代码中同样工作 也就不足为奇了。这里多了一条规则,而正是这条规则让本页有价值:

最后一点正是本页存在的理由:借助赫布异常,你可以用程序运行时所用的 同一批函数,在构建期校验常量、表和算法, 并在检查失败时得到编译错误。

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. 向编译器询问错误契约

由于规范是类型的一部分,它可以在编译期间被查询。 有两种工具:


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 的错误路径 真的就是一个返回。

要点