Another take on non-transient constexpr allocation

Document #: P4341R0 [Latest] [Status]
Date: 2026-09-01
Project: Programming Language C++
Audience: EWG
Reply-to: Barry Revzin
<>

1 Abstract

This papers proposes a new approach to solving the problem of non-transient constexpr allocation. The result is variables of types like std::vector<T> and std::string are able persist as constexpr variables and, with [P4340R0] (Extending constant template parameter support by customizing std::meta::reflect_constant), be usable as constant template parameters.

2 Introduction

Relevant reading:

The two designs laid out above basically agree on the rule for persistence:

Given a constexpr variable V whose initialization has an allocation A, that allocation is allowed to persist if evaluating the destructor of V is a constant subexpression and

  • that evaluation would free A and
  • an object stored in A may not be read during that evaluation if A is reachable as mutable.

Reads of objects in A are not constant expressions if A as reachable as mutable.

The difference between the two is: how do we determine if A is reachable as mutable? I’m going to refer to this difference between the models as, with apologies to the Motherland, trust OR verify?

2.1 The propconst model

Now, in general, I think everyone would easily prefer a sound model which just works over trusting C++ programmers to do the right thing in an increasingly complex language. However, soundness isn’t without cost.

The propconst qualifier model does achieve soundness, but it has a very large cost: we are solving a narrow problem (non-transient constexpr allocation) with a very, very global hammer (a new type qualifier). That requires us to truly globally understand what the impacts of allowing T propconst as a type are, in every context that accepts a type.

Now, I’m not going to claim that all of these problems that we’d have to work through are unsolvable. I’m not even going to claim that they’re necessarily hard. But what I think is clear is that a lot of work will have to go into making sure the rest of the language and library properly deals with the large scale of impact that will happen. And the added benefit of what we’ll be able to do as a result of such work is not really commensurate.

2.2 The marking model

On the other hand, the marking (trust) model sounds unsafe and risky. But it actually has exceedingly narrow impact, both in terms of feature scope (we only care about non-transient allocations, so the solution is to mark those allocation) and overall impact. Which makes it immediately more appealing to me.

Now, I used to think that the only way we would run into undefined behavior with the marking model is through mis-marking. But I don’t think mis-marking is actually a very big deal at all. It’s actually not very many types that need to be marked — only types that both manage their own memory and also you want to use them as constexpr variables (including as constant template parameters). Moreover, the rule for when to mark allocations is actually fairly straightforward: mark if you’re deep-const.

The standard library would mark (or not mark) all of its own types properly, and frankly just std::vector<T> and std::string alone cover the majority of the value of the feature (which is why we proposed [P3554R0] (Non-transient allocation with vector and basic_string)), and it’s easy enough for custom versions of those two types to do the right thing too by copying what we do.

As a result, initially I was going to simply propose std::mark_immutable_if_constexpr(p) as the design, as originally proposed in [P0784R5]. I implemented that approach, wrote examples, and started writing a paper. I thought such mis-marking examples weren’t a huge concern, like this one:

template <class T>
class bad_unique_ptr {
    T* ptr;

public:
    explicit constexpr bad_unique_ptr(T* p) : ptr(p) { }
    constexpr ~bad_unique_ptr() { std::mark_immutable_if_constexpr(ptr); }

    constexpr auto get() const -> T* { return ptr; }
};

auto main() -> int {
    constexpr auto p = bad_unique_ptr<bad_unique_ptr<int>>(
      new bad_unique_ptr<int>(new int(42))
    );

    p->reset(new int(43)); // oops
}

Yes, the above would be undefined behavior at runtime, but it’s an easy problem to avoid.

However, after implementing it and doing some experimentation, I realized that mis-marking isn’t the only potential source of undefined behavior in this model. We also have interior pointers. Consider these examples:

struct A {
    std::vector<int> v;
    int* p;
};

constexpr A a = []{
    std::vector<int> v = {1, 2, 3};
    int* p = v.data();
    return A{.v=std::move(v), .p=p};
}();

struct B {
    int i;
    int* p;

    constexpr B(int i) : i(i), p(&this->i) { }
    constexpr B(B const& rhs) : i(rhs.i), p(&i) { }
};

constexpr std::vector<B> bs = {1, 2, 3};

auto main() -> int {
    ++a.p[0];      // oops
    ++bs[0].p[0];  // oops
}

Both of these would be rejected by the propconst model, since the allocations here are reachable as mutable. But the marking model effectively whitelists the entire allocation, it’s not member-by-member. In both cases here, we allow the allocation — even though we can subvert that.

