Skip to content
C++
Language
since C++98
Intermediate

Unions and Anonymous Unions

C++ unions — memory layout, active member rules, type punning with bit_cast, anonymous unions, tagged unions, and std::variant.

unionsince C++98

A union is a special class type where all non-static data members share the same memory address, making the object exactly as large as its largest member; at any moment exactly one member is active and reading from any other member is undefined behaviour.

Overview

Unions predate C++ and remain essential in three narrow contexts: low-level bit manipulation, C API interoperability, and space-critical embedded layouts. Outside those contexts, std::variant (C++17) provides the same discriminated-union semantics with type safety and automatic lifetime management.

Understanding unions requires understanding two rules the standard enforces strictly:

  1. The active member rule. After you write to member m, m is active. Reading any other member (except through a common initial sequence — see below) is undefined behaviour.
  2. Lifetime of non-trivial members. If a union member has a non-trivial constructor or destructor, you are responsible for calling placement new and the explicit destructor. The compiler will not do it for you.

Syntax

cpp
union Name {
    T1 member1;
    T2 member2;
    // ...
};

Key rules from the standard:

  • All members begin at offset 0.
  • sizeof(Name) equals the size of the largest member (plus any padding needed for alignment).
  • A union may have member functions, including constructors and destructors, but no virtual functions and no base classes.
  • Prior to C++11, members were restricted to trivially copyable types. C++11 lifted that restriction — non-trivial members are allowed if the union's special members are user-defined.

Examples

Basic layout and active member

cpp
union Scalar {
    int32_t  i;
    float    f;
    double   d;
    uint8_t  bytes[8];
};

static_assert(sizeof(Scalar) == sizeof(double));  // 8 bytes
static_assert(alignof(Scalar) == alignof(double));

Scalar s;
s.i = 0x3F80'0000;
// s.f is now inactive — reading it here is UB
s.f = 1.0f;            // activates f, deactivates i
assert(s.f == 1.0f);   // OK
// assert(s.i == ...); // UB: i is inactive

Type punning — the only safe way (C++20)

Union-based type punning (pun.f = x; return pun.u;) is undefined behaviour in C++ despite being defined in C and widely supported as a GCC/Clang extension. The portable and standard-compliant replacement since C++20 is std::bit_cast:

cpp
#include <bit>      // C++20
#include <cstdint>

float f = 1.0f;
uint32_t bits = std::bit_cast<uint32_t>(f);  // C++20 — no UB
// bits == 0x3F800000

// Reconstruct:
float back = std::bit_cast<float>(bits);     // C++20
assert(back == f);

std::bit_cast requires both types to have the same size and be trivially copyable. It is constexpr and produces no runtime overhead on any mainstream compiler.

The standard permits reading a non-active member when both the active and inactive members share a common initial sequence of layout-compatible types:

cpp
struct Point2D { int x, y; };
struct Point3D { int x, y, z; };

union PointUnion {
    Point2D p2;
    Point3D p3;
};

PointUnion u;
u.p3 = {1, 2, 3};
// Legal: reading p2.x and p2.y because they form the common initial
// sequence of Point2D and Point3D (both start with int x, int y).
assert(u.p2.x == 1);  // OK per [class.union] ¶6
assert(u.p2.y == 2);  // OK
// u.p2.z would not exist, and u.p3.z is fine since p3 is active

This is the mechanism behind many C socket API structs (sockaddr / sockaddr_in / sockaddr_in6).

Anonymous union

An anonymous union has no tag name and no object name; its members are injected directly into the enclosing scope:

cpp
struct Packet {
    uint8_t type;

    union {          // anonymous — members accessed as direct fields
        uint16_t u16;
        uint32_t u32;
        float    f32;
        struct { uint8_t lo, hi; };  // nested anonymous struct (GCC/Clang extension)
    };
};

Packet p;
p.type = 2;
p.u32 = 0xDEAD'BEEF;
// p.u16 is now inactive — only p.u32 is valid to read

Anonymous unions at namespace scope must be declared static (or have internal linkage). They are commonly used in structs to save space without extra member access syntax.

Non-trivial members — manual lifetime (C++11)

C++11 removed the restriction on non-trivial union members but places the burden of construction and destruction entirely on you:

cpp
#include <string>
#include <vector>
#include <new>      // placement new

union Storage {
    std::string  s;
    std::vector<int> v;

    Storage() {}   // trivial no-op — caller must activate a member
    ~Storage() {}  // trivial no-op — caller must destroy active member
};

Storage st;
new (&st.s) std::string("hello");  // activate s
st.s += " world";
st.s.~basic_string();              // destroy before switching

