#072 – Using std::any for type-safe heterogeneous containers

Some problems genuinely require holding a value whose type isn’t known until run time — plug-in systems where a host loads arbitrary user data, generic event-handler context attached to messages, configuration values from a file whose schema isn’t known at compile time. The C-era answer was void* plus manual bookkeeping, with all the lifetime and type-safety problems that implies.

C++17’s std::any is the type-safe replacement: a single value type that can hold any copy-constructible object, remembers what it actually contains, and refuses to hand back the wrong type. The catch is that “I might need to hold any type” is a much rarer requirement than people assume — most uses of std::any would be better served by std::variant.

Three things to take away:

  • std::any holds a value of any copy-constructible type and uses typeid to verify the type at access time, throwing std::bad_any_cast (or returning nullptr, depending on the cast form) on a mismatch.
  • Two access shapes: std::any_cast<T>(a) throws on mismatch, std::any_cast<T>(&a) returns a pointer that’s null on mismatch — prefer the pointer form for control flow.
  • Reach for std::any only when the type set is genuinely open; for a closed set known at compile time, std::variant is almost always the right tool.

What std::any actually is

std::any is a value type that can store any object that can be copy-constructed. Internally, it carries the contained object’s type identity (a const std::type_info&, the same kind that typeid produces) and the storage for the object itself.

#include <any>
#include <iostream>
#include <string>

int main(){
    std::any a;                        // empty
    a = 42;                            // now holds int
    a = std::string{"hello"};          // int destroyed, now holds string
    a = 3.14;                          // string destroyed, now holds double

    std::cout << a.has_value() << '\n';      // 1
    std::cout << a.type().name() << '\n';    // implementation-defined
                                             // (typically "d" for double)
}

Assigning into an any runs the destructor of whatever it previously held and copy- or move-constructs the new value in its place. Querying has_value() and type() is cheap, but they don’t give you the value back — for that you need a cast.

Two ways to get the value out

Reading a value from std::any requires naming the type explicitly. There are two cast shapes, and they have very different failure semantics:

std::any a = 42;

// Throwing form: std::any_cast<T>(any)
int x = std::any_cast<int>(a);              // OK, returns 42
// std::string s = std::any_cast<std::string>(a);  // throws std::bad_any_cast

// Pointer form: std::any_cast<T>(&any)
if (auto* p = std::any_cast<int>(&a)) {     // returns int*, or nullptr
    std::cout << "Got int: " << *p << '\n';
}

The throwing form is appropriate when a wrong type indicates a genuine bug — you’ve already validated the type elsewhere and just want the value out. The pointer form is appropriate when the wrong type is part of normal control flow, the way you’d test for the held alternative of a std::variant (Nibble #058).

A useful idiom combines type() with the pointer form for a multi-way dispatch:

void print(const std::any& a){
    if (auto* i = std::any_cast<int>(&a))           std::cout << "int: "    << *i;
    else if (auto* s = std::any_cast<std::string>(&a)) std::cout << "string: " << *s;
    else if (auto* d = std::any_cast<double>(&a))      std::cout << "double: " << *d;
    else                                               std::cout << "unknown type";
    std::cout << '\n';
}

Note the asymmetry with std::variantstd::visit over a variant gives you a compile-time exhaustiveness check, because the alternatives are listed in the type. With any, the type set is open, so the compiler cannot warn you about the fallthrough case. That openness is exactly what any provides — and exactly what makes it riskier to use.

A heterogeneous container

The title’s use case is std::vector<std::any> — a container that mixes different types in a single sequence:

std::vector<std::any> userData;
userData.push_back(42);
userData.push_back(std::string{"hello"});
userData.push_back(3.14);
userData.push_back(MyConfigStruct{...});

for (const auto& item : userData) {
    print(item);   // dispatches by item.type()
}

This works, and it’s type-safe in the sense that the wrong extraction is a defined exception rather than memory corruption. But two costs are worth understanding:

  1. Per-element overhead. Each std::any carries the type identity plus the storage. Most implementations do small-object optimisation — values of up to two or three pointers (typically 16–24 bytes) are stored inline; anything larger is heap-allocated. A vector of std::any containing strings is therefore a vector of any headers plus per-element heap allocations.
  2. No exhaustiveness check. The dispatch in print above is the only place that knows what types might be in the container. Add a new type to a push_back somewhere, forget to add it to print, and the program silently falls into the “unknown type” branch with no warning at compile time.

When std::variant is better

For a closed set of types known when you write the container, std::variant<int, std::string, double, MyConfigStruct> is the correct answer almost every time. The advantages are concrete:

  • The type set is part of the type — readers of the code know exactly what can be in the container.
  • std::visit enforces exhaustiveness at compile time.
  • No heap allocation for any alternative; the variant is sized to fit its largest one.
  • Faster access — no typeid comparison, just an index check.

The dividing line is honest: if you can list the types now, use variant. If you cannot — because the types come from plugins, scripting layers, generic libraries that don’t know their callers’ types, or genuinely runtime-determined configuration — any is what you reach for.

When std::any is the right tool

A handful of cases do call for std::any:

  • Library-style “user data” parameters. A networking library that lets clients attach arbitrary context to a request and have it returned with the response should use any — the library author cannot enumerate the user’s context types.
  • Caches keyed by tag, holding heterogeneous values. A type-tagged cache where each entry’s type is determined by its tag rather than its position. any plus a tag-to-type map is the safe replacement for a void* cache.
  • Configuration trees with open schemas. A TOML/JSON-style document tree where the value at a node could be any of several types and new types may be added later.
  • Bridging to scripting languages. Where C++ holds values that originated in (or will be passed back to) a dynamically-typed environment.

In all of these, the type set is genuinely open at compile time. That is the bar any is designed to clear.

Takeaway

std::any is the type-safe successor to void* for problems that genuinely require runtime type heterogeneity. Use the pointer form of std::any_cast when a type mismatch is part of normal control flow; use the throwing form only when a mismatch is a bug. Be honest about whether the type set is genuinely open — std::variant (Nibble #058) is the right answer for any closed set, and brings compile-time exhaustiveness checks and zero heap allocation that any cannot. The rule is simple: reach for any when you really cannot list the types in advance, and only then.