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:
- 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. - 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.