Why Do Std::Shared_Ptr≪Void≫ Work

Why do std::shared_ptr void work

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.

Why is shared_ptr void legal, while unique_ptr void is ill-formed?

It is because std::shared_ptr implements type-erasure, while std::unique_ptr does not.


Since std::shared_ptr implements type-erasure, it also supports another interesting property, viz. it does not need the type of the deleter as template type argument to the class template. Look at their declarations:

template<class T,class Deleter = std::default_delete<T> > 
class unique_ptr;

which has Deleter as type parameter, while

template<class T> 
class shared_ptr;

does not have it.

So, why does shared_ptr implement type-erasure?

Well, it does so, because it has to support reference-counting, and to support this, it has to allocate memory from heap and since it has to allocate memory anyway, it goes one step further and implements type-erasure — which needs heap allocation too. So basically it is just being opportunist!

Because of type-erasure, std::shared_ptr is able to support two things:

  • It can store objects of any type as void*, yet it is still able to delete the objects on destruction properly by correctly invoking their destructor.
  • The type of deleter is not passed as type argument to the class template, which means a little bit freedom without compromising type-safety.

Alright. That is all about how std::shared_ptr works.

Now the question is, can std::unique_ptr store objects as void*? Well, the answer is, yes — provided you pass a suitable deleter as argument. Here is one such demonstration:

int main()
{
auto deleter = [](void const * data ) {
int const * p = static_cast<int const*>(data);
std::cout << *p << " located at " << p << " is being deleted";
delete p;
};

std::unique_ptr<void, decltype(deleter)> p(new int(959), deleter);

} //p will be deleted here, both p ;-)

Output (online demo):

959 located at 0x18aec20 is being deleted

You asked a very interesting question in the comment:

In my case I will need a type erasing deleter, but it seems possible as well (at the cost of some heap allocation). Basically, does this mean there is actually a niche spot for a 3rd type of smart pointer: an exclusive ownership smart pointer with type erasure.

to which @Steve Jessop suggested the following solution,

I've never actually tried this, but maybe you could achieve that by using an appropriate std::function as the deleter type with unique_ptr? Supposing that actually works then you're done, exclusive ownership and a type-erased deleter.

Following this suggestion, I implemented this (though it does not make use of std::function as it does not seem necessary):

using unique_void_ptr = std::unique_ptr<void, void(*)(void const*)>;

template<typename T>
auto unique_void(T * ptr) -> unique_void_ptr
{
return unique_void_ptr(ptr, [](void const * data) {
T const * p = static_cast<T const*>(data);
std::cout << "{" << *p << "} located at [" << p << "] is being deleted.\n";
delete p;
});
}

int main()
{
auto p1 = unique_void(new int(959));
auto p2 = unique_void(new double(595.5));
auto p3 = unique_void(new std::string("Hello World"));
}

Output (online demo):

{Hello World} located at [0x2364c60] is being deleted.
{595.5} located at [0x2364c40] is being deleted.
{959} located at [0x2364c20] is being deleted.

Hope that helps.

Why does the std::shared_ptr work?

Re

why does the solution with std::shared_ptr work properly?

Because you have undefined behavior, and UB includes that what you hoped would happen, happens. It's UB because an object created with a new[] expression needs to be destroyed with a delete[] expression. The shared_ptr instead destroys, by default, via a delete expression.

This is irrespective of involving a DLL or not.


Re

What does [use of shared_ptr] change?

In the DLL scenario it makes it possible to bundle a deleter function that invokes the DLL-specific deallocation function.

However, you would still have a potential problem due to allocation of the shared_ptr's control block. Whether this manifests as an actual problem depends on your build setup (e.g. shared runtime library?) and on which toolchain you're using.


Re

And what are those "memory managers" (mentioned in the thread linked above), which are different within a single process

Presumably the runtime in each DLL.

If all DLLs as well as the main program are linked against a single common DLL runtime, not a static library runtime, then all are using the same shared memory management, and that part is OK.

How does shared_ptr void know which destructor to use?

The shared state co-owned by shared pointers also contains a deleter, a function like object that is fed the managed object at the end of its lifetime in order to release it. We can even specify our own deleter by using the appropriate constructor. How the deleter is stored, as well as any type erasure it undergoes is an implementation detail. But suffice it to say that the shared state contains a function that knows exactly how to free the owned resource.

