Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Identifying Undefined Behavior in C++ Object Access

Hello! In the previous lesson, std::forward preserved an argument’s value category as it passed through a wrapper. It did not extend that argument’s lifetime. A reference can reach the intended overload and still become unsafe if the object it denotes has already been destroyed.

This lesson gives you a way to audit the low-level C++ code you might write in an allocator, binary decoder, or other performance-sensitive path. In about 40–45 minutes, you’ll learn to identify four related sources of undefined behavior: dangling references, access to an object that does not exist, misaligned typed access, and access through an impermissible type.


An address is not a lifetime

A pointer or reference tells you where to look; it does not guarantee that an object is still there. An object needs suitable storage, and its lifetime must have begun. Its lifetime can end before its storage is released—for example, when a destructor starts—or when the storage is reused for another object. A reference has its own lifetime, which can outlast the object it refers to.

Read the opening of cppreference’s Lifetime page for those rules, then its “Access outside of lifetime” section for what goes wrong when code uses an object before its lifetime begins or after it ends.

Lifetime - cppreference.com

Read this to distinguish the existence of storage from the existence of an object within it.

In the opening text above “Temporary object lifetime,” read the lifetime rules. Focus on when an object starts and ends, and on why a reference may dangle. Then go to “Access outside of lifetime” and read the access rules, especially the entries for reading a value and accessing a non-static member. You can leave the detailed pointer-casting restrictions for a later pass.

Consider two references initialized from temporary strings:

#include <string>

const std::string& passthrough(const std::string& s) {
    return s;
}

void process() {
    const std::string& safe = std::string{"bid"};
    const std::string& dangling =
        passthrough(std::string{"ask"});

    // Using safe here is valid.
    // Reading dangling here is undefined behavior.
}

The temporary bound directly to safe lives as long as that reference. The temporary passed to passthrough survives the call but is destroyed at the end of the initialization statement. Returning its reference does not pass along lifetime extension. On the following statement, dangling still exists as a reference, but its referent does not. If the result must survive, return or store an owning std::string value instead.

Watch the selected portions of Thamara Andrade’s CppCon talk C++ Object Lifetime: From Start to Finish. The first shows a reference outliving a member object; the second examines when binding to a temporary does—and does not—extend its lifetime.

C++ Object Lifetime: From Start to Finish - Thamara Andrade - CppCon 2023

Watch these examples to trace the exact point where a reference becomes dangling.

In dangling references, follow the member reference after its owner is destroyed. Then watch temporary lifetimes. Contrast direct binding to a temporary with obtaining a reference through a function: the latter does not extend the original object’s lifetime.

A useful distinction for code review is dangling versus use of dangling. Merely retaining a reference variable beyond its referent’s lifetime is not a way to keep the referent alive; accessing the former object through it is the operation you must prevent. A successful run does not establish safety: old bytes may still be at the address.


Storage can be present while the object is absent

This matters particularly when working on allocators. Reserving bytes and constructing an object in those bytes are different operations:

#include <cstddef>
#include <string>

struct Quote {
    Quote() : symbol("unset") {}
    std::string symbol;
};

alignas(Quote) std::byte space[sizeof(Quote)];

auto* q = reinterpret_cast<Quote*>(space);
q->symbol = "bid";  // Undefined behavior

space has enough bytes and the required alignment, but no Quote was constructed there. The cast changes the pointer type; it does not run Quote’s constructor or begin the lifetime of its std::string member. Calling or accessing that member through q is invalid object access.

For an ordinary local value, the repair is simply Quote q;. If managing raw storage is actually your allocator’s responsibility, you must construct the object there and later destroy it:

#include <memory>

Quote* q = std::construct_at(reinterpret_cast<Quote*>(space));
q->symbol = "bid";   // A Quote and its string now exist.
std::destroy_at(q);  // Neither may be accessed afterward.

The aligned storage must itself remain available throughout that interval. C++20 also has rules that can implicitly create certain implicit-lifetime objects in byte storage; do not generalize this example into “every access through a casted byte buffer is invalid.” It does show why a cast cannot stand in for constructing a type such as this Quote.


Alignment is a separate requirement

Even when your goal is only to read four bytes, a typed load claims that its address is suitable for that type. On the x86 Linux machine available to you, some unaligned hardware loads may appear to work. That does not make a misaligned C++ object access valid.

Timur Doumler’s CppCon talk Type punning in modern C++ illustrates the distinction between a byte array’s alignment and a typed object’s alignment. Focus on that requirement rather than treating the talk’s std::aligned_storage example as the preferred C++20 interface; alignas is sufficient to express the alignment needed in our examples.

Type punning in modern C++ - Timur Doumler - CppCon 2019

Watch this portion to see why enough bytes do not necessarily provide a suitable address for a typed object.

