std::any - comparison with void* and motivating examples

Tutorial | Nov 23, 2020 | hkumar 

ir-md-block-image ir-md-block-image-bottom-line ir-md-block-image-w-70

1. Intro

A std::any object can hold and manage the life of an instance of any type as long as its constructor requirements are met. A std::any object can be empty also. Therefore, it is quite useful for storing arbitrary data in a type-agnostic manner or creating dynamic-type interfaces. Some examples:

std::any a; //Empty
std::any ia = 10; //int
std::any sa = std::string("Hello"); //std::string
std::any va = std::vector{1,2,3}; //vector<int>
std::any aa = std::array{1,2,3}; //Array of 3 int

//In-place construction with std::make_any 
auto pa = std::make_any<std::pair<int,int>>(1,2); //std::pair<int,int>

The contained object in a std::any can be accessed through the std::any_cast<T> function. std::any_cast has a few overloads. It can either return a copy, a reference, or a pointer depending on how it is called. It throws std::bad_any_cast exception or returns a nullptr if the requested type does not match the contained type. Examples:

std::any a = std::string("Hello");

//value cast creates a copy
std::cout << std::any_cast<std::string>(a) << "\n"; //Hello

//reference cast
std::any_cast<std::string&>(a)[0] = 'h'; //cast as reference and change

//value is changed to "hello" now

//cast as const reference and print
std::cout << std::any_cast<const std::string&>(a) << "\n"; //hello

//  --- prints "Wrong Type!" below ---
try {
 std::cout << std::any_cast<double>(a) << "\n";
}catch(const std::bad_any_cast&) {
 std::cout << "Wrong Type!\n";
}

//Pointer cast example
//    ---     prints "hello" below   ---
if(auto* ptr = std::any_cast<std::string>(&a)) {
 std::cout << *ptr << "\n";
} else {
 std::cout << "Wrong Type!\n";
}

//move example
auto str = std::any_cast<std::string&&>(std::move(a));

//std::string in 'a' is moved
std::cout << str << "\n"; //hello

//string in 'a' is moved but it is not destroyed
//therefore 'a' is not empty.
std::cout << std::boolalpha << a.has_value() <<  "\n"; //true

//but should print ""
std::cout << std::any_cast<std::string>(a) << "\n"; //should be ""

A typical implementation of std::any utilizes std::type_info (obtained through typeid) to compare the two types when the contained object is accessed through std::any_cast. A fallback implementation-dependent mechanism is used for type identification if RTTI is disabled.

So, it should not come as a surprise that the requested type must match exactly. The followings do not work:

//No base pointer stuff
struct A { /*....*/ };
struct B : A { /*....*/ };

std::any a = B();
A* ptrA = std::any_cast<A>(&a); //returns nullptr
assert(ptrA == nullptr);

//  -   -   -   -

//No integral promotion
std::any ai = 10;
//prints "Wrong Type!"
try {
 std::cout << std::any_cast<int64_t>(ai) << "\n";
}catch(const std::bad_any_cast&) {
 std::cout << "Wrong Type!\n";
}

And finally, a feature that sets std::any apart from smart-pointer is that std::any has value semantics instead of the reference semantics — that means copying/moving a std::any instance copies/moves the contained object:

std::vector vec{1,2,3 /*,many more ints*/};

std::any a1 = vec; //vec is copied

std::any a2 = std::move(vec); //Now vec is moved

//change a value in a1's vector
std::any_cast<std::vector<int>&>(a1)[0]++;

std::cout << "a1[0]=" << std::any_cast<std::vector<int>&>(a1)[0] << " "
          << "a2[0]=" << std::any_cast<std::vector<int>&>(a2)[0] << "\n";
//prints a1[0]=2 a2[0]=1

std::any is often compared with void* because the latter has been the de-facto choice for storing or passing the arbitrary objects in C++ since the outset. std::any is not a replacement of void*, but it is a safer substitute for the boilerplate patterns built around void*. Nevertheless, a comparison between the two is required for a better understanding of std::any.

2. Comparison with void*

A simplistic mental model of std::any is a type-aware void*:

struct any {
 void* object;
 type_info tinfo;
};

But the above representation is an oversimplification and ignores the critical aspect that a std::any owns and manages the life of the contained object. Besides holding a void* to the object and type-identification, it also needs to support the construction, copy, move, and destruction of the managed object.

Because std::any is not a template, it cannot have any member functions specific to a type. Therefore, std::any typically carries a handle to a type-specific table of object management operations. Accordingly, a better mental model of std::any should look like:

std::any model

Moreover, std::any also does a small object optimization. Depending on the implementation, objects up to 2-3 pointer-size (e.g., int or double) are stored within std::any without requiring any dynamic allocation.

std::any is a combination of void*, type discrimination, value-semantics, and object management. It is a far more complex structure than a simple void*. It would be an overstatement to say that std::any is a replacement of void* in all situations. But it is undoubtedly a better alternative than the unsafe patterns implemented with void* where storing arbitrary objects, type-safety, and the ability to manage the object's lifetime are necessary.


std::any, not a better void*


3. Motivating Examples

In this section, we consider two use cases of std::any. Again, in each of these cases, we compare with void* to establish the usefulness of std::any.

3.1 Object cache with TTL

