Document #: | P3726R0 [Latest] [Status] |
Date: | 2025-06-03 |
Project: | Programming Language C++ |
Audience: |
CWG |
Reply-to: |
Barry Revzin <barry.revzin@gmail.com> Tomasz Kamiński <tomaszkam@gmail.com> |
[P3074R7] (trivial
union
s (was
std::uninitialized<T>
))
was adopted in Hagenberg. One of the goals of that paper was to make an
example like this work:
template <typename T, size_t N> struct FixedVector { union { T storage[N]; }; size_t size = 0; constexpr FixedVector() = default; constexpr ~FixedVector() { ::destroy(storage, storage + size); std} constexpr auto push_back(T const& v) -> void { ::new (storage + size) T(v); ++size; } }; constexpr auto silly_test() -> size_t { <std::string, 3> v; FixedVector.push_back("some sufficiently longer string"); vreturn v.size; } static_assert(silly_test() == 1);
That paper solved this problem by:
union
s have
trivial default constructors and trivial destructors, by default,
andunion
member, if that member has implicit-lifetime type.The first avoids pointless no-op empty constructors and destructors,
and the second would start the lifetime of the T[N]
member — which we need in order for the placement new to be
well-defined. Which seemed to be a pretty nice thing, as the code just
works, without any complicated intervention.
However, there is an important principle in C++ language design that Barry missed: we can’t have nice things because there are no nice things. Richard Smith sent out this example:
union U { int a, b; }; template<U u> class X {}; constexpr U make() { U u; return u; } void f(X<make()>) {}
He pointed out that today this is valid because template argument of
X
is a union object with no active
member. But with the [P3074R7] changes, this:
The relevant rule here is is 7.7 [expr.const] which says that:
22 A constant expression is either a glvalue core constant expression that refers to an object or a non-immediate function, or a prvalue core constant expression whose result object ([basic.lval]) satisfies the following constraints:
where, in that same section:
2 The constituent values of an object
o
are
Note that inactive union members are excluded, but active-yet-uninitialized union members are disallowed.
This is also a problem because the original goal of the paper was to
make types like std::inplace_vector
completely usable at compile-time, and this rule (even separate from
[P3074R7]) makes that impossible:
constexpr std::inplace_vector<int, 4> v = {1, 2};
A “normal” implementation would have union { int storage[4]; }
,
of which the first two elements are initialized but the last two are
not. And thus have indeterminate value, so this isn’t a valid constant
expression. For
int
specifically (and trivial types more broadly), this is fixable by having
the implementation simply have a int storage[4];
instead and initialize all the objects — since that’s free. But for
types that either aren’t trivially default constructible or aren’t
trivially destructible, that’s not an option, and that really shouldn’t
be the limiting factor of whether you can create
constexpr
variables (or non-type template arguments) of such types.
We’re hoping to fix both of those issues in this paper, with two fairly independent fixes.
[P3074R7] added this wording to 11.4.5.2 [class.default.ctor]:
4 If a default constructor of a union-like class
X
is trivial, then for each unionU
that is eitherX
or an anonymous union member ofX
, if the first variant member, if any, ofU
has implicit-lifetime type ([basic.types.general]), the default constructor ofX
begins the lifetime of that member if it is not the active member of its union. [ Note 1: It is already the active member ifU
was value-initialized. — end note ]AnOtherwise, an implicitly-defined ([dcl.fct.def.default]) default constructor performs the set of initializations of the class that would be performed by a user-written default constructor for that class with no ctor-initializer ([class.base.init]) and an empty compound-statement.
That wording needs to be reverted. The default constructor will no longer start lifetimes implicitly.
Instead, we allow placement new on an aggregate element to start the lifetime of the aggregate. That is, given the above implementation:
template <typename T, size_t N> struct FixedVector { union { T storage[N]; }; size_t size = 0; constexpr FixedVector() = default; constexpr ~FixedVector() { ::destroy(storage, storage + size); std} constexpr auto push_back(T const& v) -> void { ::new (storage + size) T(v); ++size; } }; constexpr auto silly_test() -> size_t { <std::string, 3> v; FixedVector.push_back("some sufficiently longer string"); vreturn v.size; } static_assert(silly_test() == 1);
This will work for the following reason:
This Paper
|
|
---|---|
Default constructor starts lifetime of array (but not any elements) | Default constructor does not start any lifetime |
The array is already within lifetime and is the active member | The act of placement-new onto the array starts the lifetime of the array and makes it the active member |
Placement new is well-defined | Placement new is well-defined |
We get to a well-defined state through a different route, but we still get to a well-defined state with reasonable code. Importantly, we don’t change the behavior of existing code (as in Richard’s example) since no lifetimes are implicitly created, and here we’re allowing a placement new that is invalid today to instead also start lifetimes.
One of the consequences of the above proposal is what happens when we compare objects that should be equivalent but got there with different paths:
// see next section for making this work, but assume it does for now constexpr auto v1 = FixedVector<int, 4>(); constexpr auto v2 = []{ auto v = FixedVector<int, 4>(); .push_back(1); v.pop_back(); vreturn v; }();
I didn’t show
pop_back()
in the above implementation, but let’s say it just does storage[--size].~T()
.
What can we say about v1
and
v2
? Well, they’re both empty
vectors, so they compare equal. However, they’re in different
states:
v1
’s anonymous union has no
active member, because we never started any lifetimes.v2
’s anonymous union does have
an active member storage
, with no
elements within lifetime.Those wouldn’t compare template-argument-equivalent, so X<v1>
and X<v2>
would be different types (for suitable template
X
). This isn’t a very serious
concern right now, since FixedVector
isn’t a structural type and that will remain true in C++26. But
nevertheless, there is an easy way to ensure equivalence: by adding a
new member to the union:
template <typename T, size_t N> struct FixedVector { struct Empty { }; union { = {}; Empty empty [N]; T storage}; size_t size = 0; constexpr FixedVector() = default; constexpr ~FixedVector() { ::destroy(storage, storage + size); std} constexpr auto push_back(T const& v) -> void { ::new (storage + size) T(v); ++size; } constexpr auto pop_back() -> void { [--size].~T(); storageif (size == 0) { = Empty(); empty } } };
Now, v1
and
v2
are in the same state: the active
member of the union is empty
.
Revert the change in [class.default.ctor]/4:
4
If a default constructor of a union-like classAnX
is trivial, then for each unionU
that is eitherX
or an anonymous union member ofX
, if the first variant member, if any, ofU
has implicit-lifetime type ([basic.types.general]), the default constructor ofX
begins the lifetime of that member if it is not the active member of its union. [ Note 1: It is already the active member ifU
was value-initialized. — end note ]Otherwise, animplicitly-defined ([dcl.fct.def.default]) default constructor performs the set of initializations of the class that would be performed by a user-written default constructor for that class with no ctor-initializer ([class.base.init]) and an empty compound-statement.
Change 11.5.1 [class.union.general]/5:
5 When either
- (5.a) the left operand of an assignment operator involves a member access expression ([expr.ref]) that nominates a union member or
- (5.b) the placement argument to a
new-expression
([expr.new]) that is a non-allocating form ([new.delete.placement]) involves such a member access expression,it may begin the lifetime of that union member, as described below.
For an expression
E
, define the setS(E)
of subexpressions ofE
as follows:
- (5.1) If
E
is of the formA.B
,S(E)
contains the elements ofS(A)
, and also containsA.B
ifB
names a union member of a non-class, non-array type, or of a class type with a trivial default constructor that is not deleted, or an array of such types.- (5.2) If
E
is of the formA[B]
and is interpreted as a built-in array subscripting operator,S(E)
isS(A)
ifA
is of array type,S(B)
ifB
is of array type, and empty otherwise.- (5.3) Otherwise,
S(E)
is empty.In an assignment expression of the form
E1 = E2
that uses either the built-in assignment operator ([expr.assign]) or a trivial assignment operator ([class.copy.assign]), for each elementX
ofS(E1)
and each anonymous union memberX
([class.union.anon]) that is a member of a union and has such an element as an immediate subobject (recursively), if modification ofX
would have undefined behavior under [basic.life], an object of the type ofX
is implicitly created in the nominated storage; no initialization is performed and the beginning of its lifetime is sequenced after the value computation of the left and right operands and before the assignment.For an expression
E
, define the setP(E)
of subexpressions ofE
as follows:
- (5.4) If
E
is of the form&A[B]
,E
is interpreted as a built-in address operator, andA[B]
is interpreted as a built-in array subscripting operator, thenP(E)
isA
ifA
is of array type,B
ifB
is of array type, and empty otherwise.- (5.5) If
E
has pointer type and is eitherthen
- (5.5.1) of the form
A + B
and is interpreted as a built-in addition operator or- (5.5.2) of the form
A - B
and is interpreted as a built-in subtraction operator,P(E)
isA
ifA
is of array type,B
ifB
is of array type, and the union ofP(A)
andP(B)
otherwise.- (5.6) Otherwise,
P(E)
is empty.In a
new-expression
with anew-placement
of the form(E)
that uses a non-allocating form ([new.delete.placement]), for each elementX
ofP(E)
that names a union member and each anonymous union memberX
that is a member of a union and has such an element as an immediate subobject (recursively), ifX
is not within its lifetime, the lifetime of an object of the type ofX
is started in the nominated storage; no subobjects are created and the beginning of its lifetime is sequenced immediately before the value computation ofE
.[ Note 2: This ends the lifetime of the previously-active member of the union, if any ([basic.life]). — end note ]
The current rule for constituent values is, from 7.7 [expr.const]/2:
2 The constituent values of an object
o
are
As mentioned earlier, this means that if we have a union { T storage[4]; }
then either there are no constituent values (if
storage
is inactive) or we consider
all of the T
s as constituent values
(even if we only constructed the first two). So we’ll need to loosen
this rule to permit objects with union members to be more usable as
constant expressions.
For the FixedVector
(aka
static_vector
aka
inplace_vector
) example, we really
only need to allow “holes” at the end of the array. But if we want to
support a different container, that is more bidirectional and supports
cheap push_front
and
pop_front
, we will also want to
support “holes” at the front of the array. So for simplicity, we’re
proposing to support holes anywhere in the array. Note that
we’re still not proposing nice syntax for actually constructing such an
array with holes. Richard on the reflector had suggested a strawperson
syntax:
// short array initializer: // initializes arr[0] and arr[1], // does not start lifetime of rest int arr[42] = {a, b, short}; // in std::allocator<T>::allocate: return new (ptr) T[n]{short};
I don’t think we strictly need to solve that problem right now, but at least we can put in the groundwork for supporting it in the future.
Until then, we’re proposing something like this change to 7.7 [expr.const]:
2 The constituent values of an object
o
are
- (2.1) if
o
has scalar type, the value ofo
;- (2.2) otherwise, the constituent values of any direct subobjects of
o
other than inactive unionmemberssubobjects (see below).The constituent references of an object
o
are
- (2.3) any direct members of
o
that have reference type, and- (2.4) the constituent references of any direct subobjects of
o
other than inactive unionmemberssubobjects.An inactive union subobject is either:
- (2.5) an inactive union member or
- (2.6) an element
A
of an array member of a union whereA
is not within its lifetime.[ Example 1:— end example ]struct A { struct X { int i; int j; }; struct Y { X x1; X x2;}; union { int i; int arr[4]; Y y;}; }; constexpr A v1; // ok, no constituent values constexpr A v2{.i=1}; // ok, the constituent values are {v2.i} constexpr A v3 = []{ A a;new (&a.arr[1]) int(1); new (&a.arr[2]) int(2); return a; }(); // ok, the constituent values are {v3.arr[1], v3.arr[2]} constexpr A v4 = []{ A a;.y.x1.i = 1; a.y.x2.j = 2; areturn a; }(); // error: the constituent values include v4.y.x1.j and v4.y.x2.i // // which have erroneous value
And extend the template-argument-equivalent rules to understand this, in 13.6 [temp.type]:
2 Two values are template-argument-equivalent if they are of the same type and
That fix ensures that:
constexpr std::inplace_vector<int, 4> v = {1, 2};
is a valid constexpr variable if the implementation uses a union { int storage[4]; }
to hold the data, because we would only consider the first two elements
of storage
as constituent values —
the fact that the last two elements are uninitialized no longer counts
against us when we consider whether
v
is a valid result of a constant
expression.
And bump the feature-test macro added by [P3074R7]:
- __cpp_trivial_union 202502L + __cpp_trivial_union 2025XXL
Thank you to Richard Smith for bringing the issue to our attention and for all the helpful suggestions.
union
s (was
std::uninitialized<T>
).