With mis-marking, it’s easy to point to whose fault it is and how to fix it. Just don’t mark. With the above examples, it’s not so easy? Just don’t use interior pointers inside types that want to persist allocations? Not a very compelling rule.

2.3 An alternative marking model

In [P2670R0] (Non-transient constexpr allocation), I tried to propose a significantly narrower form of propconst, such that we still get verification but we don’t have to have such a global hammer with far-reaching impact. That doesn’t pan out because of how exactly we would need to be able to specify how we propagate const-ness on a specifier level (see [P2670R1] for the vector and Matrix examples). At this point, I’ve become convinced that if we really want a verification-based model for ensuring that we have no undefined behavior, we have to go with a propconst type qualifier — which as described above, I don’t think we should do.

But instead of introducing a propconst specifier to try to still give us the behavior of the propconst qualifier, what if we instead introduced an immutable_if_constexpr specifier that gave us the behavior of std::mark_immutable_if_constexpr(p)?

std::mark_immutable_if_constexpr(p) globally blesses the allocation that p points to, but doesn’t give us any pathing differentiation of how we got there. It solves a lot of problems for us, but gives us no mechanism to reject A or B in the above example.

But if instead of we individually blessed paths to an allocation, by way of adding a specifier on non-static data members, we could get both. That is, a simplified version of vector and unique_ptr would look like:

template <class T>
class vector {
  immutable_if_constexpr T* begin_;
  immutable_if_constexpr T* end_;
  immutable_if_constexpr T* capacity_;

public:
  constexpr ~vector() {
    delete [] begin_;
  }
};

template <class T>
class unique_ptr {
  immutable_if_constexpr(std::is_const_v<T>) T* p_;

public:
  constexpr ~unique_ptr() {
    delete p_;
  }
};

We still have the rule about reachable-as-mutable. It’s just that we consider any path from a data member marked immutable_if_constexpr as if it were immutable. We’re not verifying that it’s actually only ever reachable as const, we’re trusting that the marking is correct.

3 Proposal

The concrete design I’m proposing is roughly as follows.

At the end of the initialization of a constexpr variable V, before the hypothetical destruction runs, for each surviving allocation A, we walk through every pointer and reference into A stored anywhere in the graph (in V itself or in any surviving allocation) and classify each such path as blessed or unblessed:

Then A is classified:

Paths into A
Result
all blessed, >=1 via an immutable_if_constexpr member immutable
all blessed, but only via const-typed paths mutable persistence
some unblessed, none via immutable_if_constexpr mutable persistence
mixed: some path via immutable_if_constexpr, some unblessed ill-formed (conflicting intent)

The rest of the persistence rules are:

3.1 Why composition

The compositional aspect here (blessing distributes through members) is necessary to handle a particular. Let’s say I want to write a deep-const type C that off-shores its allocation onto std::unique_ptr<int>. Something like this:

class C {
    std::unique_ptr<int> p_;

public:
    constexpr C(int i): p_(new int(i)) { }
    constexpr auto get() -> int& { return *p_; }
    constexpr auto get() const -> int const& { return *p_; }
};

constexpr auto c = C(42);
static_assert(c.get() == 42);

It’s worth walking through that that implementation would look like in each of the three models. In all cases, the constructor and getters look the same, so I’m not going to repeat them:

// with propconst, we use the type system to push
// deep const-ness into unique_ptr itself
class C1 {
    std::unique_ptr<int propconst> p_;
};

// with the way I'd implemented mark_immutable_if_constexpr
// we mark in the destructor. unique_ptr wouldn't here, because
// it's non-const, but we can do it ourselves
class C2 {
    std::unique_ptr<int> p_;

public:
    constexpr ~C2() { std::mark_immutable_if_constexpr(p_.get()); }
};

// with the specifier, it's just up to us to mark
class C3 {
    immutable_if_constexpr std::unique_ptr<int> p_;
};

3.2 Examples

You can see all of these on compiler explorer.

Ex 1: This is still ill-formed, because p leaks memory.

constexpr int* p = new int(1); // error

Ex 2: The allocation is fine and persists, but we can’t read through it as constant:

constexpr std::unique_ptr<int> p(new int(2)); // ok
static_assert(*p == 2); // error
void bump() { ++*p; } // ok! mutable at runtime

Ex 3: Now, we can read through it as constant, because it will have been marked:

constexpr unique_ptr<int const> p(new int(3)); // ok
static_assert(*p == 3); // ok

Ex 4: This is not okay, because destroying the outer unique_ptr will try to read through its allocation when it destroys the inner one:

constexpr unique_ptr<unique_ptr<int>> p(new unique_ptr<int>(new int(4))); // error

Ex 5: But this recursion works fine:

constexpr vector<string> v = {"this", "is", "so", "cool"};
static_assert(v[1] == "is");

Ex 6: Marking the inner unique_ptr const is fine, it allows the allocation to persist, but the innermost int read isn’t constant.

constexpr unique_ptr<unique_ptr<int> const> p(
    new unique_ptr<int> const(new int(6)));    // ok
static_assert((*p).get() != nullptr);          // ok: outer allocation marked
static_assert(**p == 6);                       // error: inner allocation unmarked
int& r = **p;                                  // ok: and mutable at runtime!

Ex 7: Here is a mixed example:

constexpr auto v = []{
    vector<unique_ptr<int>> v;
    v.push_back(make_unique<int>(1));
    v.push_back(make_unique<int>(2));
    return v;
}();                                    // ok
static_assert(v.size() == 2);           // ok: buffer marked
static_assert(v[0] != nullptr);         // ok: the unique_ptr objects are readable
static_assert(*v[0] == 1);              // error: pointees unmarked
void f() { *v[1] = 20; }                // ok: pointees runtime-mutable

Ex 8: std::map would work fine:

constexpr std::map<std::string_view, int> m = {{"one",1},{"two",2}}; // ok
static_assert(m.at("two") == 2);                                     // ok

Ex 9: std::shared_ptr could not work because of the control block (the destructor must read the control block), so it could not be marked, even for const:

constexpr shared_ptr<int const> sp = make_shared<int>(1);  // error, and rightly so

Ex 10: Constant template parameter usage is not content based.

template <int const* P> struct X { };
constexpr vector<int> v1 = {1, 2, 3};
constexpr vector<int> v2 = {1, 2, 3};

X<v1.data()> x1; // ok
X<v2.data()> x2; // ok
static_assert(type_of(^^x1) != type_of(^^x2)); // ok: these have different types

Ex 11: Persisted allocations are permitted results:

constexpr string s = "Some sufficiently long string as to definitely allocate";
constexpr string_view sv = s; // ok

Ex 12: Expanding over members_of works:

struct Point { int x, y; };
auto loop(Point const& p) -> void {
    constexpr auto ctx = meta::access_context::current();
    template for (constexpr meta::info r : nonstatic_data_members_of(^^Point, ctx)) {
        // look ma, no define_static_array
        (void)p.[:r:];
    }
}

Ex 13: These interior pointer examples are rejected, since they would other expose mutable writes into immutable allocations:

struct A {
    std::vector<int> v;
    int* p;
};

constexpr A a = []{
    std::vector<int> v = {1, 2, 3};
    int* p = v.data();
    return A{.v=std::move(v), .p=p};
}(); // error

struct B {
    int i;
    int* p;

    constexpr B(int i) : i(i), p(&this->i) { }
    constexpr B(B const& rhs) : i(rhs.i), p(&i) { }
};

constexpr std::vector<B> bs = {1, 2, 3}; // error

Ex. 14: Deep const adaptation

class C {
    immutable_if_constexpr std::unique_ptr<int> p_;

public:
    constexpr C(int i): p_(new int(i)) { }
    constexpr auto get() -> int& { return *p_; }
    constexpr auto get() const -> int const& { return *p_; }
};

constexpr auto c = C(42);
static_assert(c.get() == 42); // ok

3.3 Implementation Experience

Implemented in clang, here is the the patch (this commit actually both removes the std::mark_immutable_if_constexpr implementation and adds the immutable_if_constexpr specifier one) You can see what the opt-ins are for vector, string, and unique_ptr.

4 References

[P0784R5] Peter Dimov, Louis Dionne, Nina Ranns, Richard Smith, Daveed Vandevoorde. 2019-01-21. More constexpr containers.
https://wg21.link/p0784r5
[P1974R0] Jeff Snyder, Louis Dionne, Daveed Vandevoorde. 2020-05-15. Non-transient constexpr allocation using propconst.
https://wg21.link/p1974r0
[P1974R1] Jeff Snyder. 2026-06-12. Persistent constexpr allocation.
https://wg21.link/p1974r1
[P2670R0] Barry Revzin. 2022-10-15. Non-transient constexpr allocation.
https://wg21.link/p2670r0
[P2670R1] Barry Revzin. 2023-02-03. Non-transient constexpr allocation.
https://wg21.link/p2670r1
[P3554R0] Barry Revzin, Peter Dimov. 2025-01-06. Non-transient allocation with vector and basic_string.
https://wg21.link/p3554r0
[P4340R0] Barry Revzin. 2026-08-13. Extending constant template parameter support by customizing std::meta::reflect_constant.
https://wg21.link/p4340r0