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
- Compact storage for a closed set of alternatives. A tagged union with a small number of members and no allocation.
- Interop with a C ABI. A C library exposes a union; you mirror it exactly.
- Hardware or protocol records. Only when the layout is fixed by the platform or the protocol and you have static assertions for the sizes.
- Implementing a low-level container or allocator. The union is a storage primitive inside a wrapper that owns the tag and the lifetime.
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
- A union shares one storage region among its non-static data members.
- Only the active member may be read. The common initial sequence is the narrow exception.
- Size is at least the largest member; alignment is the strictest member alignment.
- Non-trivial members require explicit construction and destruction.
- Bit-fields are layout-dependent; do not use them as a portable wire format.
- Union type punning is undefined behavior in C++; use
bit_castormemcpy.
The next page covers a feature that is not a type but changes how you unpack one: structured bindings.