Ch7.7: Herbceptions at Compile Time

中文(简体)

IMPORTANT. Read Ch3.16 (the statements) and Ch6.16 (the specification on every function) first. This page assumes both and shows what herbceptions mean inside constexpr and consteval code.

Overview

Herbceptions are the only error mechanism in this tutorial, so it should not be a surprise that they work in compile-time code too. There is one extra rule, and it is the one that makes this page useful:

That last point is the reason this page exists: with herbceptions you can validate constants, tables and algorithms at build time using the same functions your program runs, and get a compile error when a check fails.

1. A constexpr Function That Can Fail

constexpr and throws are independent annotations: the first says "this may also run at compile time", the second says "this can report an error". Both together are fine:


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

Nothing special was needed: the same function is a compile-time constant expression on valid input and a normal fallible function at runtime.

2. Catching at Compile Time

The handler is allowed during constant evaluation, so a wrapper can decide what a failure means:


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

Both assertions are evaluated by the compiler. safe_div is noexcept precisely because it absorbs the error — that is the Ch6.16 rule applied at compile time.

A consteval function works the same way:


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. An Uncaught Herbception Is a Compile Error

When a herbception escapes a constant expression, the compiler stops. There is no runtime to trap into, so the build fails, and the diagnostic names the throw site:


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

This is exactly what you want from a validator: a wrong constant in the source is a build failure at the line that contains it, with the error code you chose. Compare with a runtime assert: that only fires if the program is run, and only if it reaches that code path.


// fine: the value is valid
constexpr int width{must_be_positive(80)};
static_assert(width == 80);

4. Build-Time Test Suites

The most valuable use of all this is a compile-time test: a constexpr bool function that exercises a component and reports through static_assert. No test framework, no runtime, no build target — a broken component does not compile.


#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");
}

Note that self_test is noexcept and catches everything: a test that can itself fail would not be a compile-time check, it would be a compile-time crash.

5. Asking the Compiler About Error Contracts

Because the specification is part of the type, it can be queried during compilation. Two tools:


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>);
Which one to use? noexcept(expr) answers the question you normally have ("must I handle this call?"). The traits exist for generic code that has to select an implementation depending on whether the operation reports errors — which is exactly what a freestanding library like fast_io does internally. You will rarely need them yourself, but you will read them.

6. Why This Works at All

Constant evaluation has no stack unwinding, no heap, no exception objects — and herbceptions need none of those. The error is a {domain, code} pair, so the evaluator only has to carry two integers and a "failed" bit; the domain lookup (::std::error_domain<T>::domain()) is intercepted during constant evaluation, which is what makes a thrown value a constant expression instead of a runtime-only object.

The same property holds at runtime, and it is why nothing changes when you move code between compile time and runtime: the error path of checked_div is literally a return.

Key Takeaways