std::any vs void* in C++: Checked Casts, Heap Costs and C Callback Context

Key takeaways

std::any remembers the stored type and checks every cast; void* forgets it and trusts you. Use void* at C API boundaries, std::any for genuinely open value bags, and std::variant or a real interface almost everywhere else.

Two ways to forget a type

std::any and void* both let you pass a value around without the receiving code knowing its static type. The difference is who keeps track of the type afterwards.

  • void* keeps nothing. It is an address. Getting the value back requires a cast that the compiler cannot check, and if you cast to the wrong type the behavior is undefined.
  • std::any (C++17, <any>) owns a copy of the value and remembers its type. Every any_cast compares the requested type with the stored one and either succeeds, throws std::bad_any_cast, or returns nullptr.

That makes the comparison less about “old vs new” and more about ownership and checking. void* is a non-owning, unchecked handle; std::any is an owning, checked value. Most bugs I see in this area come from treating one as if it were the other.

This is a head-to-head comparison focused on the failure modes of each and on the one place void* still wins. The full std::any API (every any_cast form, emplace, reset, heterogeneous containers and plugin-style uses) is in C++17 std::any.

All examples below were compiled with g++ 10.3 (-std=c++17 -Wall -Wextra); outputs are from that toolchain with libstdc++.

How std::any checks casts

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

int main() {
    std::any value = 42;

    try {
        auto s = std::any_cast<std::string>(value);   // wrong type
    } catch (const std::bad_any_cast& e) {
        std::cout << "bad cast: " << e.what() << '\n';
    }

    if (auto* p = std::any_cast<int>(&value))  std::cout << "int " << *p << '\n';
    if (!std::any_cast<long>(&value))          std::cout << "not a long\n";

    std::any_cast<int&>(value) = 7;            // reference form: modify in place
    std::cout << std::any_cast<int>(value) << '\n';

    value.reset();
    try { std::any_cast<int>(value); }
    catch (const std::bad_any_cast&) { std::cout << "empty throws\n"; }
}
bad cast: bad any_cast
int 42
not a long
7
empty throws

Three things are worth internalizing:

  1. Two failure channels. The value/reference forms (any_cast<T>(a), any_cast<T&>(a)) throw. The pointer form (any_cast<T>(&a)) returns nullptr. Pick the pointer form when a mismatch is an expected outcome (probing a heterogeneous container) and the throwing form when a mismatch means a bug.
  2. Exact type match. any_cast<long> on an int fails, even though int converts to long. There are no conversions, no base-class lookups, and no promotions. any_cast<Base> on a stored Derived fails too.
  3. any_cast<T>(a) returns a copy. For a large stored object, casting by value in a loop copies it every time. Use any_cast<const T&>(a) or the pointer form to avoid that.

The string-literal trap

std::any s = "hello";                                  // stores const char*
std::cout << (std::any_cast<std::string>(&s) == nullptr) << ' '   // 1
          << (std::any_cast<const char*>(&s) != nullptr) << '\n'; // 1

The literal decays to const char*, so that is the stored type. This is the single most common std::any surprise, and it gets worse when a wrapper swallows the failure:

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

class Config {
    std::unordered_map<std::string, std::any> values_;
public:
    template <typename T>
    void set(const std::string& key, T value) { values_[key] = std::move(value); }

    template <typename T>
    T get_or(const std::string& key, T fallback) const {
        auto it = values_.find(key);
        if (it == values_.end()) return fallback;
        if (const T* p = std::any_cast<T>(&it->second)) return *p;
        std::cout << "config: key '" << key << "' holds a different type\n";
        return fallback;
    }
};

int main() {
    Config cfg;
    cfg.set("host", "localhost");   // const char*, not std::string
    cfg.set("timeout", 30);         // int, not long
    auto host = cfg.get_or<std::string>("host", "");
    std::cout << "[" << host << "]\n";
    std::cout << cfg.get_or<long>("timeout", 5) << '\n';
    std::cout << cfg.get_or<int>("timeout", 5) << '\n';
}
config: key 'host' holds a different type
[]
config: key 'timeout' holds a different type
5
30

I have debugged this exact shape of bug more than once: a config or property bag built on std::any whose getter catches bad_any_cast and quietly returns the default. The service started, the setting “was set”, and yet the program ran with the fallback value because someone wrote 30 in one place and asked for long in another. The fix that stuck was not better discipline but a narrower design: a std::variant<bool, std::int64_t, double, std::string> for values, with the setter normalizing literals. If you do keep std::any, at minimum log or assert on type mismatches instead of hiding them, as the version above does.

What std::any costs

std::any is not a pointer with a type tag. It is an owning container with a small internal buffer, and anything that does not fit goes to the heap. Counting calls to operator new with libstdc++ 10:

allocs = 0; { std::any a = 42; }                         // int:           0
allocs = 0; { std::any a = 3.14; }                       // double:        0
allocs = 0; { std::any a = std::array<int, 4>{}; }       // array<int,4>:  1
allocs = 0; { std::any a = std::string("hi"); }          // short string:  1
allocs = 0; { std::any a = std::array<int, 4>{}; std::any b = a; }  // copy: 2

sizeof(std::any) is 16 on this 64-bit build, and the in-place buffer is one pointer wide. A std::string (32 bytes in libstdc++) does not fit, so even "hi" costs an allocation, and copying an any that holds a heap value allocates again. The buffer size is implementation-defined; MSVC and libc++ use different sizes, so do not design around a specific threshold, just assume “small trivially-movable things are inline, everything else allocates.”

