Templates for Heterogeneous Type-safe Containers in Modern C++

Photo by Pritesh Sudra on Unsplash

Code for this tutorial lives at my GitHub repo: github.com/mday299/keypuncher/

Email the Author

Overview

Introduction | Sample 1 std::any | Sample 2 std::variant | Sample 3 C++23 | Credits | Advanced

Introduction

Vector, maps, queues, sets, and all the containers for standard templates, oh my! There are a lot to choose from. The following are guides for templating heterogeneous type-safe containers in C++17 and C++23. These were tested on Ubuntu 24.04 with g++ version 13.3.0.6 and clang version 18.1.13.

Code Sample 1: std::any

Crux of the Matter

The basic idea for how std::any works with type casting is you can cast basically anything:

int main() {
    std::any x = 42;                  // store int
    std::cout << std::any_cast<int>(x) << "\n";   // valid cast
    try {
        std::cout << std::any_cast<double>(x) << "\n"; // invalid cast throws
    } catch (const std::bad_any_cast& e) {
        std::cout << "Bad cast: " << e.what() << "\n";
    }
    x = std::string{"hello"};         // store string
    std::cout << std::any_cast<std::string>(x) << "\n"; // valid cast
}

The code that improves upon this lives here: typeSafeContainer.cpp

Build

Now that you’ve got the idea, build the code on GitHub with with g++ or clang:

g++ -std=c++17 typeSafeContainer.cpp -o TSContainer

clang++ -std=c++17 typeSafeContainer.cpp -o CTSContainer -lstdc++ -ldl

Running should print a valid cast, then an invalid cast, and finally another valid cast.

Code Sample 2: std::variant and Visitor Pattern

Crux of the Matter

Diagram of the Visitor Design Pattern

Visitor Design Pattern
Visitor Design Pattern

A basic implementation of a heterogeneous container with implemented with std::variant and visitors follows:

int main() {
    std::vector<std::variant<int, double, std::string>> v = {1, 2.5, "foo"};

    auto print_visitor = [](auto& value) { std::cout << value << " "; };
    auto double_numeric = [](auto& value) {
        if constexpr (std::is_arithmetic_v<decltype(value)>) value *= 2;
    };

    for (auto& elem : v) std::visit(print_visitor, elem);
    std::cout << "\n";

    for (auto& elem : v) std::visit(double_numeric, elem);
    for (auto& elem : v) std::visit(print_visitor, elem);
}

Code that expands upon this lives here: variantTSContainerWithVisitor.cpp

Build

Now that you’ve got the idea, build the code on GitHub with with g++ or clang:

g++ -std=c++17 variantTSContainerWithVisitor.cpp -o VTSContWithVisitor

clang++ -std=c++17 variantTSContainerWithVisitor.cpp -o CVTSContWithVisitor -lstdc++ -ldl

Running this should result in printout of the original container, the results of visiting with a visitor that doubles the numeric values, the results of visiting with a visitor that appends bar to the string, and the results after chaining doubling, squaring, and appending again. Finally the size of the container and the number of integers in it is calculated.

The idea for this was inspired by Andy G’s blog: a-true-heterogeneous-container-in-c/

Code Sample 3: C++23

Advantages of C++23 vs C++17

  • constexpr std::visit
    • In C++17, std::visit could only be used at runtime.
    • In C++23, std::visit is constexpr, so you can apply visitors at compile time!
    • This enables your visit_all and chain_visit_all methods to be usable in constexpr contexts, allowing transformations and checks (static_assert) before runtime.
  • Fold expressions with visitors
    • Fold expressions were introduced in C++17, but in C++23 they combine more cleanly with constexpr std::visit.
    • The chain_visit_all method can apply multiple visitors in one pass at compile time, something not possible in C++17.
  • Concepts in lambdas
    • In C++17, we used std::is_same_v to constrain visitors.
    • Example: type‑safe visitors using concepts directly inside lambdas:
auto square_numeric = []<typename T>(T& value) {
    • This makes visitor definitions shorter, clearer, and safer.

Code for this lives at: variantTSContWithVisitorCpp23.cpp

Build

Build with g++ or clang:

g++ -std=c++23 variantTSContWithVisitorCpp23.cpp -o VTSContWithVisitor23

clang++ -std=c++23 variantTSContWithVisitorCpp23.cpp -o CvarTSContWithVisitorCpp23 -lstdc++ -ldl

The output is simply to print 2 doubled then squared (16) and 3 doubled then squared (36). Additionally an assertion is made at the end to verify both those things as at compile time as well but of course that output doesn’t get printed.

Credits

Email the Author

Sample heterogeneous container https://gieseanw.wordpress.com/2017/05/03/a-true-heterogeneous-container-in-c/

Visitor pattern: https://en.wikipedia.org/wiki/Visitor_pattern

Useful playground: https://wandbox.org/

boost::any: https://stackoverflow.com/questions/4738405/how-can-i-store-objects-of-differing-types-in-a-c-container

Variadic template: https://en.wikipedia.org/wiki/Variadic_template#C.2B.2B

Tagged Union: https://en.wikipedia.org/wiki/Tagged_union

Contiguous Container: https://en.cppreference.com/cpp/named_req/ContiguousContainer

Another heterogeneous-like, container: https://www.linkedin.com/pulse/c17-improved-interface-containers-rainer-grimm/

C++ new features in 17 and 20: https://caiorss.github.io/C-Cpp-Notes/Libraries-and-featuresCPP17.html

Microsoft Copilot

Advanced

Bonus topics:

std::any vs std::variant

Before C++17, you’d often use boost::any or void* hacks. With C++17, std::any makes this clean and safe. Contrasting std::any with std::variant is done in the following table. Both can be used for heterogeneous containers, but some trade‑offs exist with some security concerns existing for std::any.

Feature std::any std::variant
Type set Open‑ended: can hold any type Closed: must be declared with a fixed set of types
Safety Runtime checked (any_cast throws if wrong) Compile‑time checked (compiler enforces only allowed types)
Performance Extra cost: runtime type check + possible copy when casting Faster: no runtime type check, direct access via std::get
Flexibility Can store arbitrary types, even ones not known at compile time Limited to the union of types declared in the variant
Security implications Misuse can lead to crashes or exceptions if casts are wrong; unchecked casts make it easier to write unsafe code Safer: compiler prevents invalid casts, no runtime surprises
Use cases Dynamic plugin systems, scripting, serialization where types aren’t known ahead of time Algebraic data types, state machines, Abstract Syntax Trees, when all possible types are known

5Point

I was seeing some mysterious “5Point” output during my first few runs. This comes from the way std::type_info::name() reports type names.

  • x.type().name() returns a compiler‑specific string describing the type stored in the std::any.
  • On GCC/libstdc++, the returned name is mangled (encoded).
  • For my Point, GCC printed 5Point.

The 5 is not random, it’s part of the Itanium C++ ABI name mangling scheme used by GCC. It means: the next 5 characters form the type name. Hence 5Point = “Point”.

Other compilers behave differently:

  • Clang (with libc++) might show Point.
  • MSVC usually shows a more verbose decorated name.

As you probably have guessed, cxxabi.h is not cross‑platform portable with Visual Studio or other compilers.

  • It’s not part of the ISO C++ standard library.
  • It’s part of the Itanium C++ ABI implementation used by GCC and Clang on Unix‑like systems (Linux, macOS).

Portability

  • GCC / Clang (Linux, macOS): Works fine.
  • MSVC (Windows): Not available. MSVC uses a different ABI and provides its own demangling via the Debug Help Library (DbgHelp.h) and functions like UnDecorateSymbolName.
  • Other compilers: May not support it at all.

A wrapper function is included in my source code to deal with this.

Costs of std::any_cast<Point>(x)

By value cast

  • std::any_cast<Point>(x)
    • Copies the stored object out of the std::any; the Point copy constructor runs.
    • For lightweight types like Point, this is has a low computational cost. But for heavy objects (large vectors, complex classes), it can be expensive.
    • Also pays for runtime type checking: std::any_cast that compares the requested type against the stored type’s std::type_info.

By reference cast

  • std::any_cast<Point&>(x) or
  • std::any_cast<const Point&>(x)
    • This avoids the copy and returns a reference to the stored object.
    • Much cheaper, and usually the preferred way if you don’t need a copy.

Security implications

The blog author explicitly warns:

“The following is intended as a toy, not to be used in any real implementation. It has a gaping security hole in it.”

The “hole” comes from the fact that the sample heterogeneous container relies on unchecked runtime casting:

  • Call std::any_cast<T>(x) with the wrong type and we either get:
    • An exception (std::bad_any_cast), or
    • In some unsafe patterns (like using any_cast<T*>), we can get a null pointer or undefined behavior.
  • The code often assumes the cast will succeed and doesn’t always guard against exceptions.
    • A malicious or careless user could insert unexpected types into the container.
    • Later code that assumes a certain type could crash, throw, or even corrupt memory if unsafe casts are used.
  • In production, you’d need:
    • Proper type checks (x.type() == typeid(ExpectedType)).
    • Exception handling around every cast.
    • Possibly a safer abstraction (like std::variant or a polymorphic base class) to constrain the set of allowed types.

https://wandbox.org/permlink/oXJ1MG7vBV548GZ4

This is essentially a heterogeneous container that uses static unordered_maps keyed by container pointers to store vectors of different types, plus a visitor mechanism to operate on them.

How it works

  1. Type registry via static maps For each type T, there’s a static unordered_map<const heterogeneous_container*, std::vector<T>> items<T>.
    • Each heterogeneous_container instance is identified by its this pointer.
    • That pointer indexes into the map to find the vector of T belonging to that container.
  2. Push back When you call push_back<T>(value): if this container hasn’t stored T before, it registers cleanup, copy, and size functions for T. Then it appends the value into items<T>[this].
  3. Copy and assignment Copying a container means:
    • Clear existing items.
    • Copy over the registered functions.
    • For each type, run its copy function to duplicate the vectors from the source container.
  4. Clear Calls all registered clear functions, which erase the container’s entries from each items<T> map.
  5. Visitor pattern
    • A visitor type inherits from visitor_base<TYPES...> to declare which types it can handle.
    • visit(visitor) expands over all types in the visitor’s types list.
    • For each type, it iterates through the container’s vector of that type and calls visitor(element).
    • A static_assert ensures the visitor actually has an operator()(U&) for each type.

Strengths

  • True heterogeneous storage: You can store int, double, std::string, etc. in the same container.
  • Visitor enforcement: The static_assert ensures visitors handle all declared types.
  • Copy semantics: Copy constructor and assignment operator correctly duplicate contents.
  • Clear separation: Each type’s data is isolated in its own static map.

Weaknesses

  • Memory management: Erasing by pointer assumes no dangling references; misuse could leak or corrupt.
  • Performance: Indirection through maps and function vectors adds overhead compared to std::variant or polymorphic containers.
  • Complexity: The design is clever but hard to maintain.
  • Security hole: Because the container relies on raw pointer keys and global maps, a malicious or buggy program could forge a pointer or misuse the API.

Other Security Implications

  • Pointer identity as key: Using this as a key in global maps is risky. If a container is destroyed but not properly cleared, stale entries remain.
  • No type constraints: Any type can be pushed; visitors must handle them correctly. If not, static_asserts or runtime errors occur.
  • Potential dangling references: If you copy or clear incorrectly, you could end up with dangling references in the static maps.
  • Not thread‑safe: Concurrent access to the static maps would cause races.