In alignment requirements, follow the contrast between an array of bytes and a typed object of the same size. Notice why hardware tolerance of an unaligned load is not a C++ correctness argument.

Here the offset makes the address misaligned on the usual platform where alignof(std::uint32_t) is four:

#include <cstddef>
#include <cstdint>
#include <cstring>

alignas(std::uint32_t)
std::byte payload[sizeof(std::uint32_t) + 1]{};

auto* word =
    reinterpret_cast<const std::uint32_t*>(payload + 1);
auto value = *word;  // Misaligned typed access: undefined behavior

Aligning payload aligns its start, not every offset within it. The read also requires a valid std::uint32_t object at the accessed address; alignment alone would not establish every condition needed for typed access. For bytes received from a file or socket, read into an existing value instead:

std::uint32_t value;
std::memcpy(&value, payload + 1, sizeof value);

The source range fits in payload, and value is a live, properly aligned std::uint32_t. This copies a representation into a value; it does not resolve wire-format questions such as byte order. Those are separate decoding requirements.


Strict aliasing: the type of access matters too

Now suppose an object is alive and its address is aligned. You still cannot necessarily read it through an arbitrary type. For example:

#include <cstdint>

float price = 1.0f;
auto* bits = reinterpret_cast<const std::uint32_t*>(&price);
std::uint32_t raw = *bits;  // Undefined behavior

The cast may produce a pointer, but the object at that address is a float, not a std::uint32_t. Accessing its value through this unrelated type violates C++’s type-access rules. Equal sizes, matching alignment, and plausible-looking output would not fix the violation.

Watch Doumler’s explanation of why this rule is useful to optimizers. In the same-type example, two pointers can designate one object; the compiler must account for that possibility. With unrelated types such as int and float, it can make different assumptions about valid access. An invalid cast followed by a load does not override those assumptions.

Type punning in modern C++ - Timur Doumler - CppCon 2019

Watch this for the connection between legal aliasing and the machine code an optimizer may generate.

In strict aliasing, follow the two-pointer example first when both pointers have the same type, then when the types differ. Concentrate on access through the pointer: producing a casted pointer is not permission to read an unrelated object through it.

The rule has permitted forms of access. For example, you may inspect an object’s representation through char, unsigned char, or std::byte; a const-qualified view of the object’s type is also ordinary valid access. Base-class and corresponding signed/unsigned types have relevant allowances. The practical lesson is not “pointers must never share an address.” It is identify the actual object and justify the type used to access its stored value.

For the float example, C++20 provides a value-based repair:

#include <bit>
#include <cstdint>

float price = 1.0f;
static_assert(sizeof(float) == sizeof(std::uint32_t));

std::uint32_t raw = std::bit_cast<std::uint32_t>(price);

std::bit_cast produces a new value with the copied bits. It does not pretend that a std::uint32_t object occupies price’s address. Its types must meet its size and trivial-copyability requirements, and in general the resulting bits must represent a valid value of the destination type.

For a view of when std::memcpy or std::bit_cast is appropriate, watch the final selected portion of Doumler’s talk.

Type punning in modern C++ - Timur Doumler - CppCon 2019

Watch this to connect the unsafe cast pattern with value-based alternatives.

In safe type punning, focus on copying bytes into a separate object with std::memcpy and on the C++20 std::bit_cast alternative. The point is to obtain a value, not an unrelated typed view into the original object.


A compact audit for low-level code

When reviewing a cast, a borrowed reference, or access into a buffer, check four independent conditions:

  1. Lifetime: Is there a live object to access, and will it remain live for this use?
  2. Storage and alignment: Is the addressed range large enough and correctly aligned for any typed object being accessed?
  3. Access type: Is this type permitted to access the actual object, rather than merely obtainable by reinterpret_cast?
  4. Representation: If bytes were copied into a value, do they form a valid value, and has the format been decoded correctly?

A single line can fail more than one check. Conversely, fixing alignment with alignas does not fix aliasing or begin a missing object’s lifetime. Tools can expose some symptoms—particularly use-after-free or misaligned access—but an undetected strict-aliasing violation is still a violation. These checks remain necessary even when a benchmark reports that the cast-based version “works.”


Conclusion

Undefined behavior in low-level C++ often starts with treating an address as more than it proves. A reference does not keep its target alive; raw, aligned storage does not by itself construct a nontrivial object; hardware support does not legalize a misaligned typed access; and reinterpret_cast does not authorize access through an unrelated type. Prefer owning values, explicit construction when managing storage, and std::memcpy or std::bit_cast when you need values from representations.

The next lesson applies the same attention to lifetime and ownership when an operation can throw: you’ll design resource-owning code to preserve a specified exception-safety guarantee.

Can't find a good explanation? Sign up and we'll make it for you

Sign up