new (&st.v) std::vector<int>{1, 2, 3};  // activate v
st.v.push_back(4);
st.v.~vector();                         // must destroy before Storage goes out of scope

Forgetting the explicit destructor call is a resource leak. Switching members without destroying the previous one corrupts memory. This is why std::variant exists.

Tagged union pattern

The classical discriminated union — manual, but useful in embedded or performance-critical contexts:

cpp
struct Value {
    enum class Kind : uint8_t { Null, Bool, Int, Double } kind = Kind::Null;

    union {
        bool   b;
        int    i;
        double d;
    };

    static Value from_int(int v)    { Value r; r.kind = Kind::Int;    r.i = v; return r; }
    static Value from_double(double v) { Value r; r.kind = Kind::Double; r.d = v; return r; }

    double to_double() const {
        switch (kind) {
            case Kind::Int:    return static_cast<double>(i);
            case Kind::Double: return d;
            case Kind::Bool:   return b ? 1.0 : 0.0;
            default:           return 0.0;
        }
    }
};

With trivially copyable members only, this pattern requires no placement new or explicit destructors and is constexpr-friendly since C++20.

std::variant — the type-safe discriminated union (C++17)

cpp
#include <variant>   // C++17

using Value = std::variant<std::monostate, bool, int, double, std::string>;

Value v = 3.14;

// Checked access — throws std::bad_variant_access if wrong type
double d = std::get<double>(v);

// Non-throwing pointer access
if (auto* p = std::get_if<double>(&v)) {
    // *p is 3.14
}

// Exhaustive dispatch — all alternatives must be handled
std::visit([](auto&& x) {
    using T = std::decay_t<decltype(x)>;
    if constexpr (std::is_same_v<T, std::monostate>) { /* null */ }
    else if constexpr (std::is_same_v<T, bool>)      { /* bool */ }
    else if constexpr (std::is_same_v<T, int>)       { /* int */ }
    else if constexpr (std::is_same_v<T, double>)    { /* double */ }
    else                                              { /* string */ }
}, v);

// Overloaded visitor (C++17 deduction guides)
auto printer = [](auto&& x) { std::println("{}", x); };  // std::println: C++23
std::visit(printer, v);

std::monostate is a unit type that enables default-constructing a variant whose first alternative is not default-constructible.

std::variant can enter a valueless_by_exception state if an exception is thrown during an in-place assignment. Check with .valueless_by_exception() before accessing. The standard does not provide a strong exception guarantee for operator= when the types differ.


Best Practices

  • Default to std::variant for any discriminated union involving non-trivial types. The manual approach is too error-prone and adds no performance in most cases.
  • Use std::bit_cast for type punning (C++20). If targeting C++17 or earlier, use memcpy into a local variable of the target type — compilers optimise it to zero instructions on known sizes.
  • Wrap raw unions in a class that encodes the active-member invariant and manages lifetimes, so callers can never see an inconsistent state.
  • Keep union members trivially copyable unless you are deliberately managing lifetimes. The moment you include a std::string or std::vector, the complexity of the manual approach roughly equals writing std::variant from scratch.
  • Annotate the active member with a comment or accompanying tag whenever the union is a struct field. Silent mistakes happen when maintainers assume the wrong member is active.

Common Pitfalls

Reading an inactive member. This is the most common union bug. It is UB even when the size and alignment of the members are identical. Compilers are permitted to, and do, exploit this for optimisation.

Forgetting to destroy the active member. If the active member has a non-trivial destructor and the enclosing union's destructor does nothing, you leak resources. The compiler issues no warning.

Copying a union with a non-trivial active member. A union's implicitly deleted copy/move constructors must be manually defined if any member is non-trivial. Bitwise copying a std::string union member results in a double-free.

Alignment traps. Union members impose their own alignment requirements. A union { char c; double d; } has alignof(double) alignment — placing it in a __attribute__((packed)) struct can break it.

Assuming std::variant is a union. variant stores an index and the active value, but it does not overlap storage the same way a raw union does. sizeof(std::variant<int, char>) is not sizeof(int) — it includes the index and satisfies alignment for all alternatives.


Type punning and std::bit_cast

Union-based type punning is undefined behaviour in standard C++. The only conforming, portable, and constexpr-capable alternative since C++20 is std::bit_cast. For C++17 targets, a memcpy through a local variable is the next-best option — every optimising compiler reduces it to zero instructions for fixed-size types.

cpp
#include <bit>      // C++20
#include <cstdint>
#include <cstring>  // memcpy fallback

// C++20 — constexpr, zero overhead
constexpr uint32_t float_bits(float f) noexcept {
    return std::bit_cast<uint32_t>(f);  // C++20
}

