C++ Type Punning: Why memcpy Is Valid and Unions Cause UB

In C++, developers frequently need to reinterpret the bitwise representation of one data type as another, a practice known as type punning. While developers often reach for C-style unions to accomplish this, doing so in C++ violates object lifetime and strict aliasing rules, resulting in undefined behavior. Conversely, using std::memcpy operates entirely within the boundaries of the C++ standard by manipulating the underlying byte representation of trivially copyable types, allowing modern compilers to optimize the operation down to zero-overhead register moves.

The C vs. C++ Union Divergence

In standard C (specifically since C99 TC2), using a union for type punning—writing to member a and subsequently reading from member b—is explicitly permitted. However, C++ has a fundamentally different model for object orientation and object lifetimes.

In C++, a union can have only one "active" member at any given time. Under the C++ standard ([class.union]), modifying one union member ends the lifetime of the previously active member and begins the lifetime of the new member. Reading from an inactive union member accesses an object whose lifetime has not begun, which leads to undefined behavior. While many C++ compilers (like GCC and Clang) provide compiler-specific extensions to support union-based type punning, the practice is fundamentally non-portable and rejected by standard-conforming analysis tools.

The Strict Aliasing Rule and Object Lifetimes

The core issue that invalidates union punning in C++ stems from two intertwined language rules:

  1. Object Lifetimes ([basic.life]): An object's properties only exist while its lifetime is active. Accessing storage outside of an active object's lifetime through an expression that does not correspond to that object yields undefined behavior.
  2. Strict Aliasing ([basic.lval]): The compiler assumes that pointers or references of different, unrelated types cannot point to the exact same memory location. This assumption enables significant instruction reordering and register caching optimizations. Breaking this assumption confuses the compiler's optimizer and generates subtle bugs.

When reading from an inactive union member, you are aliasing distinct types without creating an object of the target type, breaching both rules.

Why std::memcpy is Standard-Compliant

Copying memory using std::memcpy is standard-compliant because of how the C++ standard treats trivially copyable types and character pointers.

According to the C++ standard ([basic.types.general]), any object of a trivially copyable type can have its underlying byte representation copied into an array of char, unsigned char, or std::byte, and vice versa. When you call std::memcpy:

  • Character Type Aliasing: The function reads and writes memory through void*, effectively operating at the byte level (unsigned char*), which is an explicitly permitted exception to the strict aliasing rule.
  • Value Representation Transfer: Copying raw bytes into an already existing, valid object of a trivially copyable type simply overwrites its value representation without illegally bypassing constructor logic or lifetime rules.

Because the destination object is already alive and trivially copyable, loading the copied bytes constitutes a valid read of an initialized object.

#include <cstring>
#include <cstdint>

float bit_pun(uint32_t input) {
    float result;
    // Standard-compliant: operates on raw bytes of trivially copyable types
    std::memcpy(&result, &input, sizeof(float));
    return result;
}

Compiler Optimization and Performance

A common misconception is that std::memcpy is inefficient because it implies a function call and a byte-by-byte memory loop. Modern optimizing compilers recognize std::memcpy as an intrinsic. When called on small, fixed-size types like floating-point and integer primitives, the compiler completely eliminates the function call and generates identical machine instructions to a direct register move (such as movd or movq on x86 architectures).

Modern Alternative: std::bit_cast

Starting in C++20, the language standardizes this behavior through std::bit_cast (defined in <bit>). Internally, std::bit_cast operates with the same semantics as std::memcpy, but it adds compile-time verification that both source and destination types are of the same size and are trivially copyable, while also allowing the operation to be evaluated in constexpr contexts.