Consider a cache of arbitrary objects that keeps cached items only for a limited time. The cached entities expire and are automatically deleted after a fixed TTL (time-to-live). The cache itself is type-agnostic and can store any type of object against a string key. Here is the interface of the TTLCache that uses std::any:

class TTLCache {
public:

 //Initializes with a given ttl (in seconds)
 TTLCache(uint32_t ttl):ttlSeconds(ttl){}

 //Adds an item to the cache along with the current timestamp
 bool add(const std::string& key, const std::any& value);

 //Gets a value from cache if exists
 // - otherwise returns empty std::any
 std::any get(const std::string& key);

 //Erases an item for a given key if exists
 void erase(const std::string& key);

 // Fires periodically in a separate thread and erases the items
 //  - from cache that are older than the ttlSeconds
 void onTimer();

 //...more interfaces...

private:

 //Values stored along with timestamp
 struct Item {
  time_t timestamp;
  std::any value;
 };

 //Expire time (ttl) of items in seconds
 uint32_t ttlSeconds;

 //Items are stored against keys along with timestamp
 std::unordered_map<std::string, Item> items;
};

We are using std::any as a value object because the TTLCache has two requirements: storing any type of object and managing (destroy) its life. A type-specific cache could be a template, and a void* would be enough if there were no need to expire (delete) the items.

Nevertheless, there are a few other points in favor of the std::any from TTLCache's client's perspective. A client can access the cached items in a type-safe manner, items are safely destroyed when explicitly erased from the cache, the small object's memory allocation is optimized, and better readability.

3.2 User-Data

Many event-based libraries (e.g., networking or UI components libraries) let their clients attach an arbitrary data handle to event sources or requests. That arbitrary data serves as context or state and is typically called user-data.

For instance, a client can attach some context or user-data to a request message before sending it. That user-data comes back with the response message letting the client process the response.

It is a common practice to use void* as a general-purpose user-data handle. Having void* as user-data is minimally intrusive and keeps the library and the application codes loosely coupled. Consider part of an interface of a hypothetical networking library:

//Clients send requests to servers
struct Request {
 /*..Request fields..*/

 //User data can be set by clients
 void* userData;
};

//When a response comes to the client, it has
// - same user data that was attached to the Request
struct Response {
 /*..Response fields..*/

 //User data copied from Request
 void* userData;
};

However, the use of void* forces the clients to write unsafe boilerplate code everywhere to manage a user-data's lifetime. An application could be littered with a repeated code that creates and destroys user-data:

void sendRequest() {
 Request req;
 //Prepare request
 req.userData = new std::string("state data"); //Attach user data
 //Send request to server...
}

//Process response 
void processResponse(Response& res) {
 auto state = (std::string*)(res.userData); //cast not type-safe
 //Process response using state data....
 delete state;  // delete state
}

Moreover, void* is not type-safe and requires dynamic allocation for even small objects.

std::any overcomes the above shortcomings of the void* user-data. It eliminates the need to manage the objects explicitly, offers type-safe access to the data, and performs better for small objects:

//--- Suppose userData is std::any ---

void sendRequest() {
 Request req;
 req.userData = std::string("state data"); //attach user data
 //send request to server
}

void processResponse(Response& res) {
 auto& state = std::any_cast<std::string&>(res.userData); //throws if type does not match
 //Process response using state data....
 //No need to explicitly delete the user data.
}

The nextptr article The std::shared_ptr as arbitrary user-data pointer covers an alternate approach of modeling user-data with shared_ptr<void>. In this section, we reconstructed the example from that article using std::any. And in the next section, we will cover that std::any is a far better choice than shared_ptr<void> for this purpose.

4. Comparison with shared_ptr<void>

std::any manages the contained object's lifetime. However, a shared_ptr<void> can also control the lifetime of an arbitrary object.

A shared_ptr<void> can hold a pointer to any object type and can still properly destroy it by calling the appropriate destructor. shared_ptr achieves this by storing type-erased deleter at the time of construction:

std::shared_ptr<void> vps = std::make_shared<std::string>(); //OK 
vps.reset();  //Appropriate destructor is called

We can cast shared_ptr<void> to any type when needed:

auto sps = std::static_pointer_cast<std::string>(vps); //OK with typecast
//sps is std::shared_ptr<std::string>

But shared_ptr<void> has a memory overhead because it requires a control block for bookkeeping — and therefore, it could be a costly choice for storing small objects. Moreover, shared_ptr<void> does not offer the type-safety and the value-semantics of std::any, and it does not exhibit a distinct purpose.

shared_ptr<void> can manage any type of object, but compared to std::any, it looks more like a workaround or somewhat a misfit for the role.

5. Conclusion

std::any is one of the three "vocabulary types" added by the C++17 standard. The other two are: std::optional and std::variant. A vocabulary type shows a distinct intention and provides a standard solution to a typical pattern. std::optional characterizes a nullable value type, std::variant represents a type-discriminating union, and std::any typifies a type-safe container of any value.

In this article, we discussed the internals of std::any, how it could be used as a type-safe container of a single value, and how it could be a good replacement for the unsafe void* patterns.

std::any could be quite useful in many situations like storing values in type-agnostic containers, interfacing with scripting languages, configuration file parsing, attaching user-data to event-sources, type-unaware libraries, and more.

6. References

std::any - cpprefernce

Class any - P0220R1

std::any without RTTI, how does it work? - StackOverflow