Why do std::shared_ptr<void> work
I found some code using std::shared_ptr to perform arbitrary cleanup at shutdown. At first I thought this code could not possibly work, but then I tried the following:
#include <memory>
#include <iostream>
#include <vector>
class test {
public:
test() {
std::cout << "Test created" << std::endl;
}
~test() {
std::cout << "Test destroyed" << std::endl;
}
};
int main() {
std::cout << "At begin of main.\ncreating std::vector<std::shared_ptr<void>>"
<< std::endl;
std::vector<std::shared_ptr<void>> v;
{
std::cout << "Creating test" << std::endl;
v.push_back( std::shared_ptr<test>( new test() ) );
std::cout << "Leaving scope" << std::endl;
}
std::cout << "Leaving main" << std::endl;
return 0;
}
This program gives the output:
At begin of main.
creating std::vector<std::shared_ptr<void>>
Creating test
Test created
Leaving scope
Leaving main
Test destroyed
I have some ideas on why this might work, that have to do with the internals of std::shared_ptrs as implemented for G++. Since these objects wrap the internal pointer together with the counter the cast from std::shared_ptr<test>
to std::shared_ptr<void>
is probably not hindering the call of the destructor. Is this assumption correct?
And of course the much more important question: Is this guaranteed to work by the standard, or might further changes to the internals of std::shared_ptr, other implementations actually break this code?
The trick is that std::shared_ptr
performs type erasure. Basically, when a new shared_ptr
is created it will store internally a deleter
function (which can be given as argument to the constructor but if not present defaults to calling delete
). When the shared_ptr
is destroyed, it calls that stored function and that will call the deleter
.
A simple sketch of the type erasure that is going on simplified with std::function, and avoiding all reference counting and other issues can be seen here:
template <typename T>
void delete_deleter( void * p ) {
delete static_cast<T*>(p);
}
template <typename T>
class my_unique_ptr {
std::function< void (void*) > deleter;
T * p;
template <typename U>
my_unique_ptr( U * p, std::function< void(void*) > deleter = &delete_deleter<U> )
: p(p), deleter(deleter)
{}
~my_unique_ptr() {
deleter( p );
}
};
int main() {
my_unique_ptr<void> p( new double ); // deleter == &delete_deleter<double>
}
// ~my_unique_ptr calls delete_deleter<double>(p)
When a shared_ptr
is copied (or default constructed) from another the deleter is passed around, so that when you construct a shared_ptr<T>
from a shared_ptr<U>
the information on what destructor to call is also passed around in the deleter
.
shared_ptr<T>
logically[*] has (at least) two relevant data members:
- a pointer to the object being managed
- a pointer to the deleter function that will be used to destroy it.
The deleter function of your shared_ptr<Test>
, given the way you constructed it, is the normal one for Test
, which converts the pointer to Test*
and delete
s it.
When you push your shared_ptr<Test>
into the vector of shared_ptr<void>
, both of those are copied, although the first one is converted to void*
.
So, when the vector element is destroyed taking the last reference with it, it passes the pointer to a deleter that destroys it correctly.
It's actually a little more complicated than this, because shared_ptr
can take a deleter functor rather than just a function, so there might even be per-object data to be stored rather than just a function pointer. But for this case there is no such extra data, it would be sufficient just to store a pointer to an instantiation of a template function, with a template parameter that captures the type through which the pointer must be deleted.
[*] logically in the sense that it has access to them - they may not be members of the shared_ptr itself but instead of some management node that it points to.
It works because it uses type erasure.
Basically, when you build a shared_ptr
, it passes one extra argument (that you can actually provide if you wish), which is the deleter functor.
This default functor accepts as argument a pointer to type you use in the shared_ptr
, thus void
here, casts it appropriately to the static type you used test
here, and calls the destructor on this object.
Any sufficiently advanced science feels like magic, isn't it ?
The constructor shared_ptr<T>(Y *p)
indeed seems to be calling shared_ptr<T>(Y *p, D d)
where d
is an automatically generated deleter for the object.
When this happens the type of the object Y
is known, so the deleter for this shared_ptr
object knows which destructor to call and this information is not lost when the pointer is the stored in a vector of shared_ptr<void>
.
Indeed the specs require that for a receving shared_ptr<T>
object to accept a shared_ptr<U>
object it must be true that and U*
must be implicitly convertible to a T*
and this is certainly the case with T=void
because any pointer can be converted to a void*
implicitly. Nothing is said about the deleter that will be invalid so indeed the specs are mandating that this will work correctly.
Technically IIRC a shared_ptr<T>
holds a pointer to an hidden object that contains the reference counter and a pointer to the actual object; by storing the deleter in this hidden structure it's possible to make this apparently magic feature working while still keeping shared_ptr<T>
as big as a regular pointer (however dereferencing the pointer requires a double indirection
shared_ptr -> hidden_refcounted_object -> real_object