Other constraints that follow from the design:

  • Copyable types only. std::any a = std::make_unique<int>(5); fails to compile:

    error: conversion from 'std::_MakeUniq<int>::__single_object' {aka 'std::unique_ptr<int, std::default_delete<int> >'} to non-scalar type 'std::any' requested

    std::any must be copyable itself, so it only accepts copy-constructible values. Move-only resources need std::shared_ptr or a hand-written type-erased wrapper.

  • No references. An any always stores a value (a decayed copy). To share an object, store a pointer or std::reference_wrapper, and then you own the lifetime problem again.

  • Every access is a runtime check. It is cheap, but it is a branch plus an indirect call, and in hot loops the allocation behavior usually matters more.

How void* fails

void* has no cost beyond a pointer, and no protection either. Conversion to void* is implicit; conversion back requires static_cast (or a C-style cast), and the standard only guarantees a correct result if you cast back to the same type you started with.

void* p = new int(42);
double* d = static_cast<double*>(p);   // compiles; reading *d is undefined behavior
delete static_cast<int*>(p);           // must delete as the original type

No warning is emitted for either line; -Wall -Wextra has nothing to say, because the code is legal until it runs. Deleting through void* directly (delete p;) is a different matter: g++ warns deleting 'void*' is undefined, and it is undefined because no destructor can be selected.

The multiple-inheritance offset bug

The “cast back to the same type” rule bites hardest with multiple inheritance, because a Derived* and a pointer to its second base are different addresses:

struct Logger  { virtual ~Logger() = default; std::string prefix = "log"; };
struct Metrics { int count = 0; };
struct Service : Logger, Metrics { };

Service svc;
std::printf("&svc=%p  (Metrics*)&svc=%p\n",
            (void*)&svc, (void*)static_cast<Metrics*>(&svc));
&svc=00000045993ff6e0  (Metrics*)&svc=00000045993ff708

The Metrics subobject lives 40 bytes into Service (after the vptr and the std::string). If you pass &svc as void* and the callback does static_cast<Metrics*>(ud), the cast does not apply the offset; the callback now treats the start of the Logger subobject as a Metrics and increments bytes inside the vptr or the string. Nothing crashes at the call site. The corruption shows up later, somewhere else.

Where void* is still the right tool: C callback context

Almost every C library that takes a callback also takes a void* user_data that it hands back to you: pthread_create, qsort_r, SQLite’s sqlite3_exec, libcurl’s write callbacks, GLFW’s window user pointer. You cannot pass a std::any through a C ABI, so void* is the only option here. The job is to make that one cast point obviously correct.

extern "C" {
typedef void (*callback_t)(int event, void* user_data);
void register_callback(callback_t cb, void* user_data);
void fire(int event);
}

static void on_event(int event, void* ud) {
    auto* m = static_cast<Metrics*>(ud);     // cast back to exactly what was passed
    m->count += event;
}

int main() {
    Service svc;
    register_callback(on_event, static_cast<Metrics*>(&svc));  // convert BEFORE void*
    fire(3);
    std::printf("count=%d\n", svc.count);   // count=3

    // Bridging any C++ callable: pass a pointer to the std::function itself.
    std::function<void(int)> handler = [](int e) { std::printf("handler got %d\n", e); };
    register_callback([](int e, void* ud) {
        (*static_cast<std::function<void(int)>*>(ud))(e);
    }, &handler);
    fire(7);                                 // handler got 7
}

The captureless lambda converts to a plain function pointer, and the std::function (or any object) travels through user_data. Rules I follow for this pattern:

  • Convert to the exact target type first, then to void*. Write the cast pair next to each other so a reviewer sees both.
  • Own the lifetime explicitly. The C library does not keep your object alive. If the callback can fire after the registering scope returns (async I/O, timers, threads), the object must be heap-allocated or a member that outlives the registration, and you must unregister before destroying it.
  • Keep void* at the boundary. Recover the real type in the first line of the trampoline and pass typed references from there on. Do not let void* spread into your own interfaces.

The version of this I have seen go wrong most often is not a wrong type but a dead object: a context struct on the stack of a function that registered an async callback and returned. The static_cast was perfectly correct; the pointer just no longer pointed at anything. Sanitizers (-fsanitize=address) catch this as a stack-use-after-return or use-after-scope, but only if the test actually triggers the late callback, which is exactly the path tests tend to miss.

Choosing between them (and the third option)

Questionvoid*std::any
Who owns the value?Nobody; you manage lifetimeThe any owns a copy
Wrong-type accessUndefined behavior, silentbad_any_cast or nullptr
Conversions on accessWhatever your cast doesNone; exact type only
Crosses a C ABIYesNo
Move-only typesYes (it is just a pointer)No, copy-constructible only
AllocationNone by itselfHeap for values beyond a small buffer

In practice the choice is usually between these two and something better:

  • Closed set of types → std::variant. If you can list the types (int, double, std::string), a variant gives compile-time exhaustiveness via std::visit, no heap allocation for the alternatives, and no possibility of the literal-decay bug going unnoticed.
  • Open set of types that share behavior → a virtual interface or a type-erased wrapper. If every stored thing needs to be drawn, serialized, or called, erase the type behind that operation (std::function, a small interface, or a hand-rolled type-erasure class), not behind “anything”.
  • Truly open, rarely inspected payloads → std::any. Plugin metadata, per-request context slots in a framework, or attaching arbitrary user data to a node in a tree are reasonable uses. The consumer knows the concrete type; the carrier does not need to.
  • C boundary → void*, wrapped as tightly as shown above.

Migrating old void* code rarely means swapping in std::any one-for-one. Usually the void* was standing in for “one of these three structs” (use a variant) or “something with a run() method” (use an interface), and std::any would only move the unchecked assumption into a checked one without making the design clearer.