Now, when we create an object of a concrete type with make_shared<Thing>() and don't provide a deleter, the shared state is set to hold some default deleter that can free a Thing. The implementation can generate one from the template argument alone. And since its stored as part of the shared state, it doesn't depend on the type T of any shared_pointer<T> that may be sharing ownership of the state. It will always know how to free the Thing.

So even when we make voidPtr the only remaining pointer, the deleter remains unchanged, and still knows how to free a Thing. Which is what it does when the voidPtr goes out of scope.

Shared void pointers. Why does this work?

The shared_ptr constructor that you use is actually a constructor template that looks like:

template <typename U>
shared_ptr(U* p) { }

It knows inside of the constructor what the actual type of the pointer is (X) and uses this information to create a functor that can correctly delete the pointer and ensure the correct destructor is called. This functor (called the shared_ptr's "deleter") is usually stored alongside the reference counts used to maintain shared ownership of the object.

Note that this only works if you pass a pointer of the correct type to the shared_ptr constructor. If you had instead said:

SharedVoidPointer sp1(static_cast<void*>(x));

then this would not have worked because in the constructor template, U would be void, not X. The behavior would then have been undefined, because you aren't allowed to call delete with a void pointer.

In general, you are safe if you always call new in the construction of a shared_ptr and don't separate the creation of the object (the new) from the taking of ownership of the object (the creation of the shared_ptr).

Why is shared_ptr void not specialized?

shared_ptr<T> is special in that it is by design allowed to hold a pointer to any pointer type which is convertible to T* and will use the proper deleter without UB! This comes into play with shared_ptr<Base> p(new Derived); scenarios, but also includes shared_ptr<void>.

For example:

#include <boost/shared_ptr.hpp>

struct T {
T() { std::cout << "T()\n"; }
~T() { std::cout << "~T()\n"; }
};


int main() {
boost::shared_ptr<void> sp(new T);
}

produces the output:

$ ./test
T()
~T()

If you visit http://www.boost.org/doc/libs/1_47_0/libs/smart_ptr/shared_ptr.htm, scroll down to the assignment section to see the very thing being demonstrated. See http://www.boost.org/doc/libs/1_47_0/libs/smart_ptr/sp_techniques.html#pvoid for more details.

EDIT as noted by trinithis, it is UB if the pointer type passed into the constructor is a void * pointer. Thanks for pointing that out!

How to do function overloading with std::shared_ptr void and another type of std::shared_ptr?

I'm confused but I try an explanation.

I see that your lambda can be accepted by both std::function<void(std::shared_ptr<void>)> and std::function<void(std::shared_ptr<int>)>; you can verify that both the following lines compile

std::function<void(std::shared_ptr<void>)>  f0 = [](std::shared_ptr<void>){};
std::function<void(std::shared_ptr<int>)> f1 = [](std::shared_ptr<void>){};

And this is because (I suppose) a shared pointer to int can be converted to shared pointer to void; you can verify that the following line compile

std::shared_ptr<void> sv = std::shared_ptr<int>{};

At this point we can see that calling

c.F([](std::shared_ptr<void>) {});

you don't pass a std::function<void(std::shared_ptr<void>)> to F(); you're passing an object that can be converted to both std::function<void(std::shared_ptr<void>)> and std::function<void(std::shared_ptr<int>)>; so an object that can be used to call both versions of F().

So the ambiguity.

Is there any way to workaround this ambiguity? Perhaps with SFINAE?

Maybe with tag dispatching.

You can add an unused argument and a template F()

void F (std::function<void(std::shared_ptr<void>)>, int)
{ std::cout << "void version" << std::endl; }

void F (std::function<void(std::shared_ptr<int>)>, long)
{ std::cout << "int version" << std::endl; }

template <typename T>
void F (T && t)
{ F(std::forward<T>(t), 0); }

This way calling

c.F([](std::shared_ptr<void>) {});
c.F([](std::shared_ptr<int>){});

you obtain "void version" from the first call (both non-template F() matches but the "void version" is preferred because 0 is a int) and "int version" from the second call (only the F() "int version" matches).



Related Topics



Leave a reply



Submit