Non-Trivial Types in C++ Unrestricted Unions

In C++, unrestricted unions allow the inclusion of members with non-trivial constructors and destructors, such as std::string or custom classes. However, because a union allocates overlapping storage for all its members without maintaining runtime metadata about which member is active, the compiler cannot automatically determine which constructor or destructor to invoke. Consequently, the compiler deletes the union's default special member functions, requiring developers to explicitly manage object lifecycles using placement new and manual destructor calls.

The Problem of Shared Storage

A union stores all its data members at the same memory address, with a total size equal to the size of its largest member (plus alignment requirements). Unlike standard structs or classes, only one member can be active and hold valid data at any given time.

Prior to C++11, unions were restricted to Plain Old Data (POD) types with trivial default constructors and trivial destructors. C++11 removed this restriction, introducing "unrestricted unions." While this allows complex types to inhabit unions, it exposes a fundamental limitation: the union contains no inherent discriminator or "tag" to store which variant is currently instantiated.

Automatic Deletion of Special Member Functions

When a class member has a non-trivial constructor, copy/move operation, or destructor, the compiler typically generates corresponding code to manage that member's lifetime when the enclosing class is constructed or destroyed.

In an unrestricted union, if one or more members have a non-trivial special member function, the compiler faces a conflict:

  • It cannot know which member's constructor to call during default initialization.
  • It cannot know which member to copy or move when the union is duplicated or relocated.
  • It cannot determine which member's destructor to invoke when the union goes out of scope.

Invoking the wrong destructor on uninitialized or overwritten memory leads to undefined behavior. Because the compiler cannot make this choice safely or automatically, the C++ language standard mandates that the union’s corresponding special member functions (default constructor, copy/move constructors, copy/move assignment operators, and destructor) are implicitly defined as deleted (= delete).

Manual Lifecycle Management

To make an unrestricted union usable with non-trivial members, the programmer must supply custom implementations of the constructors, assignment operators, and destructor, manually controlling memory initialization and cleanup.

1. Explicit Construction with Placement new

Because the union itself cannot determine how to construct the active member, storage must be initialized explicitly. This is achieved using placement new, which constructs an object at a pre-allocated memory address:

new (&my_union.active_string) std::string("Hello, World!");

This constructs the std::string instance directly within the union's allocated buffer.

2. Switching Active Members

When switching from one non-trivial member to another, or even to a trivial type, the currently active object's lifetime must be explicitly terminated before initializing the new member:

// Destroy the active object
my_union.active_string.~basic_string();

// Construct the new active object
new (&my_union.active_vector) std::vector<int>{1, 2, 3};

Failing to call the destructor before overwriting the memory will cause resource leaks (e.g., dynamically allocated buffers retained by std::string).

3. Explicit Destruction

When the union itself reaches the end of its lifetime, its implicitly deleted destructor will prevent compilation unless a custom destructor is defined. The custom destructor must explicitly invoke the active member's destructor:

union CustomUnion {
    std::string text;
    int number;

    CustomUnion() {} // Defined constructor
    
    ~CustomUnion() {
        // Must explicitly destroy the active member
        // (Assuming an external mechanism tracks which is active)
    }
};

Because unrestricted unions shift the responsibility of tracking type safety and object lifetimes entirely onto the developer, modern C++ codebases generally prefer std::variant, which automates this lifecycle tracking safely via a type-safe tagged union implementation.