Ch25.1: Union: One Storage, Many Types

中文(简体)

1. The Feature That Fell Through

A union is a class type whose non-static data members all share one storage region. Only one member is alive at a time. It is the language's built-in way to say "this object is one of several types, and I do not want to pay for all of them".

union value
{
    int i;
    double d;
};

value is at least as large as its largest member, and aligned for its most strictly aligned member. On a typical 64-bit platform, sizeof(value) == 8 and alignof(value) == 8. The compiler may add padding; the standard only guarantees that every member fits.

The important rule is the active member. After you assign to v.i, the active member is i. Reading v.d is undefined behavior: the bytes may be there, but there is no double object whose lifetime has started. A union is not a license to reinterpret bytes.

value v{};
v.i = 42;          // active member: i
int x = v.i;       // OK
// double y = v.d; // undefined behavior: d is not active

2. Size, Alignment and the Layout Bill

Property Rule Consequence
Size At least the size of the largest member; may be larger because of padding A union is not a compact serialization format
Alignment The strictest alignment among the members A union containing a double is 8-byte aligned even if you only use its char member
Padding Implementation-defined Do not write it to a file or a socket as if it were a wire format
Empty union Not allowed; a union must have at least one non-static data member Use a struct with an empty member if you need a distinct type

If you need a specific alignment, say so with alignas. If you need to know the alignment, ask alignof. Both are covered in 25.4.

union alignas(64) cache_line
{
    char bytes[64];
    double value;
};

static_assert(alignof(cache_line) == 64);
static_assert(sizeof(cache_line) >= 64);

alignas can increase the alignment; it cannot reduce it below what the members require. The compiler is allowed to make the object larger, but not smaller than the largest member.

3. A Tagged Union, Written by Hand

A union alone does not tell you which member is active. You need a tag next to it. This is a tagged union, or discriminated union:

struct number
{
    enum class kind { integer, real } k;
    union
    {
        int i;
        double d;
    };
};

The inner union is anonymous: it has no name, and its members are injected into the enclosing class. You write n.i and n.d, not n.u.i. The tag says which one is alive:

number n{};
n.k = number::kind::integer;
n.i = 42;

switch (n.k)
{
case number::kind::integer:
    std::print("integer {}\n", n.i);
    break;
case number::kind::real:
    std::print("real {}\n", n.d);
    break;
}

The tag is not checked by the language. If you write the tag as integer but assign n.d, the program is still well-formed until it reads the wrong member. The invariant is your responsibility. Keep the tag and the union together, and change them in one place.

This is the pattern to reach for when you need a small closed set of alternatives and cannot afford a separate allocation. It is not a general-purpose dynamic type; it has no visitor, no type erasure and no automatic lifetime management. Those are separate design decisions.

4. Non-Trivial Members: You Own the Lifetime

A union with a member that has a non-trivial constructor or destructor needs help. The union itself does not know which member is alive, so it cannot construct or destroy the right one. You must provide the special members and manage the lifetime explicitly.

#include <memory>
#include <string>

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

    storage() noexcept {}
    ~storage() {}
};

storage st;
std::construct_at(&st.s, "hello");
std::print("{}\n", st.s);
std::destroy_at(&st.s);

std::construct_at starts the lifetime of the std::string in the union's storage. std::destroy_at ends it. If you forget the destroy, the string's buffer leaks. If you destroy a member that is not alive, the behavior is undefined. If you copy the union, you must know which member to copy; the implicit copy is deleted for a reason.

This is why a non-trivial union is usually wrapped in a class that owns the tag and provides constructors, a destructor, copy/move operations and accessors. The union is the storage primitive; the wrapper is the type that maintains the invariant.

5. The Common Initial Sequence

There is one narrow exception to "only the active member". If two standard-layout structs start with the same sequence of layout-compatible members, a union of those structs may be read through either member for that common initial part:

struct header
{
    std::uint32_t tag;
};

struct a
{
    std::uint32_t tag;
    int x;
};

struct b
{
    std::uint32_t tag;
    double y;
};

union u
{
    a av;
    b bv;
};

u value{};
value.av = {1, 2};
std::uint32_t tag = value.bv.tag; // OK: common initial sequence
// int x = value.bv.y;            // still undefined: y is not common

The exception is deliberately narrow. It exists for C compatibility and for inspecting the common header of variant-like records. It does not make arbitrary type punning legal, and it does not apply to non-standard-layout types.

If you need to reinterpret the bytes of one type as another, use ::std::bit_cast when the types are trivially copyable and the same size (Ch2.13 and Ch5.13), or std::memcpy into a properly constructed object. A union is not the tool for that job in C++.

6. Bit-Fields

A bit-field declares a member with a specific number of bits:

struct flags
{
    unsigned ready : 1;
    unsigned mode  : 3;
    unsigned       : 0;   // force the next member to a new allocation unit
    unsigned error : 1;
};

Bit-fields are useful for compact in-memory records, hardware registers and protocol headers that are defined in terms of bits. They are a poor choice for a portable wire format: the allocation order, padding, signedness of a plain int bit-field and the meaning of a zero-width field are implementation-defined.

Question Answer for a portable format
Which bit is the first field? Not specified by the standard; depends on the ABI
How wide is the allocation unit? Implementation-defined
What does unsigned : 0 do? Implementation-defined alignment to the next unit
Can I take its address? No; a bit-field is not addressable

The classic C trick of writing an integer and reading a bit-field through a union is undefined behavior in C++. If you need to pack and unpack bits, do it explicitly with shifts and masks, or use ::std::bit_cast to a fixed-width integer and then shift. That code is portable and testable; the union version depends on the compiler's layout.

7. When to Use a Union

Do not use a union as a general "any type" variable. Do not use it to bypass the type system. Do not use it for type punning. If the alternatives need behavior, write a class that owns the tag and the storage; the union is only one piece of the implementation.

Key Takeaways

The next page covers a feature that is not a type but changes how you unpack one: structured bindings.