Learner's Notes: Exploring C++ Undefined Behavior (Part 2) – Revisiting std::launder
Quick Note:
If you haven’t read Part 1, why are you here? Turn back and read Part 1!
Looking Back At Our Fix
In our previous post, we came up with the following implementation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<std::size_t N>
struct MyAllocator
{
std::byte data[N];
void* p;
std::size_t sz;
MyAllocator() : p(data), sz(N) {}
// Note: Only well-defined for implicit-lifetime types
template<typename T>
T* implicit_aligned_alloc(std::size_t a = alignof(T))
{
if (std::align(a, sizeof(T), p, sz))
{
T* result = reinterpret_cast<T*>(p);
p = static_cast<std::byte*>(p) + sizeof(T);
sz -= sizeof(T);
return result;
}
return nullptr;
}
};
I claimed that since the array of std::byte
implicitly creates the int
object within the region of storage occupied by the array, pointer p
now points to an object of dynamic type int
. Therefore, it’s well-defined for us to reinterpret_cast
to obtain a pointer to such an object.
But… there’s a big assumption here. I assumed that p
now points to an object of dynamic type int
because they share the same memory location. But does it really?
What does the pointer p point to?
Let’s take a quick detour and dive into [intro.object]/11:
Further, after implicitly creating objects within a specified region of storage, some operations are described as producing a pointer to a suitable created object. These operations select one of the implicitly-created objects whose address is the address of the start of the region of storage, and produce a pointer value that points to that object, if that value would result in the program having defined behavior. If no such pointer value would give the program defined behavior, the behavior of the program is undefined. If multiple such pointer values would give the program defined behavior, it is unspecified which such pointer value is produced.
This rule dictates that operations like std::malloc
will return a pointer to an object that was implicitly created in the storage region. Unfortunately, this doesn’t apply to us since reinterpret_cast
isn’t one of those “blessed” operations.
Now, the rules for what a pointer point to are quite complex. The general idea, though, is that a pointer can’t be considered to point to a different object (other than the original one) unless it follows specific rules. For example, consider the “transparently replaceable” rule in [basic.life]/8:
If, after the lifetime of an object has ended and before the storage which the object occupied is reused or released, a new object is created at the storage location which the original object occupied, a pointer that pointed to the original object, a reference that referred to the original object, or the name of the original object will automatically refer to the new object and, once the lifetime of the new object has started, can be used to manipulate the new object, if the original object is transparently replaceable (see below) by the new object. […]
But, all hope isn’t lost as there are special rules for reinterpret_cast
. Let’s check out [basic.compound]/4:
Two objects
a
andb
are pointer-interconvertible if:
- they are the same object, or
- one is a union object and the other is a non-static data member of that object, or
- one is a standard-layout class object and the other is the first non-static data member of that object or any base class subobject of that object, or
- there exists an object
c
such thata
andc
are pointer-interconvertible, andc
andb
are pointer-interconvertible.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
.
Unfortunately, none of this applies to us, so a reinterpret_cast
won’t be enough.
So what now?
Now, we need to figure out how to actually obtain a pointer that correctly refers to the implicitly created int
object. What’s the magic trick that saves us here?
Ironically, we’ve talked about this before in Learner’s Notes, yet I still missed it when making my first suggestion to cppreference. The answer? std::launder
.
Lets take a look at [ptr.launder]:
template<class T> [[nodiscard]] constexpr T* launder(T* p) noexcept;
Mandates:!is_function_v<T> && !is_void_v<T>
is true.
Preconditions:p
represents the addressA
of a byte in memory. An objectX
that is within its lifetime and whose type is similar to T is located at the address A. All bytes of storage that would be reachable through the result are reachable throughp
.
Returns: A value of typeT*
that points toX
.
Remarks: An invocation of this function may be used in a core constant expression if and only if the (converted) value of its argument may be used in place of the function invocation.
The pointer p
represents the address where the int
object resides. Since the int
object is within its lifetime and every byte of storage that would be accessible through an int*
is also accessible through p
, all necessary conditions are satisfied. As a result, std::launder
correctly provides a valid pointer to the implicitly created int
object.
Final Changes, For Real This Time
With that in mind, our fix is simple:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<std::size_t N>
struct MyAllocator
{
std::byte data[N];
void* p;
std::size_t sz;
MyAllocator() : p(data), sz(N) {}
// Note: Only well-defined for implicit-lifetime types
template<typename T>
T* implicit_aligned_alloc(std::size_t a = alignof(T))
{
if (std::align(a, sizeof(T), p, sz))
{
T* result = std::launder(reinterpret_cast<T*>(p));
p = static_cast<std::byte*>(p) + sizeof(T);
sz -= sizeof(T);
return result;
}
return nullptr;
}
};
And with that, we’ve finally eliminated the UB.
This fix is already reflected on cppreference’s std::align page. However, it’s worth noting that this might not be necessary in the future if proposal P3006 is accepted.
In Part 3, I’ll take a step back and discuss broader best practices when dealing with low-level object manipulation.
Till next time!