Introduction
Suppose we have \(2\) distinct types - we have a Widget and we have a Gizmo, those are different types. There is no inheritance going on here. Each one of them has a function that prints something. We are creating a Gizmo and we are casting it to a Widget and calling its .doSomething() method.
#include <print>
struct Widget{
virtual void doSomething(){
std::println("Widget");
}
};
struct Gizmo{
virtual void doSomethingCompletelyDifferent(){
std::println("Gizmo");
}
}
int main(){
Gizmo gizmo;
Widget* widget = (Widget*)(&gizmo); // [1]
widget->doSomething();
}[1] compiles - this is a C-style cast. If you look at the standard, what a C-style cast does, it says that, well it says that a C-style cast is going to try all these things : first it’s going to try const_cast, then it’s going to try static_cast, and if all these things fail, then it will try a reinterpret_cast. If that succeeds, then, that’s what you get. So, the above code is going to compile and run. The question is what is it going to do?
This is undefined behavior(UB). You can’t just cast Gizmo to Widget. If you run this code snippet on clang with optimizations on, it prints Gizmo. If you try it on gcc, the compiler detects that this is a gizmo, you cannot reach this line of code, this cannot happen and then it optimizes away everything before it that lead to it, which is basically the whole program - so it doesn’t produce any output.
What is type punning?
Type punning is like poking holes - a technique to subvert the language’s type system. There are recommended ways to perform type punning and others that you should actively seek to avoid.
A very common task is the following : you have an object of one type - let’s say a float and then you want to pretend that the bytes of this object are actually an int or vice versa.
+--+--+--+--+
float |F0|00|80|01|
+--+--+--+--+
+--+--+--+--+
int |F0|00|80|01|
+--+--+--+--+
Another class of type punning is the following. You have an object and you either want to look at the raw bytes of the object or the other way round, you just have a blob of bytes and you want to pretend, that they are an object of some type. Whereas in reality, it’s just a bunch of bytes.
+--+--+--+--+
float |F0|00|80|01|
+--+--+--+--+
+--+ +--+ +--+ +--+
char* |F0| |00| |80| |01|
std::byte* +--+ +--+ +--+ +--+
And then there is this third case, which is a bit weird, where you wanna look at an object as if it were several other objects. So, the sizes don’t match.
+--+--+--+--+
float |F0|00|80|01|
+--+--+--+--+
+--+--+ +--+--+
short[2] |F0|00| |80|01|
+--+--+ +--+--+
Type punning and the Quake-3 fast inverse square root algorithm
Let’s say you have a float and you want to look at it as an int. Why would you want to do that? Turns out, this is pretty common, especially in low-latency applications - here’s a snippet of code which is probably the most famous example of this.
// Fast inverse square root algorithm from Quake III Arena
float Q_rsqrt(float x){
float xhalf = 0.5f * x;
int i = *(int*)(&x); // [1] get bits for floating value
i = 0x5f3759df - (i >> 1); // gives an initial guess y_0
x = *(float*)&i; // [2] convert bits back to float
x = x*(1.5f - xhalf * x * x); // Newton step, repeating increases accuracy.
}This algorithm computes the inverse square root \(1/\sqrt{x}\) and it appears in the source code for Quake-3, written by the legendary game programmer John Carmack. This code works roughly \(4\) times faster than the naive 1.0/sqrt(x), and the maximum relative error over all floating point numbers is \(0.175228\) percent.
You have these two casts in there, which are reinterpret_casts. You take a float and look at it as if its an int. Of course, this is undefined behavior(UB). The above is actually C code and this is actually undefined behavior even in C. Let’s look into the C++ standard to see, why is this undefined behavior.
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 signed or unsigned type corresponding to the dynamic type of the object.
- a
char,unsigned charorstd::bytetype.
The dynamic type of the object in quake-3 example is float and we are accessing it through an int* pointer. So, it is UB.
Using unions
Consider the following code snippet:
static_assert(sizeof(float) == sizeof(int));
union U{
float f;
int i;
} u = { 1.0f };
int i = u.i;This is performing a type pun through a union. In C++, this code has undefined behavior. Because, the standard says, in a union, only \(1\) member can be active at a time.
Why do we have these rules?
Why does C++ disallow type puns? There are broadly speaking \(4\) rules that you need to keep in mind:
- Strict aliasing rules
- Object lifetime rules
- Alignment rules
- Rules for valid value representations
These are basically the rules, why you can’t do this stuff.
Strict aliasing rules
Let’s start with aliasing rules. If you look again at this piece from the standard which says:
[basic.lval]/11.3 An object of dynamic type \(T_{obj}\) is type-accessible through a reference of \(T_{ref}\) if \(T_{ref}\) is similar to:
- the dynamic type of the object.
- a type that is signed or unsigned type corresponding to the dynamic type of the object.
- a
char,unsigned charorstd::bytetype.If a program attempts to access the stored value of an object through a reference that is not type-accessible, the behavior is undefined.
So, according to the standard, if you attempt to access a type through a reference or pointer to another type, its UB. And then there is this footnote which says:
The intent of this list is to specify circumstances in which an object may or may not be aliased.
What does that mean? Let’s look at this code:
void test(int* a, int* b){
*a = 1;
*b = 2;
*a += *b;
}
int main(){
int x = 0;
int y = 0;
test(&x, &y); // [1]
return x;
}We call the function test(int*, int*) in [1]. So, we have \(2\) integers x and y, we pass pointers to these integers to this function test() and then we return the result. This program is going to return 3.
Now, we are going to do something evil. Let’s have just one integer and lets pass two pointers to this function, where the pointers point to the same integer.
void test(int* a, int* b){ // b is aliasing q. Ok, because same type
*a = 1;
*b = 2;
*a += *b;
}
int main(){
int x = 0;
int y = 0;
test(&x, &x); // [1]
return x;
}You might think, is this valid C++? Yes, it is and the program is going to return \(4\). Here, we have this case where the two pointers are aliasing each other - they are pointing to the same object. And that is okay, because the pointers are of the same type. You have an int and you access it through a pointer that is also int*. So, that’s fine.
In C++, aliasing is fine, if we have pointers to the same types. Aliasing is not fine, if we have pointer to different types. If you do something like this, you get UB.
void test(int* a, float* b){
*a = 1;
*b = 2;
*a += static_cast<int>(*b);
}
int main(){
int x{0};
test(&x, reinterprete_cast<float*>(&x)); // Undefined behavior
return x;
}This is called as the strict aliasing rule.
Object lifetime rules
For this, let’s look at the second category of type puns. Specially, the direction, where we have an array of bytes and we want to type pun the bytes into an object.
+--+--+--+--+
float |F0|00|80|01|
+--+--+--+--+
+--+ +--+ +--+ +--+
char* |F0| |00| |80| |01|
std::byte* +--+ +--+ +--+ +--+
What is object lifetime?
The lifetime of an object can be described as follows:
- The storage is allocated.
- An object is constructed and initialized, the lifetime starts.
- An object is used; its value is changed or read.
- An object is destroyed; the lifetime ends.
- The storage is deallocated.
This is in a nutshell, what every object goes through.
A short note about terminology. Objects can be created. This does not necessarily mean that their lifetime starts. It simply means that now we have got a thing with a bunch of properties that we can talk about.
However, the standard also says that objects can be destroyed. This ends the lifetime.
Lifetime is something that the standard invented to describe the semantics on the abstract machine. It has got nothing to do with the physical machine your code actually executes on. We are talking about details on the abstract machine and nothing on the physical CPU.
Variable declaration
The standard says that an object is created by a definition.
int main(){
int x = 11; // Creates the object,
// allocates storage and starts lifetime.
std::print("x = {}\n", x); // Use the object
++x; // Use the object
std::print("x = {}\n", x); // use the object
} // destroy the object, end the liftetime
// deallocate storageStorage Duration
The storage duration is the property of an object that defines the minimum potential lifetime of the storage containing the object. The storage duration is determined by the construct used to create the object.
Variables that belong to a block or parameter scope and are not explicitly declared static, thread_local or extern have automatic storage duration. The storage for these entities lasts until the block in which they are created exits. The storage for these entitities lasts until the block in which they are created exits.
int main(){
int a; // allocation of a
int b; // allocation of b
{
int c; // allocation of c
} // deallocation of c
} // deallocation of a and bStatic storage duration
[basic.stc.static]/1 All variables which belong to a namespace scope or are first declared with the
staticorexternkeywords have static storage duration. The storage for these entities lasts for the duration of the program.
int global; //static storage
static int static_global; // static storage
void f(){
static int function_local_static; // static storage
extern in extern_global; // static storage
}Thread storage duration
[basic.stc.thread]/1 All variables declared with
thread_localkeyword have thread storage duration. The storage for these entities lasts for the duration of the thread in which they are created. There is a distinct object or reference per htread, and use of the declared name refers to the entity associated with the current thread.
thread_local int thread_local_variable; // thread storage
int main(){ // at this point, one copy of thread_local_variable exists
std::thread t(); // allocate another copy
} // deallocate copiesStorage duration versus lifetime
In general, the storage duration is not the same as the lifetime of the object. The storage duration has to outlive the lifetime of the object.
[basic.life]/1 The lifetime of an object of type
Tbegins when:
- storage with the proper alignment and size of type
Tis obtained.- its initialization (if any) is complete.
When does this initialization happen? It depends on the storage duration.
Automatic storage duration
For automatic storage duration, the storage duration and the lifetime match. When they are allocated storage, their lifetime also starts.
Static and thread storage duration
For static variables, the answer to the question, when does the lifetime start is - its complicated. We need to look at:
- function-local static vs global scope.
constinitvs. dynamic initialization.- nifty counters, module dependency graph and
inlinevariables.
Object lifetime and initial value
In general, an object can have its lifetime start without a known value.
When we allocate storage for an object, the object has an indeterminate value and if we don’t do any initialization, then that object retains the indeterminate value and when we use it, we have undefined behavior.
[basic.indet]/1 When storage for an object with automatic or dynamic storage duration is obtained, the bytes comprising the storage for the object have the following initial value:
If the object has dynamic storage duration, the bytes have indeterminate values;
otherwise, the bytes have erroneous values, where each value is determined by the implementation independently of the state of the program.
Objects with static or thread-storage duration are zero-initialized.
The below code snippet is instructive:
#include <print>
int f(){
std::byte* b = new std::byte;
std::byte c = *b; // ok, c has indeterminate value
int d = c; // undefined behavior
}
int g(){
unsigned char c;
unsigned char d = c; // no erroneous behavior, but erroneous value
int e = d; // erroneous behavior
}C++26 introduces the concept of erroneous behavior: behavior that is known to be incorrect yet not undefined. It also introduces one concrete instance of erroneous behavior: in C++26, reading a default-initialized variable of automatic storage duration and scalar type now produces an erroneous value instead of an indeterminate value; where using the indeterminate value has undefined behavior, use of an erroneous values instead has erroneous behavior.
[intro.abstract]/6 If the execution of operation results in erroneous behavior, the implementation is permitted to issue a diagnostic and terminate the execution of the program.
new and delete
An object is created by a new expression.
[intro.object]/1 An object is created by a definition, by a
newexpression, by an operation that implicitly creates objects, or when a temporary object is created. An object occupies a region of storage in its period of construction, throughout it’s lifetime and in its period of destruction.
[expr.delete]/1 The delete-expression operator destroys the most derived object or array created by a new-expression.
int main(){
int* ptr = new int(11); // create the object and start the lifetime
std::print("*ptr = {}\n", *ptr); // use the object
++*ptr; // use the object
std::print("*ptr = {}\n", *ptr); // use the object
delete ptr; // destroy the object and end the lifetime
}It is also possible to create one with indeterminate values, which can lead to undefined behavior:
int main(){
int* ptr = new int; // create the object
// and start the lifetime with indeterminate value
std::print("*ptr = {}\n", *ptr); // UB
*ptr = 11;
std::print("*ptr = {}\n", *ptr); // okay
}Temporary objects
An object is created when a temporary object is created. An example is the following:
void f(const int& ref);
int main(){
f(42);
}When a temporary is created, its lifetime starts.
[class.temporary]/4 Temporary objects are destroyed as the last step in evaluating the full-expression that (lexically) contains the point where they were created.
void f(auto&&... args);
int g();
int main(){
f(11, g()); // two temporary objects were created
// ^ temporaries are destroyed here
}This means that it is safe to use the temporary objects within the function.
Temporary lifetime extension
When a reference is bound to a temporary, the lifetime of the temporary is extended to the lifetime of the reference.
int main(){
const int& ref = 42; // temporary created here
std::println("ref = {}", ref);
} // temporary destroyed here when ref is destroyedThis is really subtle however. It only happens if we bind directly to a temporary. Consider the following example:
std::vector<std::string> get_strings();
int main(){
const auto& strings = get_strings(); // [1] extended
const auto& string = get_strings()[0]; // [2] not extended
}
T& std::vector<T>::operator[](std::size_t idx);On line [1], we have got a temporary lifetime extension, because we bind a reference to a temporary, but then in the second case on line [2], we are calling the square bracket operator operator[] which returns a reference. So, we are not binding a reference to a prvalue, but we are binding a reference to a reference. So, we create a temporary object to call the operator[], then we return a reference, that reference is bound to the other reference string, but when we reach the semi-colon, we destroy the temporary object we have created and immediately we have a dangling reference.
All temporaries created within the range expression of for are destroy after the loop:
std::vector<std::string> get_strings();
int main(){
for(auto&& str : get_strings()){
std::println("{}", str);
}// temporaru destroyed here
for(auto&& c : get_strings()[0]){
std::print("{}", c);
}// also okay, temporary destroyed here.
}Placement new
The creation of an object can be broken down into \(2\) steps - allocation of storage followed by construction of an object. We can allocate storage using operator new(sizeof(T)). If the storage allocation is successful, we can manually construct an object into this pre-existing storage using placement new.
// 1. allocation
int* raw_mem = static_cast<int*>(::operator new(sizeof(int)));
// 2. placement new creates an object in raw_mem
int* ptr = ::new(static_cast<void*>(raw_mem)) int(42); The C++ standard library also provides a constexpr wrapper over placement new- std::construct_at.
namespace std{
template<typename T, typename... Args>
constexpr T* construct_at(T* ptr, Args&&... args){
return ::new(static_cast<void*>(ptr)) T(std::forward<Args>(args)...);
}
}Explicitly destroy an object
If we manually create an object using placement new, it means that, we also need to manually destroy the object. For this, we can explicitly call the destructor.
x.~T();We can also use the standalone function std::destroy_at.
namespace std{
template<typename T>
void destroy_at(T* ptr){
ptr->~T();
}
}How to allocate memory without creating objects?
We can use std::malloc. malloc gived us storage, it does not give us the object, so we need to use placement new.
void* raw_mem = std::malloc(sizeof(int)); // allocate storage
int* ptr = ::new(raw_mem) int(11); // start lifetime
std::print("*ptr = {}\n", *ptr); // use the object
std::destroy_at(ptr); // end lifetime
std::free(raw_mem); // deallocate storageHere, we have the object lifecycle completely explicit.
Similarly, we have got the global ::operator new which is the C++ version of malloc.
void* raw_mem = ::operator new(sizeof(int)); // allocate storage
// ...
::operator delete(raw_mem);More interesting is the array of unsigned char or std::byte.
#include <print>
#include <memory>
int main(){
alignas(int) std::byte buffer[sizeof(int)]; // allocate storage
int* ptr = ::new(static_cast<void*>(&buffer)) int(11); //start lifetime
std::println("*ptr = {}", *ptr); // use the object
std::destroy_at(ptr); // end lifetime
} // deallocate storageWe can also do something more interesting. We can reuse the memory of an existing object.
int main(){
int x = 11; // create an object
std::destroy_at(&x); // end the lifetime
int* ptr = ::new(static_cast<void*>(&x)) int(42); // start lifetime
std::println("*ptr = {}", *ptr); // use the object
} // end the lifetime and deallocateWe can reuse the memory of an existing object, but we have to be really careful.
Be careful when re-using memory
For starters, const means immutable.
[basic.life]/9 An object
o1is transparently replaceable by another objecto2if either:
o1is notconst.- the storage that
o2occupies exactly overlaps the storage thato1occupies.o1ando2are the same type.
[basic.life]/10 After the lifetime of an object has ended, and before the storage which the object occupied is reused or released, if a new object is created at the storage location which the original object occupied and the original object was transparently replaceable by the new object, a pointer or reference to the original object will automatically refer to the new object, and once the lifetime of the new object is started, can be used to manipulate the new object.
#include <memory>
#include <print>
struct X{ int i; };
int main(){
X x(10);
X& xref = x;
std::destroy_at(&x);
// transparent replacement of int
::new(static_cast<void*>(&x)) X(42);
std::println("xref.i = {}", xref.i);
}If these conditions are violated, we have UB.
#include <memory>
#include <print>
struct X{ int i; };
int main(){
const int x = 11;
const int* ptr = &x;
std::destroy_at(ptr);
::new(static_cast<void*>(const_cast<int*>(ptr))) int(42);
std::println("*ptr = {}", *ptr); // Undefined behavior
}Because, the compiler thinks x is const, it performs various optimizations.
However, the above is well-defined, if we have an object on the heap.
#include <memory>
#include <print>
struct X{ int i; };
int main(){
const int* ptr = new int(11);
std::destroy_at(ptr);
int* new_ptr = ::new(static_cast<void*>(const_cast<int*>(ptr))) const int(42);
std::println("*new_ptr = {}", *new_ptr); // Well-defined
std::println("*ptr = {}", *ptr); // Undefined
}Note that, for the purposes of the above discussion, you can const_cast the pointer away, as the type of the pointer does not matter, it’s the dynamic type of the object that matters.
So, the fix to these issues is std::launder. std::launder is a magic identity function. While money laundering is used to prevent people from tracing where you got money from, memory laundering is to prevent the compiler from tracing, where you got your memory from. std::launder is used to break the optimization chain. It is simply implemented as:
namespace std{
template<typename T>
T* launder(T* ptr) noexcept; // magic identity function
}When do I need to use std::launder
You use std::launder when you want to re-use the storage of:
constheap objects.- base classes
[[no_unique_address]]members.
Remember that, if you have const non-heap object, you can’t reuse the storage anyway.
#include <memory>
#include <print>
struct X{ int i; };
int main(){
const int* ptr = new int(11);
std::destroy_at(ptr);
int* new_ptr = ::new(static_cast<void*>(const_cast<int*>(ptr))) const int(42);
std::println("*new_ptr = {}", *new_ptr); // Well-defined
std::println("*ptr = {}", *std::launder(ptr)); // Okay
}Implicit object creation
The standard says:
[intro.object]/13 Some operations are described as implicitly creating objects in a specified region of storage. For each such operation, that operation implicitly creates and starts the lifetime of a zero or more objects 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.
#include <cstdlib>
struct X{ int a, b; }
X* make_x(){
// The call to std::malloc implicitly creates an object of type X
// and its subobjects a and b, and returns a pointer that X object
X* ptr = static_cast<X*>(std::malloc(sizeof(struct X)));
ptr->a = 1;
ptr->b = 2;
return ptr;
}Implicit lifetime types
According to the standard, implicit object creation only applies to implicit lifetime types.
[basic.types.general]/9 Scalar types, implicit-lifetime class types, array types, array types are collectively called implicit-lifetime types.
[[class.prop]/9] A class
Sis an implicit-lifetime class if:
- if it is an aggregate whose destructor is not user-supplied.
- it has atleast one trivial eligible constructor and a trivial, non-deleted destructor.
Essentially, a trivial default constructor does nothing. A trivial copy constructor copies the contents of the object member-by-member.
Which operations implicitly create objects?
std::malloc and its variants : ::operator new, std::allocator::allocate and other allocation functions implicitly create objects. The below is valid C++ code:
int* ptr = static_cast<int*>(std::malloc(sizeof(int))); // implicitly creates an int
*ptr = 11;Anything that starts the lifetime of an unsigned char or std::byte array also implictly creates an int.
alignas(int) std::byte buffer[sizeof(int)]; // implicitly creates an int
int* ptr = reinterpret_cast<int*>(&buffer);
*ptr = 11;std::memcpy and std::memmove implicitly create an objects, if their type is an implicit lifetime type.
alignas(int) char buffer[sizeof(int)]; // creates nothing
std::memcpy(buffer, &some_int, sizeof(int)); // creates an int
int* ptr = reinterprete_cast<int*>(buffer);
std::println("*ptr = {}", ptr);How does the compiler know what objects to create?
The answer is easy - it does time travel. Implicit object creation uses time-travel.
static_assert(
sizeof(int) == sizeof(float) && alignof(int) == alignof(float)
);
alignas(int) unsigned char buffer[sizeof(int)];
if(rand() % 2){
*reinterpret_cast<int*>(buffer) = 11; // [1]
}
else{
*reinterpret_cast<float*>(buffer) = 3.14f; // [2]
}Depending on the result of a coin flip, you need an int or a float. As soon as we reach one of those lines [1] or [2], you can imagine, the compiler travels back in time in order to put the int in there, creates a well-formed object and then execution continues.
Its important to point out that we are talking about the abstract machine. This is a voodoo trick that the standards committee came up with to make this workaround. It’s only in the head, it isn’t in the compiler.
Explicitly starting the lifetime of an object
Consider the canonical example on cppreference.com. [1] is UB, because it violates strict-aliasing and object lifetime rules. [2] is also UB, because std::launder can’t create an object where none exists in the first place. std::start_lifetime_as<T> can be used to explicitly trigger the creation and start the lifetime of an object whose type is an implicit lifetime type.
alignas(std::complex<float>) unsigned char network_data[sizeof(std::complex<float>)] {
0xcd, 0xcc, 0xcc, 0x3d, 0xcd, 0xcc, 0x4c, 0x3e
};
// auto d = *reinterpret_cast<std::complex<float>*>(network_data); [1]
// UB: network_data does not point to a complex<float>
// auto d1 = *std::launder(reinterpret_cast<std::complex<float>*>(network_data)); [2]
// still UB: no object was ever created there
auto d2 = *std::start_lifetime_as<std::complex<float>>(network_data); // [3] : OK
std::cout << d2 << '\n'; // (0.1, 0.2)Remember, this does not call complex<float> constructor - it just declares - the bytes here are now to be understood as complex<float>.
Implementation of std::start_lifetime_as
template<typename T>
T* start_lifetime_as(void* ptr){
std::memmove(ptr, ptr, sizeof(T));
return std::launder(static_cast<T*>(ptr));
}memmove just moves the bytes to itself, but also implicitly creates an object in the destination storage. And then we just std::launder to ensure that the pointer ptr can be reused.
Alignment rules
Consider the following code snippet:
struct X{
int a;
int b;
}
X* make_x(){
char storage[sizeof(X)]; // allocate storage
X* p = new(storage) X; // create a new object
p -> a = 1; // initialization
p -> b = 2;
return p;
}This code is, in general, undefined behavior(UB). The problem with this code is alignment.
[basic.align]/6.8.3 Object types have alignment requirements which place restrictions on the addresses at which an object of the that type may be allocated. An alignment is an implementation-defined integer value representing the number of bytes between successive addresses at which a given object can be allocated.
An object type imposes alignment requirements on every object of that type; stricter alignment can be requested using the
alignasspecifier.
A more realistic mental model of the memory looks like this. If you have a \(64\)-bit machine, then you the word size = \(8\) bytes and memory actually looks like this.
Byte
0 1 2 3 4 5 6 7
+--+--+--+--+--+--+--+--+
|0F|1A|5C|1B|00|00|3A|80|
+--+--+--+--+--+--+--+--+
|E2|47|9C|00|FF|FF|01|33|
+--+--+--+--+--+--+--+--+
|88|00|00|00|C4|2D|71|09|
+--+--+--+--+--+--+--+--+
|00|00|00|00|B8|4F|A6|1E|
+--+--+--+--+--+--+--+--+
|D3|91|22|58|00|00|00|00|
+--+--+--+--+--+--+--+--+
|4A|FE|17|63|9B|00|D0|8C|
+--+--+--+--+--+--+--+--+
Objects can only live at certain positions. For example, if we have a float which is \(4\)-bytes, then it has to be aligned on a \(4\)-byte boundary. So it can live at the address 0x08 or 0x18. If you have a char[5] array, it has an alignment requirement of only \(1\) byte, which means it can live at any address for instance 0x07.
References
- A deep dive into C++ object lifetimes by Jonathan Muller, C++ Now 2024.
- Type punning in modern C++ by Timur Doumer, CppCon 2019.