Home » C++ » Why is shared_ptr<void> legal, while unique_ptr<void> is ill-formed?

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

Posted by: admin November 29, 2017 Leave a comment

Questions:

The question really fits in the title: I am curious to know what is the technical reason for this difference, but also the rationale ?

std::shared_ptr<void> sharedToVoid; // legal;
std::unique_ptr<void> uniqueToVoid; // ill-formed;
Answers:

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.

Now the question is, 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,

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

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

template<typename T>
auto unique_void(T * ptr) -> unique_void_ptr
{
    return unique_void_ptr(ptr, &deleter<T>);
}

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.

Questions:
Answers:

One of the rationales is in one of the many use-cases of a shared_ptr – namely as a lifetime indicator or sentinel.

This was mentioned in the original boost documentation:

auto register_callback(std::function<void()> closure, std::shared_ptr<void> pv)
{
    auto closure_target = { closure, std::weak_ptr<void>(pv) };
    ...
    // store the target somewhere, and later....
}

void call_closure(closure_target target)
{
    // test whether target of the closure still exists
    auto lock = target.sentinel.lock();
    if (lock) {
        // if so, call the closure
        target.closure();
    }
}

Where closure_target is something like this:

struct closure_target {
    std::function<void()> closure;
    std::weak_ptr<void> sentinel;
};

The caller would register a callback something like this:

struct active_object : std::enable_shared_from_this<active_object>
{
    void start() {
      event_emitter_.register_callback([this] { this->on_callback(); }, 
                                       shared_from_this());
    }

    void on_callback()
    {
        // this is only ever called if we still exist 
    }
};

because shared_ptr<X> is always convertible to shared_ptr<void>, the event_emitter can now be blissfully unaware of the type of object it is calling back into.

This arrangement releases subscribers to the event emitter of the obligation of handling crossing cases (what if the callback in on a queue, waiting to be actioned while active_object goes away?), and also means that there is no need to synchronise unsubscription. weak_ptr<void>::lock is a synchronised operation.