· 7 min read ·

How C++20 Made std::vector Work at Compile Time

Source: isocpp

The exercise Quasar Chunawala walks through on isocpp.org — building your own vector<T> from scratch — teaches you placement new, the growth factor tradeoffs, and the move_if_noexcept dance during reallocation. What it will not show you unless you push further is what C++20 had to do to make std::vector usable in constant expressions. That work was not library work. It required changing the language.

Why vector Couldn’t Be Constexpr Before C++20

Constant expression evaluation in C++14 and C++17 operated under strict constraints: no dynamic memory allocation, no pointer arithmetic past array bounds, no undefined behavior of any kind. The rules were conservative because the compiler acts as the evaluator, and tracking arbitrary heap usage at compile time requires the compiler to behave like a virtual machine with full memory accounting.

A vector’s core operation — allocating a raw buffer and constructing elements via placement new — ran into a specific structural problem. Placement new looks like this:

new (static_cast<void*>(ptr)) T(value);

The void* cast in a placement new expression was not permitted in a constant evaluation context. The compiler had no way to track what ptr pointed to through an opaque void*, and constant evaluation requires tracking every object precisely to detect undefined behavior. No void pointers meant no placement new. No placement new meant no separation of allocation from construction. No separation of allocation from construction meant no dynamic array.

This was not a restriction specific to std::vector. Any code that called operator new to obtain raw storage and then used placement new to construct into it was off-limits in constexpr contexts. Which is to say: the entire architecture of a dynamic container was incompatible with constant evaluation.

What P0784 Changed

The C++20 standard included P0784 “More constexpr containers”, which addressed three things simultaneously.

First, it allowed dynamic memory allocation in constant expressions. C++20’s constant evaluator now tracks heap allocations explicitly. When operator new is called in a constexpr context, the compiler records that an allocation was made. When operator delete is called, it marks that allocation freed. The evaluator can then detect allocation leaks and use-after-free at compile time.

Second, it made std::allocator<T> constexpr-capable. The allocator’s allocate function calls ::operator new, which in a constexpr context triggers the compiler’s allocation tracking rather than a real heap allocation. std::allocator_traits became the correct path for constexpr-capable containers.

Third, it introduced std::construct_at as a constexpr-compatible replacement for placement new. Its declaration is simple:

template<class T, class... Args>
constexpr T* construct_at(T* p, Args&&... args);

Internally, its implementation calls the same placement new as before:

return ::new(const_cast<void*>(static_cast<const volatile void*>(p)))
    T(std::forward<Args>(args)...);

The difference is that std::construct_at is a named operation the compiler recognizes. When the constant evaluator encounters a call to std::construct_at, it knows the precise type and the target address and can track the construction correctly. The void* cast inside the implementation is fine because the evaluator sees through the std::construct_at call rather than evaluating its body as if it were arbitrary user code.

C++17 had already added std::destroy_at for explicit destructor invocation. In C++20, it became constexpr, completing the symmetric pair: std::construct_at constructs in pre-allocated memory, std::destroy_at destroys without deallocating.

The No-Leak Rule

Here is the constraint that catches people off guard. A constant expression evaluation cannot complete with any outstanding heap allocations. Every allocation made during a constexpr evaluation must be freed before the evaluation ends.

This means the following is invalid in C++20:

constexpr std::vector<int> primes = {2, 3, 5, 7, 11};  // Error

The vector’s constructor would allocate a buffer. That allocation cannot persist into a constexpr variable because the compiler’s allocation tracking ends when the constant evaluation completes. A heap pointer in a constexpr object would point to nothing. There is no runtime heap at constant evaluation time, and the standard [expr.const] explicitly prohibits constant expressions that end with unfreed allocations.

This creates an asymmetry worth understanding. constexpr std::string works fine when the string fits in SSO storage, because small string optimization keeps the data inline and no heap allocation is made. std::vector has no equivalent: all of its storage is always on the heap, so a non-empty constexpr vector variable is simply not possible.

What is valid is using a vector inside a constexpr computation that produces a scalar or non-allocating result:

constexpr int count_primes_below(int n) {
    std::vector<bool> sieve(n, true);
    sieve[0] = sieve[1] = false;
    for (int i = 2; i * i < n; ++i) {
        if (sieve[i]) {
            for (int j = i * i; j < n; j += i)
                sieve[j] = false;
        }
    }
    int count = 0;
    for (bool b : sieve) count += b;
    return count;
    // sieve destroyed here; allocation freed; evaluation is clean
}

