The std::shared_ptr as arbitrary user-data pointer

An std::shared_ptr<void> can be used as a handle to an unspecified data type in a non-intrusive manner.

Tutorial | Oct 22, 2019 | nwheeler 

Overview

In C++, a pointer to a specific type (primitive or user-defined) can be assigned to a void* without an explicit typecast. Also, a void* can be typecasted back to a pointer of any type:

void* vp = new int(); // OK
int* ip = static_cast<int*>(vp); //OK with typecast.     

This property of void* makes it quite useful as a generic or opaque handle. What is interesting is that the std::shared_ptr<void> extends this characteristic of raw void* quite seamlessly:

std::shared_ptr<void> vps = std::make_shared<int>(); // OK
auto ips = std::static_pointer_cast<int>(vps);  // OK with typecast

This leads to a question of how std::shared_ptr<void> can destroy a managed object of a specific type despite holding a void*? It works because the object deletion procedure in std::shared_ptr is type-erased, or it is made independent of the stored pointer type that is returned by the get(). A later section of this article throws more light on that. But first, we look at a realistic example of where std::shared_ptr<void> could be quite useful.

A User-Data Pointer

Consider an asynchronous networking library that provides an interface to request some data from a remote server. The Request class has an interface for attaching a pointer of any arbitrary type to it; it is called a user-data pointer. The user-data pointer attached to a Request comes back in a Response object in a response handler callback. The clients of the library can associate a context to a request through a user-data pointer, which saves them a lookup of the request's context when a response arrives. Following is a schematic illustration of request/response events and data flow:

Request Response User Data

For brevity, the below code shows only the relevant parts of the Request and Response:

class Request {
 void* pData; //User-data pointer
 //more request fields
public:
 //Called by clients to set user-data
 void setUserData(void* p) { pData = p; }
};

class Response {
 void* pData; //User-data pointer copied from Request
 //..more response fields
public:
 //Called by clients to get user-data
 void* getUserData() { return pData; }
};

A user-data pointer is set before a request is sent out as follows:

//Client's context of the request 
struct Context { /*data*/ };

//Create request and attach user-data
Request r; 
//Set request fields
r.setUserData(new Context(/*..*/)); //Set a context
//Send request asynchrounously.  

When the response arrives in the handler callback, it contains the user-data pointer, which can be typecast to context. When the response is processed using the attached context, the context can be deleted:

 // Response handler callback
void onResponse(Response* pr) {
 auto pc = (Context*)pr->getUserData();
 // Process response using context....
 delete pc;  // delete context
}

The asynchronous networking libraries are mostly used in multithreaded environments where different states of the requests, responses, and connection events need to be synchronized. In those situations, explicitly deleting the user-data introduces race-conditions, which can lead to memory leaks or even deadlocks. Having an std::shared_ptr as the user-data pointer, as opposed to a raw pointer, is safer because it makes it easier to manage the lifecycle of the associated user-data or context.

A shared_ptr as User-Data Pointer

One way to use std::shared_ptr for user-data is to define an interface that clients can inherit. But that approach is quite intrusive because it forces clients to inherit from a library interface. Instead, we can change the Request class as follows to use std::shared_ptr<void> for user-data:

class Request {
 std::shared_ptr<void> pData;
public:
 //Called by clients to set user-data
 void setUserData(const std::shared_ptr<void>& p) {
   pData = p;
 }
};

The setUserData method is very intuitive. The getUserData in Response is changed to a template for conveneince, which casts and returns the std::shared_ptr<void> to a required type:

class Response {
 std::shared_ptr<void> pData; // Copied from Request
public:
 //Called by clients
 template<typename T>
 auto getUserData() {
  return std::static_pointer_cast<T>(pData);
 }
};

The client-side code is much safer and simpler:

//Create a request and attach user data
Request r;
r.setUserData(std::make_shared<Context>(/*..*/)); // Set a context
// Send request asynchrounously.  

// Response handler callback
void onResponse(Response* pr) {
 auto pc = pr->getUserData<Context>();
 // Process response using context....
 // No explicit context delete here
}

How shared_ptr<void> Deletes a Specific Type

A typical implementation of std::shared_ptr contains two pointers: a stored pointer that is returned by get() and a pointer to the control block. The control block (among other overheads) stores a delete-expression or a deleter. An std::shared_ptr invokes a stored deleter to destroy its managed object when the reference count reaches zero. A custom deleter can be provided as an argument to an std::shared_ptr constructor or a default-deleter is used, which calls delete. These are the two most relevant constructors of std::shared_ptr<T> for our discussion here:

// std::shared_ptr<T> 

// default deleter
template< class Y >
explicit shared_ptr( Y* ptr );

// custom deleter
template< class Y, class Deleter >
shared_ptr( Y* ptr, Deleter d );

The above constructors are templates, which means an std::shared_ptr<T> can be constructed/initialized with a raw pointer of type Y, as long as Y* is convertible to T*. In that case, the get() returns T*, and the deleter calls delete on Y*. Generally, it is said that the deleter of an std::shared_ptr is type-erased. To achieve this type-erasure, the std::shared_ptr stores the deleter as part of its control block.

Therefore, when an std::shared_ptr<void> is constructed with a raw pointer of an arbitrary type Foo, a deleter is created that deletes a Foo* whereas the std::shared_ptr<void>::get returns a void*.

{
  std::shared_ptr<void> vps(new Foo());
  // vps.get() returns void* 
} // Foo's destructor is called here

Final Words

The type-erasure property of std::shared_ptr makes std::shared_ptr<void> an excellent replacement for raw void* as an opaque handle of arbitrary data, mainly, where the resource leak is a more significant concern than the overhead of using smart pointers.

Further Reading 📖

std::shared_ptr: cppreference