How C++ Unions Manage Overlapping Memory Storage
In C++, a union is a user-defined data type that allows multiple data
members of different types to occupy the same contiguous memory
location. This article covers how the compiler allocates and aligns
memory for unions, the single-offset model used to access members, the
rules governing member lifetimes, and modern alternatives such as
std::variant.
Sizing and Alignment of Union Memory
When a union is declared, the compiler allocates a single block of memory rather than separate space for each member. The size of this memory block is determined by two factors:
- Size of the Largest Member: The allocated memory must be at least as large as the largest data member defined in the union.
- Alignment Requirement: The total size and the starting memory address must satisfy the strictest alignment requirement among all members. The compiler may add trailing padding bytes to ensure the total size is an exact multiple of this alignment requirement.
For example:
union Data {
char c; // size: 1 byte, alignment: 1
int i; // size: 4 bytes, alignment: 4
double d; // size: 8 bytes, alignment: 8
};In this case, sizeof(Data) is 8 bytes, and its alignment
is 8 bytes, matching the requirements of double.
Shared Base Address (Offset Zero)
Every member within a union shares the exact same starting memory
address. Unlike a struct or class, where
members are stored sequentially at increasing offsets, every member of a
standard union begins at offset zero from the address of the union
object.
When you write to one member, the processor writes raw bytes to this memory block according to that member's binary representation. If you then assign a value to another member, the processor writes the new value over the exact same bytes, partially or completely overwriting the previous value.
Active Members and Undefined Behavior
A union can only hold one valid value at any given time, known as the active member. Writing to a member makes it active.
In standard C++, reading from a member that is not the active member
leads to undefined behavior. While some compilers allow this
practice—known as type punning—as an extension, the C++
standard strictly enforces the active member rule. If you need to
reinterpret the binary representation of an object as another type in
modern C++, you should use std::bit_cast (available in
C++20) or std::memcpy rather than reading an inactive union
member.
Managing Non-Trivial Types
Since C++11, unions can contain non-trivial types (such as types with custom constructors, destructors, or copy assignments). When a union contains a non-trivial member, the compiler deletes the union's default constructors and destructor.
To manage non-trivial types in a union, the programmer must explicitly handle object lifecycles:
- Construction: Initialize the active member using
placement
new. - Destruction: Explicitly invoke the destructor of the active member before activating another member or before the union goes out of scope.
#include <string>
#include <new>
union NonTrivialUnion {
std::string str;
int num;
NonTrivialUnion() { num = 0; }
~NonTrivialUnion() {} // Must be user-defined
};
// Usage:
NonTrivialUnion u;
u.num = 42;
// Switch to string:
new (&u.str) std::string("Hello, World!");
// Clean up string before switching back or destructing:
u.str.~basic_string();
u.num = 100;Modern Alternative:
std::variant
Because native unions do not track which member is currently active,
they are prone to logic bugs and undefined behavior. C++17 introduced
std::variant, a type-safe union that manages storage
overlap while automatically tracking the active type, safely calling
constructors and destructors, and throwing an exception or returning
null if an inactive type is accessed.