Skip to content
C++
Language
Advanced

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:

  1. Union-based punning — write through one member, read through another
  2. Pointer-cast punning — cast a T* to U* and dereference
  3. memcpy-based punning — copy bytes explicitly (safe since C++98)
  4. 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)

cpp
// 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)

cpp
// 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:

cpp
#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:

cpp
#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 T and From are trivially copyable
  • T is 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:

cpp
#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)

cpp
#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

cpp
#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, is constexpr, and the compiler guarantees correct codegen.
  • Use memcpy for 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:

  1. LTO (link-time optimization): The cross-translation-unit optimizer sees the full call graph and may eliminate or reorder the inactive-member read.
  2. Clang under -fstrict-aliasing with 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:

cpp
// 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:

cpp
warning: dereferencing type-punned pointer will break strict-aliasing rules

With -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 -O0 during development
  • The same code may silently misbehave at -O2 in 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:

  1. Build with -fno-strict-aliasing to unblock shipping
  2. Enable -Wstrict-aliasing=2 to get warnings without enforcing them
  3. Fix violations file by file, removing -fno-strict-aliasing from 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
# 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:

cpp
// 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:

cpp
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

cpp
#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

  • T must 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 for T.
  • Misaligned storage produces undefined behavior (just as misaligned reinterpret_cast does).

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

cpp
// 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 begun

std::start_lifetime_as is what actually starts that lifetime:

cpp
// Correct C++23
auto val = *std::start_lifetime_as<std::complex<float>>(network_data);
FunctionStarts lifetime?Copies bytes?constexpr?Primary use
memcpy + localYes (via init)YesNoPre-C++20 safe punning
std::bit_castYes (new object)YesYesValue conversion, compile-time
std::launderNoNoYesPointer provenance / const members
std::start_lifetime_asYesNoNoZero-copy buffer reinterpretation

Safe Alternatives: Decision Table

ScenarioRecommended toolNotes
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 earliermemcpy into a localCompiler 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 earliermemcpy into a local TAlways 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 useFormally UB; use bit_cast or memcpy
reinterpret_cast dereferenceDo not useStrict 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.

cpp
// 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.0

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

cpp
// 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.

cpp
// 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.

cpp
// 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++20
  • std::start_lifetime_as — <memory>, C++23 (P2590R2 / P0593R6)
  • std::launder — <new>, C++17; pointer provenance, not lifetime start
  • std::endian — <bit>, C++20; detects platform byte order at compile time
  • std::byteswap — <bit>, C++23; byte-reversal without UB
  • -fsanitize=undefined and -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)