constexpr int primes_below_100 = count_primes_below(100);  // Valid

The vector exists during the evaluation and is destroyed at the end of the function. The compiler tracks the allocation, confirms it is freed within the same evaluation, and accepts the expression. The result, an integer, carries no allocations into the constexpr variable.

You can also use a vector to produce a std::array at compile time, performing the computation with the dynamic container and extracting a fixed-size result:

constexpr auto first_n_primes() {
    std::vector<int> primes;
    for (int n = 2; primes.size() < 10; ++n) {
        bool prime = true;
        for (int p : primes)
            if (n % p == 0) { prime = false; break; }
        if (prime) primes.push_back(n);
    }
    std::array<int, 10> result{};
    for (int i = 0; i < 10; ++i) result[i] = primes[i];
    return result;  // result is a std::array with no heap allocation
}

constexpr auto primes = first_n_primes();  // Valid

The vector is the working storage, not the persistent result. This pattern shows up repeatedly in constexpr algorithms: use the dynamic container during computation, extract the answer into a fixed-size type for the return value.

What a Constexpr-Capable Custom Vector Requires

If you are extending the implementation exercise Chunawala describes to support constexpr contexts, the changes are specific.

Replace every placement new with std::construct_at:

// Before:
new (buf + i) T(value);

// After:
std::construct_at(buf + i, value);

Replace explicit destructor calls with std::destroy_at:

// Before:
(buf + i)->~T();

// After:
std::destroy_at(buf + i);

Route allocation through a constexpr-capable allocator. Since std::allocator<T> is constexpr in C++20, using std::allocator_traits with the default allocator type already handles this. A custom allocator needs its allocate and deallocate member functions marked constexpr.

Mark member functions constexpr where compile-time use is desired. Constructors, the destructor, push_back, reserve, resize, operator[], and the range accessors are all candidates.

The destructor being constexpr is notable. Before C++20, destructors could not be marked constexpr because they implicitly called operator delete. With constexpr allocation tracking in the evaluator, this is now permitted. GCC 10+ and Clang 10+ implement P0784 fully; both handle constexpr std::vector in C++20 mode.

What This Unlocks

Constexpr vectors open up compile-time computation that previously required either fixed-size arrays, template metaprogramming, or deferring to runtime.

Parsers that build intermediate data structures during compilation. Lookup table generation from computed sequences where the size is not known until runtime but should be computed once. Configuration validators that check invariants at compile time. Sorting and deduplication of data before it reaches any runtime code.

The Sieve of Eratosthenes above is a basic example. Before C++20, the same computation required a compile-time array of fixed maximum size, a template recursion that computed primes through instantiation depth, or a runtime function that had to run before anything could use the result. The constexpr vector version reads exactly like normal runtime code and runs during compilation.

C++23 extended the constexpr allocation machinery further. if consteval (from P1938) lets code behave differently when evaluated in a manifestly constant-evaluated context versus a potentially-evaluated one. This is useful for constexpr containers that want to skip certain runtime diagnostics during compilation. constexpr std::unique_ptr also arrived in C++23, using the same allocator infrastructure.

What the Exercise Teaches About the Object Model

The gap between a working runtime vector and a constexpr-capable one is small in terms of code change: swap placement new for std::construct_at, mark functions constexpr, use a constexpr-capable allocator. The machinery that makes it work is in the compiler’s handling of std::construct_at as a recognized operation, in the constexpr specifier on std::allocator::allocate, and in the evaluator’s allocation tracker.

This is a useful framing for what the implementation exercise actually teaches. The standard library’s vector is not just a carefully tuned container. It is a design that had to remain coherent as the language evolved around it, from C++11 move semantics, to C++17 std::allocator_traits conventions, to C++20 constexpr allocation. Each of those changes required the library authors to thread new capabilities through existing interfaces without breaking the ones that already existed.

Building your own version, and then trying to make it constexpr-capable, surfaces the connection between the library and the language in a way that reading the documentation alone will not. The placement-new restriction is not an arbitrary rule. It exists because the compiler’s ability to reason about object lifetimes at compile time depends on knowing the type of every object being constructed, and void* erases that information. std::construct_at preserves it. That distinction is worth understanding before you reach for any other part of the constexpr story.

Was this interesting?