Type Punning
Reinterpreting the raw bit pattern of one type as another—from UB-laden union tricks to the safe std::bit_cast in C++20.
Type Punningsince C++20 (safe form)Type punning is the act of reinterpreting the underlying bit representation of a value of one type as if it were a value of a different, unrelated type.
Overview
Type punning has existed since the earliest days of systems programming. The motivation is straightforward: sometimes you need to inspect or manipulate the raw bytes of a value—reading the IEEE 754 bit fields of a float, serializing integers without format conversion, or probing memory layout. The problem is that C++ makes most natural ways of doing this undefined behavior, and compilers exploit that UB aggressively.
The C++ object model and strict aliasing rules exist to enable optimizations. Violating them doesn't merely risk "wrong" results—it invites the optimizer to silently delete or reorder code in ways that bear no relation to what you wrote.
Three mechanisms have been used historically:
- Union-based punning — write through one member, read through another
- Pointer-cast punning — cast a
T*toU*and dereference memcpy-based punning — copy bytes explicitly (safe since C++98)std::bit_cast— first-class language support (C++20)
Only the last two are safe in standard C++.
The Illegal Approaches
Union Punning (UB in C++, not in C)
// UNDEFINED BEHAVIOR in C++ — do not use
union FloatBits {
float f;
uint32_t i;
};
uint32_t read_bits(float x) {
FloatBits fb;
fb.f = x;
return fb.i; // reads from inactive union member — UB
}This is explicitly permitted in C99 and later (C permits type-punning via union). It is explicitly not permitted in C++. The C++ standard defines the active member of a union as the last one written; reading any other member is UB, and compilers act on that. GCC documents it as an extension, but relying on extensions makes your code non-portable and fragile under LTO.
Pointer Cast Punning (Strict Aliasing Violation)
// UNDEFINED BEHAVIOR — violates strict aliasing
float f = 1.0f;
uint32_t bits = *reinterpret_cast<uint32_t*>(&f);The strict aliasing rule ([basic.lval]) states that an object of type T may only be accessed through a glvalue of a compatible type. uint32_t and float are not compatible. The compiler is entitled to assume these pointers never alias the same storage and may reorder or eliminate the load entirely. With -O2 or higher this can and does produce wrong results in practice.
The one escape hatch: char, unsigned char, and (since C++17) std::byte may alias any object type. You can always read object representation through these.
The Safe Approaches
memcpy (Safe Since C++98)
The standard explicitly permits memcpy to move between representations of trivially copyable types. Any conforming implementation must make this work:
#include <cstring>
#include <cstdint>
float bit_increment(float f) {
uint32_t i;
std::memcpy(&i, &f, sizeof i); // defined behavior
++i;
std::memcpy(&f, &i, sizeof f); // defined behavior
return f;
}The apparent cost of two memcpy calls is illusory. Every major compiler recognizes this pattern and compiles it to a single register move. Inspect the output on Godbolt—with -O1 you get one instruction.
The constraints:
- Both types must be trivially copyable
- Both types must be the same size
- You are responsible for ensuring the resulting bit pattern is a valid value of the destination type (a concern with floating-point trap representations)
std::bit_cast (C++20)
C++20 introduced std::bit_cast<T>(src) in <bit>. It provides the same semantics as the memcpy pattern but as a named, auditable, type-safe operation—and it is constexpr:
#include <bit> // C++20
#include <cstdint>
#include <iostream>
#include <format> // C++20
int main() {
constexpr double pi = 3.14159265358979323846;
// Inspect the IEEE 754 bit pattern of a double at compile time
constexpr uint64_t bits = std::bit_cast<uint64_t>(pi); // C++20
// sign=0, exponent=1024 (biased), mantissa encodes fractional part
std::cout << std::format("pi bits: {:#018x}\n", bits); // C++20
// Round-trip back
constexpr double pi2 = std::bit_cast<double>(bits); // C++20
static_assert(pi == pi2);
}std::bit_cast requires:
sizeof(T) == sizeof(From)(checked at compile time)- Both
TandFromare trivially copyable Tis trivially constructible (it constructs, not assigns)
The constexpr property is the real win over memcpy. You can use std::bit_cast in if constexpr, template metaprogramming, and compile-time unit tests.
Examples
IEEE 754 Bit Manipulation
A practical use case is classifying or manipulating floating-point values at the bit level, which is faster than FPU comparisons when you need bulk classification:
#include <bit> // C++20
#include <cstdint>
// Returns true if f is negative without using comparisons
bool is_negative_fast(float f) {
return (std::bit_cast<uint32_t>(f) >> 31) != 0; // C++20
}
// Extract raw biased exponent from IEEE 754 single
int raw_exponent(float f) {
const uint32_t bits = std::bit_cast<uint32_t>(f); // C++20
return static_cast<int>((bits >> 23) & 0xFF);
}
// ULP distance: how many representable floats apart are a and b?
// Assumes same sign and neither is NaN/Inf.
uint32_t ulp_distance(float a, float b) {
const uint32_t ia = std::bit_cast<uint32_t>(a); // C++20
const uint32_t ib = std::bit_cast<uint32_t>(b); // C++20
return (ia > ib) ? (ia - ib) : (ib - ia);
}Endian-Aware Serialization (C++20)
#include <bit> // C++20
#include <array>
#include <cstdint>
// Serialize a uint32_t as big-endian bytes, defined behavior
std::array<uint8_t, 4> to_big_endian(uint32_t v) {
if constexpr (std::endian::native == std::endian::little) { // C++20
v = std::byteswap(v); // C++23
}
return std::bit_cast<std::array<uint8_t, 4>>(v); // C++20
}Pre-C++20 Compatibility Shim
#include <cstring>
#include <type_traits>
template <typename To, typename From>
To bit_cast_compat(const From& src) noexcept { // noexcept: C++11
static_assert(sizeof(To) == sizeof(From));
static_assert(std::is_trivially_copyable_v<From>); // C++17
static_assert(std::is_trivially_copyable_v<To>); // C++17
To dst;
std::memcpy(&dst, &src, sizeof dst);
return dst;
}This shim lacks constexpr (the memcpy path cannot be constant-evaluated pre-C++20) and requires C++17 for _v aliases, but is otherwise correct.
Best Practices
- Prefer
std::bit_cast(C++20) when both types are the same size and trivially copyable. It names the intent, isconstexpr, and the compiler guarantees correct codegen. - Use
memcpyfor pre-C++20 codebases. The pattern is well-established and universally optimized. Wrap it in a named template to make intent explicit. - Validate the resulting value. Not all bit patterns are valid values. After punning into
float/double, check for NaN or trap representations if your platform has them. - Avoid union punning entirely in C++ code, even with GCC's documented extension. It is invisible to static analyzers and UBSan will flag it.
- Enable UBSan in CI (
-fsanitize=undefined). Strict aliasing violations are caught at runtime by-fsanitize=strict-aliasing.
Common Pitfalls
Assuming reinterpret_cast is equivalent to memcpy. It is not. reinterpret_cast<U*>(&t) followed by a dereference violates strict aliasing unless U is a character type or byte type. The optimizer will exploit this and produce wrong results.
Type sizing mismatch. If sizeof(T) != sizeof(From), std::bit_cast refuses to compile. With memcpy, you can silently truncate or read past the object. Always assert sizes match.
Punning into non-trivially-copyable types. Types with non-trivial constructors (virtual dispatch tables, mutex members) cannot be reconstituted by copying bytes—the result is not a valid object. std::bit_cast enforces this statically; raw memcpy does not.
Assuming a bit pattern is a valid value. The bit pattern 0x7FC00000 is a float NaN. Arithmetic on NaN propagates NaN. The pattern 0x00000000 is zero for uint32_t but is also +0.0f—fine here. Be explicit about what bit patterns you expect and what they mean in the destination type.
Using std::function and assuming zero overhead. std::function may internally perform type punning or dynamic allocation to type-erase arbitrary callables. It is not zero-cost. Prefer auto and template parameters for call sites where performance matters.
Union Punning in Depth: Why "Works" is Not "Defined"
The union technique is old enough to appear in every C codebase and most C++ tutorials written before 2011. Understanding exactly why it is UB—and why compilers nonetheless emit the "expected" code—matters for reading existing code and arguing against its use in code review.
What C and C++ actually say
In C99 and C11, type-punning through a union is explicitly permitted. Footnote 95 of C11 reads:
If the member used to read the contents of a union object is not the same as the member last used to store a value in the object, the appropriate part of the object representation of the value is reinterpreted as an object representation in the new type.
C++ contains no equivalent permission. The C++ standard ([class.union.general]) states that at most one member of a union can be the active member at a time—the one whose lifetime has started. Writing to fb.f starts the lifetime of f and ends any prior active member. Reading from fb.i then accesses an object outside its lifetime: undefined behavior per [basic.life].
Why the UB is silent in practice
GCC documents union type-punning in C++ as a compiler extension (see GCC manual, -fstrict-aliasing):
In ISO C++, type-punning through a union type is undefined behavior, but GCC supports it as an extension.
This is why the code runs correctly in isolation under GCC and Clang with default settings. The danger emerges in two scenarios:
- LTO (link-time optimization): The cross-translation-unit optimizer sees the full call graph and may eliminate or reorder the inactive-member read.
- Clang under
-fstrict-aliasingwith aggressive inlining: The optimizer may assume the inactive member is never read and delete the read.
The extension guarantee is per-compilation-unit and without LTO. Neither condition is reliable in production builds.
The GCC proof: strict aliasing in action
Consider this function, which takes a pointer to float and a pointer to uint32_t. With strict aliasing enabled the compiler assumes they cannot point to the same storage:
// Strict aliasing violation — compile with: g++ -O2 -fstrict-aliasing
void increment_float_bits(float* fp, uint32_t* ip) {
*ip = *reinterpret_cast<uint32_t*>(fp);
++(*ip);
// Optimizer may assume fp and ip don't alias, eliminating the store to *ip
}Compile with -O2 -Wstrict-aliasing=2 and GCC will warn:
warning: dereferencing type-punned pointer will break strict-aliasing rulesWith -O2 -fstrict-aliasing (default at -O2), the optimizer is entitled to assume fp and ip never alias and to reorder or eliminate memory operations accordingly. The resulting binary may silently produce wrong values.
The -fno-strict-aliasing Flag
What it does
-fno-strict-aliasing instructs GCC and Clang to disable the aliasing-based optimizations enabled by -fstrict-aliasing. The compiler will no longer assume that pointers of different types point to distinct storage. Every pointer access is treated as a potential alias of every other pointer access of any type.
This reverts the compiler to the conservative model that predates C89's aliasing rules. Union punning, pointer-cast punning, and direct reinterpret_cast dereferencing all behave as the programmer expects.
When -fstrict-aliasing is active
-fstrict-aliasing is enabled automatically at -O2, -O3, and -Os in both GCC and Clang. It is not enabled at -O0 or -O1. This means:
- Code with aliasing UB may work at
-O0during development - The same code may silently misbehave at
-O2in release builds - The failure is non-deterministic: it depends on inlining decisions, loop unrolling, and register allocation that vary between compiler versions
Performance cost of -fno-strict-aliasing
Disabling strict aliasing prevents a class of load/store optimizations that are particularly valuable in loops:
- The compiler must re-load values from memory before each use, since any pointer write may have modified them
- Loop vectorization is inhibited where the optimizer cannot prove that output pointers don't alias input pointers
- Redundant load elimination (a component of GVN) is suppressed
Real-world cost depends heavily on code structure. Pointer-heavy numerical code (DSP, linear algebra, image processing) can regress 5–30% at -fno-strict-aliasing. Code that passes values by value or uses local variables is largely unaffected.
When to use -fno-strict-aliasing
This flag is a legitimate escape hatch for legacy codebases where aliasing violations are too numerous to fix incrementally. The correct workflow is:
- Build with
-fno-strict-aliasingto unblock shipping - Enable
-Wstrict-aliasing=2to get warnings without enforcing them - Fix violations file by file, removing
-fno-strict-aliasingfrom each translation unit as it becomes clean
Using -fno-strict-aliasing for new code is never correct. It documents that the code violates the standard, not that the code is portable.
# CMake: apply -fno-strict-aliasing only to legacy files
target_compile_options(legacy_module PRIVATE -fno-strict-aliasing)
# New code in the same project is compiled normally (strict aliasing on)
target_compile_options(new_module PRIVATE -Wstrict-aliasing=2)std::start_lifetime_as (C++23)
C++23 introduced std::start_lifetime_as<T>(ptr) in <memory> (P2590R2, building on the implicit object creation rules of P0593R6 from C++20). It solves a problem that neither memcpy nor std::bit_cast can: viewing an existing byte buffer as a typed object without copying the bytes.
The problem it solves
The following deserialization pattern looks obviously correct but is UB in all C++ standards prior to C++23:
// Reading a packet from a socket
alignas(MessageHeader) unsigned char buf[sizeof(MessageHeader)];
read(fd, buf, sizeof(buf));
// UNDEFINED BEHAVIOR before C++23: no MessageHeader object exists at buf
const MessageHeader* hdr = reinterpret_cast<const MessageHeader*>(buf);
if (hdr->type == MSG_PING) { /* ... */ }The problem is not alignment, not the cast syntax, and not the size—it is that no MessageHeader object has been created at buf. The storage exists; the object does not. Dereferencing hdr accesses an object outside its lifetime.
Before C++23, the only fully conforming fix was memcpy:
MessageHeader hdr;
std::memcpy(&hdr, buf, sizeof(hdr)); // copies bytes; creates object; defined
if (hdr.type == MSG_PING) { /* ... */ }This is correct but introduces a copy. For small headers the compiler will optimize it away; for large structs in tight loops it may not.
How std::start_lifetime_as fixes it
#include <memory> // C++23
alignas(MessageHeader) unsigned char buf[sizeof(MessageHeader)];
read(fd, buf, sizeof(buf));
// C++23: explicitly starts the lifetime of a MessageHeader at buf.
// No bytes are copied. No constructor is called.
// The object's value is determined from the existing bytes.
const MessageHeader* hdr = std::start_lifetime_as<MessageHeader>(buf);
if (hdr->type == MSG_PING) { /* ... */ }std::start_lifetime_as<T>(ptr) does exactly one thing: it tells the C++ abstract machine that a T object now exists at ptr and that its value is determined by the bytes already present there (for trivially copyable members). No storage is allocated, no constructor is called, no bytes are moved.
Requirements
Tmust be an implicit-lifetime type: an aggregate, a scalar, an array, or a type with a trivial eligible constructor and destructor. Types with user-provided constructors do not qualify.- The storage at
[ptr, ptr + sizeof(T))must be already allocated and must be suitably aligned forT. - Misaligned storage produces undefined behavior (just as misaligned
reinterpret_castdoes).
Contrast with std::launder
std::launder(ptr) is a different tool that solves a different problem. It instructs the optimizer to treat ptr as a fresh pointer—bypassing pointer provenance and const-member caching—but it assumes the lifetime has already started. If no object of the target type exists at the pointed-to address, std::launder does not help. From the cppreference example (see CWG2721):
// This is still UB even with std::launder — lifetime has not started
auto* p = std::launder(reinterpret_cast<std::complex<float>*>(network_data));
// network_data holds bytes but no complex<float> lifetime has begunstd::start_lifetime_as is what actually starts that lifetime:
// Correct C++23
auto val = *std::start_lifetime_as<std::complex<float>>(network_data);| Function | Starts lifetime? | Copies bytes? | constexpr? | Primary use |
|---|---|---|---|---|
memcpy + local | Yes (via init) | Yes | No | Pre-C++20 safe punning |
std::bit_cast | Yes (new object) | Yes | Yes | Value conversion, compile-time |
std::launder | No | No | Yes | Pointer provenance / const members |
std::start_lifetime_as | Yes | No | No | Zero-copy buffer reinterpretation |
Safe Alternatives: Decision Table
| Scenario | Recommended tool | Notes |
|---|---|---|
Convert float bits to uint32_t (same size) | std::bit_cast<uint32_t>(f) (C++20) | constexpr; static size check |
| Same conversion, C++17 or earlier | memcpy into a local | Compiler will optimize to register move |
| Read a struct from a network/disk byte buffer (zero-copy) | std::start_lifetime_as<T>(buf) (C++23) | Buffer must be aligned; no copy |
| Read a struct from a buffer, C++20 or earlier | memcpy into a local T | Always safe; may be optimized away |
| Inspect object bytes (read-only) | Cast to const std::byte* | Char/byte types exempt from strict aliasing |
| Union punning (any standard C++) | Do not use | Formally UB; use bit_cast or memcpy |
reinterpret_cast dereference | Do not use | Strict aliasing violation |
std::bit_cast Deep Dive: Constraints and Compile-Time Power
std::bit_cast is the only safe, constexpr-capable type-punning primitive in standard C++20. Unlike the memcpy pattern, it participates fully in constant evaluation, making it usable in static_assert, constexpr variables, and template metaprogramming contexts where runtime functions are forbidden.
The constraint that both types must be trivially copyable is non-negotiable. Types with user-provided copy constructors, virtual functions, or non-trivial destructors contain invariants that raw byte copying cannot preserve. Attempting to bit_cast such types is a compile-time error, not a runtime surprise.
// C++20
#include <bit>
#include <cstdint>
#include <array>
// Works: float and uint32_t are both trivially copyable, same size
constexpr uint32_t float_bits(float f) {
return std::bit_cast<uint32_t>(f);
}
// Works: punning between a uint64_t and an array of 8 bytes
constexpr std::array<uint8_t, 8> split_u64(uint64_t v) {
return std::bit_cast<std::array<uint8_t, 8>>(v);
}
// Compile error: std::string is not trivially copyable
// constexpr auto bad = std::bit_cast<uint64_t>(std::string{"hi"});
static_assert(float_bits(0.0f) == 0x00000000u); // +0.0 is all zeros
static_assert(float_bits(1.0f) == 0x3F800000u); // IEEE 754 encoding of 1.0When porting a pre-C++20 codebase, the migration path is mechanical: replace every memcpy-based punning helper with std::bit_cast and delete the helper. The compiler validates the size and trivial-copyability constraints that your static_asserts were approximating.
Pointer Provenance and Strict Aliasing in Practice
Strict aliasing is not a theoretical concern — real production binaries have silently miscompiled because of it. The canonical example is the Linux kernel, which historically compiled with -fno-strict-aliasing because the networking stack performed extensive pointer-cast punning across sk_buff members. Linus Torvalds has written publicly that strict aliasing is "actively harmful for kernel code." The kernel's solution was a blanket -fno-strict-aliasing flag rather than fixing thousands of individual violations.
The mechanism by which the compiler exploits strict aliasing is alias analysis during the optimization pipeline. When the compiler sees two pointer dereferences of unrelated types in the same basic block, it is permitted to reorder them, cache one in a register, or eliminate a reload entirely—because the standard guarantees they cannot refer to the same storage.
// C++17
// Demonstrates optimizer freedom under strict aliasing
// Compile: g++ -O2 -fstrict-aliasing to see the effect
float evil_increment(float* fp) {
uint32_t* ip = reinterpret_cast<uint32_t*>(fp); // strict aliasing violation
*ip += 1; // modify bit pattern as integer
return *fp; // optimizer may cache the original *fp and return the stale value
// because it assumes ip and fp cannot alias
}
// The correct approach: use memcpy (C++11) or std::bit_cast (C++20)
float safe_increment(float f) {
uint32_t bits;
std::memcpy(&bits, &f, sizeof bits); // C++11 and later: always safe
++bits;
std::memcpy(&f, &bits, sizeof f);
return f;
}Enabling -Wstrict-aliasing=2 (GCC) or -Wundefined-reinterpret-cast (Clang) surfaces violations without breaking the build. The -fno-strict-aliasing flag is a last resort: it prevents the optimization but does not make the code conforming — the code is still undefined behavior, just behavior that happens to be exploited less aggressively.
Type Punning in Serialization
Network protocols and binary file formats require converting values to and from a specific byte order, and std::memcpy is the correct, portable mechanism for all of it. The POSIX ntohl/htonl family converts between network byte order (big-endian) and host byte order, but it only covers 16- and 32-bit integers. For 64-bit values, floating-point data, or custom layouts you need to handle the bytes yourself.
The pattern is always the same: use std::memcpy to move between a typed variable and an unsigned char (or std::byte) buffer, apply any byte-order swaps as integer arithmetic on the raw bits, then write the result. This is defined behavior at every C++ version.
// C++17
#include <cstring>
#include <cstdint>
#include <array>
// Deserialize a big-endian uint32_t from a network buffer
uint32_t read_be32(const unsigned char* buf) {
uint32_t val;
std::memcpy(&val, buf, sizeof val); // defined: unsigned char can alias anything
// Byte-swap if host is little-endian (portable arithmetic, no UB)
return ((val & 0xFF000000u) >> 24) |
((val & 0x00FF0000u) >> 8) |
((val & 0x0000FF00u) << 8) |
((val & 0x000000FFu) << 24);
}
// Serialize a double into 8 big-endian bytes for a binary protocol
std::array<unsigned char, 8> serialize_f64_be(double d) {
uint64_t bits;
std::memcpy(&bits, &d, sizeof bits); // defined: reinterpret as uint64_t
std::array<unsigned char, 8> out{};
for (int i = 7; i >= 0; --i) {
out[static_cast<std::size_t>(i)] = static_cast<unsigned char>(bits & 0xFFu);
bits >>= 8;
}
return out;
}In C++20 the same serialization logic becomes cleaner. std::bit_cast replaces the memcpy-into-integer step and std::byteswap (C++23) or a manual swap handles endianness. The resulting code is constexpr-capable and expresses the intent without raw byte arithmetic in the middle of business logic.
// C++20
#include <bit>
#include <cstdint>
#include <array>
// Round-trip a float through a 4-byte big-endian wire format
std::array<uint8_t, 4> to_wire(float f) {
uint32_t bits = std::bit_cast<uint32_t>(f); // C++20: safe, constexpr-capable
if constexpr (std::endian::native == std::endian::little) { // C++20
bits = ((bits & 0xFF000000u) >> 24) | ((bits & 0x00FF0000u) >> 8) |
((bits & 0x0000FF00u) << 8) | ((bits & 0x000000FFu) << 24);
}
return std::bit_cast<std::array<uint8_t, 4>>(bits); // C++20
}
float from_wire(std::array<uint8_t, 4> bytes) {
uint32_t bits = std::bit_cast<uint32_t>(bytes); // C++20
if constexpr (std::endian::native == std::endian::little) { // C++20
bits = ((bits & 0xFF000000u) >> 24) | ((bits & 0x00FF0000u) >> 8) |
((bits & 0x0000FF00u) << 8) | ((bits & 0x000000FFu) << 24);
}
return std::bit_cast<float>(bits); // C++20
}See Also
std::bit_cast—<bit>, C++20std::start_lifetime_as—<memory>, C++23 (P2590R2 / P0593R6)std::launder—<new>, C++17; pointer provenance, not lifetime startstd::endian—<bit>, C++20; detects platform byte order at compile timestd::byteswap—<bit>, C++23; byte-reversal without UB-fsanitize=undefinedand-fsanitize=strict-aliasing— GCC/Clang flags for catching aliasing violations[basic.lval]— the strict aliasing rule in the C++ standard[class.union.general]— union active member rules in the C++ standard- P0593R6 — "Implicit creation of objects for low-level object manipulation" (C++20 foundation for
start_lifetime_as)