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

Code for this tutorial lives at my GitHub repo: github.com/mday299/keypuncher/
Email the AuthorOverview
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

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::visitcould only be used at runtime. - In C++23,
std::visitisconstexpr, so you can apply visitors at compile time! - This enables your
visit_allandchain_visit_allmethods to be usable inconstexprcontexts, allowing transformations and checks (static_assert) before runtime.
- In C++17,
- 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_allmethod can apply multiple visitors in one pass at compile time, something not possible in C++17.
- Fold expressions were introduced in C++17, but in C++23 they combine more cleanly with
- Concepts in lambdas
- In C++17, we used
std::is_same_vto constrain visitors. - Example: type‑safe visitors using concepts directly inside lambdas:
- In C++17, we used
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 AuthorSample 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 thestd::any.- On GCC/libstdc++, the returned name is mangled (encoded).
- For my
Point, GCC printed5Point.
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 likeUnDecorateSymbolName. - 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; thePointcopy 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_castthat compares the requested type against the stored type’sstd::type_info.
- Copies the stored object out of the
By reference cast
std::any_cast<Point&>(x)orstd::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.
- An exception (
- 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::variantor a polymorphic base class) to constrain the set of allowed types.
- Proper type checks (
Link to Wandbox code
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
- Type registry via static maps For each type
T, there’s a staticunordered_map<const heterogeneous_container*, std::vector<T>> items<T>.- Each
heterogeneous_containerinstance is identified by itsthispointer. - That pointer indexes into the map to find the vector of
Tbelonging to that container.
- Each
- Push back When you call
push_back<T>(value): if this container hasn’t storedTbefore, it registers cleanup, copy, and size functions forT. Then it appends the value intoitems<T>[this]. - 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.
- Clear Calls all registered clear functions, which erase the container’s entries from each
items<T>map. - 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’stypeslist.- 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.
- A visitor type inherits from
Strengths
- True heterogeneous storage: You can store
int,double,std::string, etc. in the same container. - Visitor enforcement: The
static_assertensures 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::variantor 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
thisas 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.