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. Everyany_castcompares the requested type with the stored one and either succeeds, throwsstd::bad_any_cast, or returnsnullptr.
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:
- Two failure channels. The value/reference forms (
any_cast<T>(a),any_cast<T&>(a)) throw. The pointer form (any_cast<T>(&a)) returnsnullptr. 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. - Exact type match.
any_cast<long>on anintfails, even thoughintconverts tolong. There are no conversions, no base-class lookups, and no promotions.any_cast<Base>on a storedDerivedfails too. any_cast<T>(a)returns a copy. For a large stored object, casting by value in a loop copies it every time. Useany_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' requestedstd::anymust be copyable itself, so it only accepts copy-constructible values. Move-only resources needstd::shared_ptror a hand-written type-erased wrapper. -
No references. An
anyalways stores a value (a decayed copy). To share an object, store a pointer orstd::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 letvoid*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)
| Question | void* | std::any |
|---|---|---|
| Who owns the value? | Nobody; you manage lifetime | The any owns a copy |
| Wrong-type access | Undefined behavior, silent | bad_any_cast or nullptr |
| Conversions on access | Whatever your cast does | None; exact type only |
| Crosses a C ABI | Yes | No |
| Move-only types | Yes (it is just a pointer) | No, copy-constructible only |
| Allocation | None by itself | Heap 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 viastd::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.