Learner's Notes: Exploring C++ Undefined Behavior (Part 1) – Implicit-Lifetime Types
Quick Note:
This is a three-part series exploring C++ undefined behavior and best practices to avoid it in our specific use case. No spoilers on the next two parts — let’s dive in!
The Start of the Mystery
The other day, I was digging into std::align
and came across this code example on cppreference. Here it is for reference:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <iostream>
#include <memory>
template<std::size_t N>
struct MyAllocator
{
char data[N];
void* p;
std::size_t sz;
MyAllocator() : p(data), sz(N) {}
template<typename T>
T* aligned_alloc(std::size_t a = alignof(T))
{
if (std::align(a, sizeof(T), p, sz))
{
T* result = reinterpret_cast<T*>(p);
p = (char*)p + sizeof(T);
sz -= sizeof(T);
return result;
}
return nullptr;
}
};
int main()
{
MyAllocator<64> a;
std::cout << "allocated a.data at " << (void*)a.data
<< " (" << sizeof a.data << " bytes)\n";
// allocate a char
if (char* p = a.aligned_alloc<char>())
{
*p = 'a';
std::cout << "allocated a char at " << (void*)p << '\n';
}
// allocate an int
if (int* p = a.aligned_alloc<int>())
{
*p = 1;
std::cout << "allocated an int at " << (void*)p << '\n';
}
// allocate an int, aligned at 32-byte boundary
if (int* p = a.aligned_alloc<int>(32))
{
*p = 2;
std::cout << "allocated an int at " << (void*)p << " (32 byte alignment)\n";
}
}
At first glance, this looks fine. The allocator gives us memory, we reinterpret_cast
it to the desired type, and everything works, right?
But hold on. There’s something subtle happening here. We’re casting a char*
to an int*
and writing to it. Seems reasonable at first, but does the C++ standard actually allow this? Or have we just walked into a well-camouflaged trap of undefined behavior?
Accessing Values Is Complicated
First things first, does accessing the value after reinterpret_cast<int*>
even make sense? Let’s break it down.
Shoutout to Seha, who was the first to point out a potential issue with this code and also quoted the relevant part of the standard.
According to [basic.lval]/11, we have this rule:
If a program attempts to access the stored value of an object through a glvalue whose type is not similar to one of the following types the behavior is undefined:
- the dynamic type of the object,
- a type that is the signed or unsigned type corresponding to the dynamic type of the object, or
- a
char
,unsigned char
, orstd::byte
type.
So, applying this to our case, the only way this wouldn’t be undefined behavior is if int
is similar to the dynamic type of the object, which, in this case, is char
.
Now, the formal definition of similar types in the standard is pretty complicated (I had to take a second look), but cppreference gives a nice summary in this section:
Informally, two types are similar if, ignoring top-level cv-qualification:
- they are the same type; or
- they are both pointers, and the pointed-to types are similar; or
- they are both pointers to member of the same class, and the types of the pointed-to members are similar; or
- they are both arrays and the array element types are similar.
Looking at this, it’s pretty clear that int
and char
are not similar types.
So, does that mean case closed? Is this definitely undefined behavior? Or is there more to the story?
Implicitly Creating Objects
Well, there’s actually another way this could be well-defined, that is if, somehow, the dynamic type of the object is actually int
. But how would that even work? We never used placement new
, so there shouldn’t be an int
object at that memory location… right?
Let’s take a look at what it takes to create an object, according to [intro.object]/1 and [intro.object]/10:
The constructs in a C++ program create, destroy, refer to, access, and manipulate objects. An object is created by a definition, by a new-expression, by an operation that implicitly creates objects (see below), when implicitly changing the active member of a union, or when a temporary object is created. An object occupies a region of storage in its period of construction, throughout its lifetime, and in its period of destruction.
Some operations are described as implicitly creating objects within a specified region of storage. For each operation that is specified as implicitly creating objects, that operation implicitly creates and starts the lifetime of zero or more objects of implicit-lifetime types in its specified region of storage if doing so would result in the program having defined behavior. If no such set of objects would give the program defined behavior, the behavior of the program is undefined. If multiple such sets of objects would give the program defined behavior, it is unspecified which such set of objects is created.
That’s a lot of words, but the key part is this: even though there’s no explicit creation of an int
object, one could be implicitly created under the right conditions.
So, to really figure out whether this is undefined behavior, we need to answer two questions:
- Is
int
an implicit-lifetime type? - Is there an operation here that implicitly creates an
int
object? If both are true, then ourreinterpret_cast<int*>
might actually be fine. Otherwise, we’re back in UB territory.
Implicit-Lifetime Types
Let’s try to break down what implicit-lifetime types actually are. According to [basic.types.general]/9:
Scalar types, implicit-lifetime class types, array types, and cv-qualified versions of these types are collectively called implicit-lifetime types.
So what exactly are implicit-lifetime class types? They’re either:
- An aggregate whose destructor is not user-provided, or
- A class that has at least one trivial eligible constructor and a trivial, non-deleted destructor.
This is a handy bit of information for future reference, but for our case, we don’t really need to worry about this. We can already see that int
qualifies as an implicit-lifetime type because it’s a scalar type.
With that settled, we move on to the second (and more interesting) question: Is there an operation in the original implementation that implicitly creates an int
object?
Looking At CWG 2489
Originally, the C++ standard had this rule:
An operation that begins the lifetime of an array of
char
,unsigned char
, orstd::byte
implicitly creates objects within the region of storage occupied by the array.
However, this changed with the acceptance of CWG2489. Now, as per [intro.object]/3, the wording is:
An operation that begins the lifetime of an array of
unsigned char
orstd::byte
implicitly creates objects within the region of storage occupied by the array.
In simpler terms:
An operation that begins the lifetime of an array of unsigned char
or std::byte
such as a definition of the array would have implicitly created the int
object within the region of storage occupied by the array, but this no longer applies to char
arrays.
This is unfortunate for our case. Since the original implementation used an array of char
, this is undefined behavior. (There are other ways an object could be implicitly created, but none of them apply here.)
Final Changes
Let’s recap. Initially, we established that accessing the stored value of an object through an int*
is undefined behavior unless the object itself is of a type similar to int
. The only way this could be valid is through implicit object creation, which requires two conditions:
int
must be an implicit-lifetime type (which it is).- The array must be of type
unsigned char
orstd::byte
.
The issue? The original implementation used an array of char
, which violates this requirement, therefore leading to undefined behavior.
With all that said, my suggested fix can be found here. I might have overcomplicated things a little, but ultimately, the array of char
was replaced with std::byte
and the example now clearly indicates it only works for implicit-lifetime types.
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;
}
};
Till next time!
Or Is It?
Well, unfortunately no, it turns out there’s still UB in this implementation, which I missed during my first fix. We’ll revisit this in the next post. See if you can spot the UB before the next blog! For real though,
Till next time!