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:

  1. Size of the Largest Member: The allocated memory must be at least as large as the largest data member defined in the union.
  2. 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.