Why does the use of `std::aligned_storage` allegedly cause UB due to it failing to "provide storage"?

Viewed 859

Inspired by: Why is std::aligned_storage to be deprecated in C++23 and what to use instead?

The linked proposal P1413R3 (that deprecates std::aligned_storage) says that:

Using aligned_* invokes undefined behavior (The types cannot provide storage.)

This refers to [intro.object]/3:

If a complete object is created ([expr.new]) in storage associated with another object e of type “array of N unsigned char” or of type “array of N std​::​byte” ([cstddef.syn]), that array provides storage for the created object if: ...

The standard then goes on to use the term "provides storage" in a few definitions, but I don't see it saying anywhere that using a different type as storage for placement-new (that fails to "provide storage") causes UB.

So, the question is: What makes std::aligned_storage cause UB when used for placement-new?

3 Answers

The C++ standard lets a very restricted set of types serve as storage for other objects. The set of types that can serve as storage for other objects cannot themselves have alignment packaged into their type.

Imagine:

template<std::size_t N>
using bytes=std::byte[N];
template<std::size_t S, std::size_t A>
struct alignas(A) aligned{
  bytes<S> data;
};

You cannot use &aligned<12,4> to store another object safely. You cannot make a typedef that carries alignment with it with this property.

You could use aligned<12,4> a; &a.data or similar, but that is syntactically different.

Now, the standard could get around it by adding wording; but the aligned storage existing definition does not have this magic wording, and no construct in C++ can have the properties users of aligned_storage_t are expecting without such wording. I mean, UB is UB, so the compiler is free to interpret your program as if it was a program in a language with that wording... but that is swatting a standard error with a nuclear bomb.

The paper appears to be wrong on this.

If std::aligned_storage_t failed to "provide storage", then most uses of it would indirectly cause UB (see below).

But whether std::aligned_storage_t can actually "provide storage" appears to be unspecified. A common implementation that uses a struct with alignas(Y) unsigned char arr[X]; member (seemingly) does "provide storage" according to [intro.object]/3, even if you pass the address of the whole structure into placement-new, rather than the array. Even though this specific implementation isn't mandated now, I believe mandating it would be a simple non-breaking change.


If std::aligned_storage_t actually didn't "provide storage", then most use cases would cause UB:

Placement-new into an object that fails to "provide storage" is legal by itself, but...

This ends the lifetime of the object that failed to "provide storage" (aligned_storage_t), and, recursively, all enclosing objects. The next time you access any of those, you get UB.

Even if aligned_storage_t is not nested within other objects (which is rare), you'd have to be careful when destroying it, since calling its destructor would also cause UB, since its lifetime has already ended.

[basic.life]/1.5

... The lifetime of an object o of type T ends when:

— the storage which the object occupies ... is reused by an object that is not nested within [the object]

intro.object/4

An object a is nested within another object b if:

—a is a subobject of b, or

— b provides storage for a, or

— there exists an object c where a is nested within c, and c is nested within b.

Preface: This is a long answer. Sorry about that! The fundamental answer is short and sweet. But there are a lot of bad arguments out there so there are a lot of basics to touch on.

The proposal claims "Using aligned_* invokes undefined behavior (The types cannot provide storage.)" but provides no additional discussion, proof, or references to language in the standard.

This appears to be a "language-lawyering" position based on an overly literal interpretation of "provides storage" as used in 6.7.2 of the C++ standard. The claim appears to draw from section 6.7.2.3 which states:

If a complete object is created (7.6.2.7) in storage associated with another object e of type “array of N unsigned char” or of type “array of N std::byte” (17.2.1), that array provides storage for the created object ...

The apparent argument then goes on to say, to paraphrase, "Well, aligned_storage_t isn't an array of bytes. It is a struct/union/class that contains an array of bytes. So while that array could provide storage, aligned_storage_t itself cannot." The obvious issue with this argument is... if section 6.7.2.3 doesn't apply here, some other section in the standard still might. There are plenty of other "things" in the standard that "provide storage".

But all of that is irrelevant. What isn't in debate is that the unsigned char array contained within aligned_storage_t can provide storage. We can access that storage if we can get a pointer to it (e.g., for placement new or memcpy of a trivially-copyable type).

And per the standard in the clearest language, we can get a unsigned char* (which points to the beginning of array storage) through the pointer to aligned_storage_t. This is defined behavior to get a pointer to the 'storage' contained inside aligned_storage_t:

std::aligned_storage_t<sizeof(T), alignof(T)> buf;
unsigned char* storage = reinterpret_cast<unsigned char*>(&buf);
T* tptr = new(storage) T;
tptr->~T();

Why? Because aligned_storage_t is implemented either as a union with the unsigned char array as a non-static data member or as standard-layout class (e.g., POD struct) with the array as the first non-static data member.

See section 6.8.2 Compound types of the standard on pages 72-73:

4 Two objects a and b are pointer-interconvertible if:

(4.2) — one is a union object and the other is a non-static data member of that object (11.5), ...

(4.3) — one is a standard-layout class object and the other is the first non-static data member of that object, ...

And the term "pointer-interconvertible" means:

If two objects are pointer-interconvertible, then they have the same address, and it is possible to obtain a pointer to one from a pointer to the other via a reinterpret_cast (7.6.1.9). [Note: An array object and its first element are not pointer-interconvertible, even though they have the same address. — end note]

And that's it. The array contained in aligned_storage_t does provide storage and we can legally get a pointer to that storage through the "pointer-interconvertible" rules.


EDIT: To address the discussion with Language Lawyer in the comments, there is in fact a literal "pointer to an array" (fun fact). Semantically, it has one more level of indirection (in a sense, a pointer to a pointer) than an array type. But logically, the pointer to an object of type array N T -> points to the beginning of array storage -> points to the location of the first element in the array. So:

  char buf[4];
  char (*ptr_buf)[4] = &buf;
  char* ptr_elem0 = &buf[0]; 

ptr_buf and ptr_elem have different types but the same address. They are not interconvertible. See the accepted answer over here. This language in the standard forbids something like this:

struct MyStruct {
  char name[4];
  int value;
};

void g(char *chr) {
  char (*name)[4] = reinterpret_cast<char (*)[4]>(chr); // Invalid
  MyStruct* s = reinterpret_cast<MyStruct *>(name);
  // This function uses a pointer to the first element in the array to 
  // access another member of the containing struct. C++ forbids this.
  s->value = 10;
}

void f() {
  MyStruct s;
  g(&s.name[0]);
}

But that language and restriction is irrelevant here. These pointer-interconvertible "rules" are relevant to get access to first non-static element within aligned_storage_t (the array that provides storage). It doesn't matter that we can't interconvert from unsigned char* back to unsigned (*) [sizeof(T)] (because we aren't even attempting to do that).

An array is convertible to a pointer to the first element (per Section 7.3.2). And that pointer to the first element can be used to access the entire array (because pointer arithmetic has defined behavior and arrays are contiguous). That pointer is all we need to provide to placement new. Note that operator new takes a void* so array-to-pointer conversion will happen anyways. It makes no difference if we do that array-to-pointer conversion ourselves beforehand.

Related