// C++17 fallback — not constexpr, but still no UB
uint32_t float_bits_17(float f) noexcept {
    uint32_t result;
    std::memcpy(&result, &f, sizeof result);  // C++17: defined behaviour
    return result;
}

static_assert(float_bits(1.0f) == 0x3F800000u);  // C++20 constexpr check

std::bit_cast is a compile-time error if the source and destination types differ in size or if either is not trivially copyable. It cannot be used on types with padding bytes whose value is unspecified.


Anonymous unions

Anonymous unions inject their members directly into the surrounding scope with no extra qualifier syntax. They are commonly used inside structs to overlay storage without requiring callers to navigate an extra level of member access.

cpp
// C++98
struct Register32 {
    union {           // anonymous — no name, no object declaration
        uint32_t full;
        struct {      // nested anonymous struct — GCC/Clang extension, not ISO C++
            uint16_t lo;
            uint16_t hi;
        };
    };
};

Register32 r;
r.full = 0xDEAD'BEEF;
// r.lo / r.hi only valid on little-endian systems with struct extension

Anonymous unions at namespace scope must have internal linkage (declared static or inside an anonymous namespace). They cannot have member functions, access specifiers, or non-public members. The nested anonymous struct pattern is a common GCC/Clang extension — avoid it in strictly portable code.

cpp
// Portable namespace-scope anonymous union (internal linkage required)
namespace {
    union {
        int   shared_int;
        float shared_float;
    };
}

void toggle_active() {
    shared_float = 1.5f;   // activates shared_float; shared_int now inactive
}

Tagged union pattern (manual variant)

The tagged union encodes the active member in a companion discriminant field. This pattern predates std::variant and remains the go-to in embedded or C-interop contexts where dynamic allocation is forbidden and RTTI is unavailable.

cpp
// C++11
struct JsonValue {
    enum class Tag : uint8_t { Null, Bool, Int, Float, String } tag;

    union Payload {
        bool     b;
        int64_t  i;
        double   f;
        char     s[24];   // small-string optimisation — fixed-length, no heap
    } data{};

    static JsonValue make_int(int64_t v) noexcept {
        JsonValue jv;
        jv.tag    = Tag::Int;
        jv.data.i = v;
        return jv;
    }

    int64_t as_int() const {
        if (tag != Tag::Int) throw std::bad_cast{};
        return data.i;
    }
};

All payload members above are trivially copyable, so no placement new or explicit destructor is required — the aggregate is safe to memcpy and is ABI-stable. Introducing a non-trivial member (e.g., std::string) forces you to manage lifetimes manually, at which point std::variant is the better choice.

cpp
// C++20: trivial tagged union is constexpr-friendly
constexpr JsonValue kZero = JsonValue::make_int(0);

Unrestricted unions (C++11)

C++11 removed the restriction that union members must be trivially copyable, enabling members such as std::string or std::vector at the cost of requiring user-defined special members.

cpp
#include <string>
#include <new>   // placement new  // C++11

union StringOrInt {
    std::string s;
    int         i;

    // User must supply non-trivial special members
    StringOrInt()  {}  // no-op; caller must activate a member
    ~StringOrInt() {}  // no-op; caller must destroy the active member
};

StringOrInt u;
// Activate the string member
::new (&u.s) std::string("hello");    // C++11 placement new
u.s.append(" world");
u.s.~basic_string();                  // explicit destructor before switching

// Activate the int member
u.i = 42;
// No destructor needed — int is trivially destructible

The copy constructor and copy assignment operator of StringOrInt are implicitly deleted because std::string's copy constructor is non-trivial and the union cannot know which member is active. You must write them by hand if you need them.

cpp
// Move constructor example — C++11
StringOrInt(StringOrInt&& o, bool is_string) {
    if (is_string) ::new (&s) std::string(std::move(o.s));
    else           i = o.i;
}

Union in constexpr context

Since C++20, unions can participate in constant evaluation provided only trivially constructible/destructible members are activated during the constant expression. Reading an inactive member during constant evaluation is a hard compile-time error rather than silent UB.

cpp
// C++20
#include <cstdint>

union Bits {
    float    f;
    uint32_t u;
};

constexpr uint32_t as_bits(float val) {
    Bits b{};
    b.f = val;        // activate f
    // b.u would be a compile-time error here — inactive member read
    return std::bit_cast<uint32_t>(val);  // C++20: use bit_cast instead
}

static_assert(as_bits(1.0f) == 0x3F800000u);

When working with raw unions in constexpr functions, the compiler tracks which member was last written and rejects reads from any other member at compile time. This means many union-based tricks that "work" at runtime (because the UB is undetected) become hard errors during constant evaluation — which is precisely the diagnostic value of using constexpr.

