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.