[[indeterminate]]
for non-static data members[[indeterminate]]
for non-static data members| Document #: | P4424R0 Latest Status |
| Date: | 2026-10-23 |
| Project: | Programming Language C++ |
| Audience: |
EWG |
| Reply-to: |
Barry Revzin <barry.revzin@gmail.com> |
[P2795R5] (Erroneous behaviour for
uninitialized reads) was adopted for C++26 and
implemented in gcc 16. In practice, it ensures that automatic storage
duration variables are initialized unless they are marked
[[indeterminate]]
— both gcc
and clang
implement the flag
-ftrivial-auto-var-init, which
specifies how automatic storage variables are initialized. GCC in C++26
mode defaults to
-ftrivial-auto-var-init=zero,
which meets the requirements of the feature. As also originally proposed
in [P2723R1] (Zero-initialize objects of
automatic storage duration).
Now, the issue is that only block storage variables can be
marked
[[indeterminate]].
And, for some reason, function parameters. But mainly local variables.
That’s straightforward enough when declaring a local
char buffer[1'024]
to read in some input. But what if you’re using a type that, as an
implementation detail, has a local buffer?
fmt::memory_bufferConsider the
{fmt}
library. One of its types, which is used both internally across every
formatting path but is also intended to be used publicly, is
fmt::memory_buffer.
Its declaration starts
with:
// The number of characters to store in the basic_memory_buffer object itself // to avoid dynamic memory allocation. enum { inline_buffer_size = 500 }; /** * A dynamically growing memory buffer for trivially copyable/constructible * types with the first `SIZE` elements stored in the object itself. Most * commonly used via the `memory_buffer` alias for `char`. * * **Example**: * * auto out = fmt::memory_buffer(); * fmt::format_to(std::back_inserter(out), "The answer is {}.", 42); * * This will append "The answer is 42." to `out`. The buffer content can be * converted to `std::string` with `to_string(out)`. */ template <typename T, size_t SIZE = inline_buffer_size, typename Allocator = detail::allocator<T>> class basic_memory_buffer : public detail::buffer<T> { private: T store_[SIZE]; // ... }; // ... using memory_buffer = basic_memory_buffer<char>;
That is,
fmt::memory_buffer
has a data member of type
char[500].
That buffer is intended to be uninitialized. It is never read
before it is written, and by now surely this library has been used by
enough people over enough time that we can trust that it gets this
right.
The problem is, in C++26, that buffer becomes initialized. gcc zeroes it by default. If I look at the code in the example in the comment, between C++23 and C++26, you can see the memset in the codegen (I abbreviated some function names here for clarity):
C++23
|
C++26
|
|---|---|
|
|
That cost shows up in benchmarks — on my machine it was roughly a
10ns fixed overhead. Which given that some
format calls only take 15-25ns, is a
significant percentage of the total cost.
Now, within the
{fmt}
library this is solvable.
memory_buffer is constructed in
about a dozen places, it’s easy enough to find them and mark them
FMT_INDETERMINATE. But
outside of
{fmt},
this is a public type. Users would have to know this is a type they have
to annotate. That seems very unlikely to happen with regularity, so
everyone will just pay this cost.
This is hardly the only example though, there are plenty of others.
std::stringfmt::memory_buffer
isn’t the only example that has unnecessary overhead.
std::string
does as well — it has a union with local storage in the small string
case. So an example like this, with a temporary, must still zero that
buffer:
auto make_name() -> std::string; auto use(std::string const&) -> void; auto temp_from_call() -> void { use(make_name()); }
C++23
|
C++26
|
|---|---|
|
|
And the same is true with local variable that uses a small string, though gcc is smart enough to group the zeroing together a bit:
auto named_short() -> void { std::string s = "hi"; use(s); }
C++23
|
C++26
|
|---|---|
|
|
std::optional<double>Similar behavior can be seen using
std::optional,
for the same reason:
auto use(std::optional<double> const&) -> void; auto lookup(int) -> std::optional<double>; auto from_call(int k) -> void { auto o = lookup(k); use(o); }
C++23
|
C++26
|
|---|---|
|
|
Consider the difference between:
struct Msg { int id; char payload[256]; // filled in later, by design explicit Msg(int i): id(i) { } }; auto send(Msg const&) -> void; auto temporary() -> void { send(Msg(42)); } auto named() -> void { [[indeterminate]] auto m = Msg(42); send(m); }
There’s nowhere in temporary to
attach
[[indeterminate]],
so if I want to avoid the memset
before writing id, I have to
implement this function as in
named()
(which compiles the same in C++23 and C++26) instead of
temporary().
But now consider I put my message in an envelope:
struct Envelope { uint64_t seq = 0; Msg msg{7}; }; auto subobject() -> void { Envelope e; send(e.msg); } auto subobject_all() -> void { [[indeterminate]] Envelope e; send(e.msg); }
Indeterminacy is all-or-nothing. C++26 gives me an extra
memset which I can remove to get
back the C++23 code, but it also drops the guarantee for
e.seq and
for any later member. There’s no way to say that just
Msg::payload
is indeterminate.
While attempting to upgrade a large library to C++26, the release
build had around 50,500 objects which were now being zero-initialized
that were not before (note that this is already excluding those cases
where an object is zero-initialized by the compiler optimizes out that
extra initialization). Of those, 88% had class type yet only involve a
mere 704 distinct class types. It’s a lot of concentration. Over 25,000
of those extra zero-initializations are just between
std::string
and types in
{fmt}
(value and
iterator_buffer being the most
common)!
74% of those overall are class types that are partly uninitialized by design, whether that’s a union, a member left for later, an inline buffer, etc. And many of those are temporaries.
A further 14% though are fully initialized, but by code that gcc could not see — because the initialization happens in an out-of-line constructor or function. For instance:
class Container { char buffer[1'024]; public: Container(); // actually does zero buffer // but you don't know that };
Uses of such types just get double-initialized.
There has to be a better way to handle this. Until then, I think we
have to build our code with
-ftrivial-auto-var-init=uninitialized.
Currently,
[[indeterminate]]
can only be applied to local variables and function parameters. This
paper proposes that it also be able to be applied on non-static data
members, with the obvious meaning. That allows types like
fmt::basic_memory_buffer
(and
fmt::value
and
std::string
and
std::optional
and …) to annotate their uninitialized members, so that only one place
needs to be annotated by only one person: the class author. Nobody has
to pay cost for initializing memory that need not be initialized.
This one change alone covers the vast majority of our extra-initialized objects. It also covers temporaries, which cannot be annotated today either. It seems like a straightforward omission.
What it does not help are those cases that are initialized, but out-of-line, and become double-initialization. I’m not sure what to do about those.
Change 9.13.6 Indeterminate storage [dcl.attr.indet]:
1 The
attribute-tokenindeterminatemay be applied to the definition of a block variable with automatic storage durationor, to aparameter-declarationof a function declaration., or to the declaration of a non-static data member. Noattribute-argument-clauseshall be present.TheWhen applied to the definition of a variable or to aparameter-declaration, the attribute specifies that the storage of an object with automatic storage duration is initially indeterminate rather than erroneous ([basic.indet]). When applied to the declaration of a non-static data member M of a class type C, the attribute specifies that for any automatic storage duration object or subobject of class type C, the storage associated with member M is initially indeterminate rather than erroneous.2 If a function parameter is declared with the
indeterminateattribute, it shall be so declared in the first declaration of its function. If a function parameter is declared with theindeterminateattribute in the first declaration of its function in one translation unit and the same function is declared without theindeterminateattribute on the same parameter in its first declaration in another translation unit, the program is ill-formed, no diagnostic required ([ifndr:dcl.attr.indet.mismatched.declarations]).
Bump the feature-test macro for the attribute in 15.2 Conditional inclusion [cpp.cond]:
- indeterminate 202403L + indeterminate 2026XXL