· 6 min read ·

Three Proposals, Four Standards: How Constexpr Union Support Got Built Incrementally

Source: isocpp

The story of std::is_within_lifetime in C++26 is more interesting when you understand what it took to make it possible. The feature itself, proposed in P2641R4 by Barry Revzin and Daveed Vandevoorde and covered recently on isocpp.org by Sandor Dargo, adds a consteval function that queries whether a pointer points to an object currently within its lifetime during constant evaluation. The primary use is checking which member of a union is currently active. It is narrow, precise, and genuinely useful. But it also could not have existed without two earlier proposals that set up the infrastructure it relies on, one from C++20 and one from C++23.

This is a story about incremental language design, where each standard version contributes a piece that makes the next piece sensible.

The C++14 Floor

Constexpr in C++11 was deliberately restricted. Functions had to be single return statements; no local variables, no loops, no conditionals beyond the ternary operator. Unions could technically participate in constant expressions, but the tight restrictions made any non-trivial union usage awkward.

C++14 relaxed constexpr substantially. Local variables became legal. Loops and branches were allowed. Constexpr functions could now look like ordinary functions. For unions, this meant you could write constexpr constructors that initialized a specific member, and call constexpr member functions that read from it. But there was still a hard restriction: you could not change which union member was active inside a constexpr function. Whichever member was initialized first was locked in.

// C++14: legal
constexpr int read_int(int x) {
    union U { int i; float f; };
    U u;
    u.i = x;
    return u.i;  // fine, i is the active member
}

// C++14: not legal in constexpr
constexpr float switch_to_float(int x) {
    union U { int i; float f; };
    U u;
    u.i = x;
    u.f = 1.0f;  // error: changing active member not allowed in constexpr
    return u.f;
}

This limitation was not arbitrary. Allowing union member switches in constexpr requires the compiler’s constant evaluator to track lifetime transitions, and in 2014 the evaluator model was not built to support that. The restriction was a consequence of the implementation model, not a deliberate design choice.

C++20 and P1330R0: The Foundation

The first major unlock came with C++20 and P1330R0, titled simply “Changing the active member of a union inside constexpr.” The proposal observed that the reason for the C++14 restriction, compiler evaluators not tracking lifetime transitions, had been resolved. Modern constexpr evaluators in GCC and Clang already tracked object lifetimes in detail for other purposes. Lifting the restriction was an engineering matter of wiring that tracking through to union member assignments.

// C++20: now legal
constexpr float switch_to_float(int x) {
    union U { int i; float f; };
    U u;
    u.i = x;
    u.f = 1.0f;  // legal: evaluator tracks the active member transition
    return u.f;
}

This was the piece that made std::optional and std::variant implementable as constexpr types using union-based storage. Before P1330R0, library implementors were forced to use other storage strategies, like aligned byte arrays with placement new, that worked around the union restriction at the cost of complexity.

C++20 also added std::is_constant_evaluated(), which deserves mention here. It introduced the concept of a function that queries the evaluation context, returning true during constant evaluation and false at runtime. It was the first standard interface that directly exposed the compiler’s evaluation mode to user code. The mechanism it established, a standard function whose answer depends on the evaluator’s internal state, is exactly the mechanism that std::is_within_lifetime builds on.

C++23 and P1938R3: The Dispatch Mechanism

After C++20, union-based types could switch active members in constexpr, but a practical problem remained. If you wanted a constexpr path that behaved differently from the runtime path, std::is_constant_evaluated() worked but had a well-known footgun: it evaluated to true in contexts you might not expect, including static initializers and template arguments, which led to subtle bugs.

P1938R3, adopted in C++23, added if consteval to replace the pattern cleanly:

// C++23: clean dispatch between constexpr and runtime paths
constexpr bool has_value_impl(const StorageType& s) {
    if consteval {
        // compile-time path: use evaluator introspection
        // (not yet available in C++23, but the structure is here)
    } else {
        // runtime path: use whatever tracking the type maintains
        return s.discriminant;
    }
}

if consteval is a statement, not a function call, which means its condition is evaluated structurally rather than as a runtime expression. This eliminated the footgun and established a clean idiom for writing functions that behave differently in the two contexts. With this in place, there was a clear slot in the language for a constexpr-only introspection function: you could call it on the if consteval branch and the compiler would know statically that it was in a context where the call was valid.

C++26 and P2641R4: The Final Piece

With the C++20 and C++23 foundations in place, P2641R4 adds the function that was always missing: a way to ask the evaluator which union member is currently active.

template <typename T>
consteval bool std::is_within_lifetime(const T* p) noexcept;

The consteval specifier is the correct choice here, for the same reason if consteval exists: the question has no runtime analog. At runtime, pointer values are addresses with no attached lifetime metadata. At compile time, the evaluator maintains precise lifetime state for every object it tracks. std::is_within_lifetime exposes a read from that state.

Combined with if consteval, the full pattern for a union-based optional becomes:

template <typename T>
struct Optional {
    union {
        T value;
        char empty = '\0';
    };
    bool has_val = false;  // still needed for runtime

    constexpr bool has_value() const noexcept {
        if consteval {
            return std::is_within_lifetime(&value);  // ask the evaluator directly
        } else {
            return has_val;                          // use the runtime tracking flag
        }
    }
};

The constexpr path eliminates the redundancy: you no longer need to trust a boolean that shadows the evaluator’s knowledge. The runtime path keeps the boolean because no other mechanism exists there.

The Compiler Prior Art

Before standardization, this feature existed in compilers as a non-standard builtin. GCC exposed __builtin_is_within_lifetime, and Clang had equivalent internal machinery for the same purpose. Standard library implementors writing std::optional and std::variant were already using this functionality, guarded by preprocessor conditionals to handle the case where the builtin was unavailable.

P2641R4 follows a well-established pattern in recent C++ standardization: identify something compilers already do internally, agree on a specification, add it to the standard. std::bit_cast (C++20) went through the same process for type-punning. std::is_constant_evaluated() (C++20) followed a similar path. The feature exists before the standard catches up; standardization makes it portable and specifiable.

The three-year gap between the C++20 union switch support and the C++26 introspection support reflects this process. Compiler implementors had the internal machinery earlier, but standardization requires consensus on semantics, a specification precise enough to implement portably, and the wording work to integrate it into the standard text correctly.

What the Arc Shows

The progression from C++14’s locked active member to C++26’s queryable lifetime state is a direct line: lift the restriction on switching members, add a clean dispatch mechanism between evaluation contexts, then add the introspection function that the dispatch mechanism was designed to support.

Each standard provided the infrastructure the next one needed. P1330R0 made the evaluator’s lifetime tracking load-bearing for union types. P1938R3 established the structural context in which a consteval-only introspection call makes syntactic sense. P2641R4 provides the introspection call itself.

The result is that as of C++26, you can write a fully constexpr-correct union-based sum type using only standard language features, without reaching for __builtin_is_within_lifetime or maintaining redundant tracking state at compile time. For std::optional, std::variant, std::expected, and similar vocabulary types, the implementation can now be written to use exactly the information the evaluator already holds, rather than carrying a shadow copy of it through a separate member variable.

For most C++ developers this remains infrastructure work, something that matters through the standard library rather than through direct use. But the pattern of building evaluator introspection incrementally across standards is likely to continue. Reflection in C++26 is a much larger version of the same idea: making the compiler’s internal metadata readable from user code. std::is_within_lifetime is a small, early, and precise instance of a direction the language has been moving toward for a decade.

Was this interesting?