cpp
// C++20: constexpr union with trivial members only
union Vec2Bits {
    struct { float x, y; };   // extension; alternatively use array
    uint64_t raw;
};

constexpr Vec2Bits pack(float x, float y) {
    Vec2Bits v{};
    v.x = x;   // not legal to read v.raw after this in constexpr — UB tracked
    v.y = y;
    return v;
}

std::variant as the safe alternative

std::variant<Ts...> is the standard discriminated union — it tracks the active type automatically, enforces correct access, and manages non-trivial lifetimes without any manual placement new or destructor calls.

cpp
#include <variant>   // C++17
#include <string>

using Payload = std::variant<int, double, std::string>;

Payload p = 42;                          // active: int
p = 3.14;                                // active: double — int lifetime ended
p = std::string("hello");               // active: string — double lifetime ended

// Type-checked retrieval
double d = std::get<double>(p);          // throws std::bad_variant_access if wrong

// Non-throwing pointer access
if (auto* s = std::get_if<std::string>(&p)) {
    s->append(" world");
}

// Exhaustive visitor — compiler error if a type is unhandled  // C++17
std::visit([](auto& val) {
    using T = std::decay_t<decltype(val)>;
    if constexpr (std::is_same_v<T, int>)         { /* ... */ }
    else if constexpr (std::is_same_v<T, double>) { /* ... */ }
    else                                           { /* string */ }
}, p);

std::monostate adds a valueless state and makes a variant default-constructible when no other alternative is:

cpp
using OptValue = std::variant<std::monostate, int, std::string>;  // C++17
OptValue empty;                          // holds monostate — no int or string
bool is_empty = std::holds_alternative<std::monostate>(empty);

variant can enter a valueless_by_exception state if an assignment throws after the old value was destroyed. In practice this is avoided by using std::visit for in-place mutation or by assigning from a temporary.


Union alignment and size

A union's size is the size of its largest member rounded up to its strictest alignment requirement. Members with larger alignment pad the union even if they are not the largest by byte count.

cpp
// C++11
#include <cstdint>
#include <cassert>

union AlignDemo {
    char    c;       // 1 byte, align 1
    int32_t i;       // 4 bytes, align 4
    double  d;       // 8 bytes, align 8
};

static_assert(sizeof(AlignDemo)  == 8);   // largest: double
static_assert(alignof(AlignDemo) == 8);   // strictest: double

union OverAligned {
    char c;
    alignas(16) int arr[2];  // forced 16-byte alignment  // C++11
};

static_assert(sizeof(OverAligned)  == 16);
static_assert(alignof(OverAligned) == 16);

Adding a member solely to control size is a valid technique in embedded contexts:

cpp
// Force a union to be exactly 8 bytes, padded to double alignment
union NetworkWord {
    uint16_t flags;
    uint32_t value;
    uint8_t  raw[8];   // padding member — makes sizeof == 8
};
static_assert(sizeof(NetworkWord) == 8);

ABI and union layout

Union layout is deterministic within a single ABI, but switching compilers, platforms, or packing attributes can silently change member offsets and size. All members always start at offset 0, and no reordering is possible — unlike struct.

cpp
// C++11
// Unions in extern "C" interfaces are stable across C and C++ TUs
// provided both sides use the same ABI (typically Itanium on Linux/macOS,
// MSVC on Windows).

extern "C" {
    union CApiData {
        uint32_t raw;
        struct { uint8_t a, b, c, d; } bytes;
    };
}

// __attribute__((packed)) breaks alignment guarantees — use with caution
// union Packed { char c; double d; } __attribute__((packed));
// sizeof == 9, but accessing d through an unaligned pointer is UB on
// architectures that require natural alignment (e.g. ARMv6 without UNALIGN_TRP).

When unions are embedded in #pragma pack or __attribute__((packed)) structs, members lose their alignment guarantee and hardware-level misaligned-access faults become possible on strict-alignment architectures. Prefer explicit padding members and static_assert checks over compiler packing attributes whenever binary layout must be controlled precisely.

cpp
// Safe layout control with explicit padding  // C++11
union ControlWord {
    uint32_t raw;
    struct {
        uint32_t opcode  : 4;
        uint32_t flags   : 4;
        uint32_t payload : 24;
    } fields;
};
static_assert(sizeof(ControlWord) == 4);
static_assert(alignof(ControlWord) == 4);

See Also

  • std::variant — type-safe discriminated union, C++17
  • std::bit_cast — safe type punning, C++20
  • std::optional — nullable value without a union tag, C++17
  • std::any — type-erased value storage, C++17
  • std::is_union<T> — type trait, C++11
  • Anonymous structs inside unions (GCC/Clang extension, not standard C++)