15

Why when using std::shared_ptr deallocation calls destructors from both base and derived classes when second example calls only destructor from base class?

class Base
{
public:
    ~Base()
    {
        std::cout << "Base destructor" << std::endl;
    }
};

class Derived : public Base
{
public:
    ~Derived()
    {
        std::cout << "Derived destructor" << std::endl;
    }
};

void virtual_destructor()
{
    {
        std::cout << "--------------------" << std::endl;
        std::shared_ptr<Base> sharedA(new Derived);
    }

    std::cout << "--------------------" << std::endl;
    Base * a = new Derived;
    delete a;
}

Output:

--------------------
Derived destructor
Base destructor
--------------------
Base destructor

I was expecting the same behaviour in both cases.

Piotr Wach
  • 913
  • 2
  • 9
  • 22

2 Answers2

20

delete a is undefined behaviour, because the class Base does not have a virtual destructor and the "complete object" of *a (more accurately: the most-derived object containing *a) is not of type Base.

The shared pointer is created with a deduced deleter that deletes a Derived *, and thus everything is fine.

(The effect of the deduced deleter is to say delete static_cast<Derived*>(__the_pointer)).

If you wanted to reproduce the undefined behaviour with the shared pointer, you'd have to convert the pointer immediately:

// THIS IS AN ERROR
std::shared_ptr<Base> shared(static_cast<Base*>(new Derived));

In some sense, it is The Right Way for the shared pointer to behave: Since you are already paying the price of the virtual lookup for the type-erased deleter and allocator, it is only fair that you don't then also have to pay for another virtual lookup of the destructor. The type-erased deleter remembers the complete type and thus incurs no further overhead.

Kerrek SB
  • 464,522
  • 92
  • 875
  • 1,084
  • 1
    *it is only fair that you don't then also have to pay for another virtual lookup of the destructor* The implementation of `shared_ptr` **will** pay the cost of the dynamic dispatch if the destructor is virtual. Cost is *not* the reason why this is done there are other features of shared pointers that would not work if the deleter did not encode the type of the original pointer (which might or not be the type of the complete object), like for example aliasing subobjects, or maintaining shared pointers with different types (base vs. derived). The last paragraph is filled with issues. – David Rodríguez - dribeas Dec 27 '13 at 15:34
  • 1
    BTW, disabling the virtual dispatch inside the deleter would cause undefined behavior in cases like: `struct base { virtual ~base(); }; struct derived : base {}; base *p = new derived; std::shared_ptr sp(p);` and this is probably a common use case (a pointer to an interface is handed into a library that manages it internally via a shared pointer) – David Rodríguez - dribeas Dec 27 '13 at 15:37
  • @DavidRodríguez-dribeas: Maybe I didn't quite make clear that the deleter doesn't *disable* virtual dispatch (which is not possible anyway, for delete expressions), but it gives you the option of not requiring virtual destructors if you only use your hierarchy in shared pointers. – Kerrek SB Dec 27 '13 at 16:04
  • but the call to the deleter delete function is virtual. because it cannot be stored as its concrete type into the shared pointer which is type erased. therefore, we pay virtual call at destruction for any type. and double virtual indirection for type with virtual destructors. one for the deleter operator () (or destruct() function in Matthieu's code) and one further next inside the `delete` operator. – v.oddou Mar 04 '15 at 09:14
  • @v.oddou: Yes, indeed, `shared_ptr` itself always pays the price of type erasure. – Kerrek SB Mar 04 '15 at 09:21
7

A missing piece to Kerrek SB's answer is how does the shared_ptr knows the type ?

The answer is that there are 3 types involved:

  • the static type of the pointer (shared_ptr<Base>)
  • the static type passed to the constructor
  • the actual dynamic type of the data

And shared_ptr does not know of the actual dynamic type, but knows which static type was passed to its constructor. It then practices type-erasure... but remembers somehow the type. An example implementation would be (without sharing):

template <typename T>
class simple_ptr_internal_interface {
public:
    virtual T* get() = 0;
    virtual void destruct() = 0;
}; // class simple_ptr_internal_interface

template <typename T, typename D>
class simple_ptr_internal: public simple_ptr_internal_interface {
public:
    simple_ptr_internal(T* p, D d): pointer(p), deleter(std::move(d)) {}

    virtual T* get() override { return pointer; }
    virtual void destruct() override { deleter(pointer); }

private:
    T* pointer;
    D deleter;
}; // class simple_ptr_internal

template <typename T>
class simple_ptr {
    template <typename U>
    struct DefaultDeleter {
        void operator()(T* t) { delete static_cast<U*>(t); }
    };

    template <typename Derived>
    using DefaultInternal = simple_ptr_internal<T, DefaultDeleter<Derived>>;

public:
    template <typename Derived>
    simple_ptr(Derived* d): internal(new DefaultInternal<Derived>{d}) {}

    ~simple_ptr() { this->destruct(); }

private:
    void destruct() { internal->destruct(); }

    simple_ptr_internal_interface* internal;
}; // class simple_ptr

Note that thanks to this mechanism, shared_ptr<void> is actually meaningful and can be used to carry any data an properly dispose of it.

Note also that there is a penalty involved with this semantics: the need for indirection required for the type-erasure of the deleter attribute.

legends2k
  • 31,634
  • 25
  • 118
  • 222
Matthieu M.
  • 287,565
  • 48
  • 449
  • 722