To store a destructor

[edited to add notes and apply Howard Hinnant’s de-escalation to static_cast]

After my talk on Friday, a couple of people asked me how I was storing destructors in my gcpp library. Since several people are interested, I thought I’d write a note.

The short answer is to store two raw pointers: one to the object, and one to a type-erased destructor function that’s handy to write using a lambda.

In deferred_heap.h, you’ll see that the library stores a deferred destructor as:

struct destructor {
    const void* p;
    void(*destroy)(const void*);
};

So we store two raw pointers, a raw pointer to the object to be destroyed and a raw function pointer to the destructor. Then later we can just invoke d.destroy(d.p).

The most common question is: “But how can you get a raw function pointer to the destructor?” I use a lambda:

// Called indirectly from deferred_heap::make<T>.
// Here, t is a T&.
dtors.push_back({
    std::addressof(t), // address of object
    [](const void* x) { static_cast<const T*>(x)->~T(); }
});                    // dtor to invoke

A non-capturing lambda has no state so it can be used as a plain function, and because we know T here we just write a lambda that performs the correct cast back to invoke the correct T destructor. So for each distinct type T that this is instantiated with, we generate one T-specific lambda function (on demand at compile time, globally unique) and we store that function’s address.

Notes:

  • The lambda gives a handy way to do type erasure in this case. One line removes the type, and the other line adds it back.
  • Yes, it’s legal to call the destructor on a const object. It has to be; we have to be able to destroy const objects. Const never applies to a constructor or destructor, it applies to all the other member functions.
  • Some have asked what the body of the wrapper lambda generates. I looked at the code gen in Clang a couple of weeks ago, and depending on the optimization level, the lambda is generated as either a one-instruction function (just a single jmp to the actual destructor) or as a copy of the destructor if it’s inlined (no run-time overhead at all, just another inline copy of the destructor in the binary if it’s generally being inlined anyway).
  • Because we are storing the destructor at the same time as we are constructing the T object, we know the complete object’s type is T and therefore can easily store the correct destructor for the complete object. Later, regardless of whether the list deferred_ptr keeping it alive is a deferred_ptr<T> or something else, such as a deferred_ptr<const T> or deferred_ptr<BaseClass> or deferred_ptr<TDataMember>, the correct T destructor will be called to clean up the complete object.

I’ve now added a version of this to the gcpp readme FAQ section.

14 thoughts on “To store a destructor

  1. @codant I’m not sure that involves any less cheating (or that there is any cheating), and in both cases we are storing a pointer and a destructor function, so we have the same space footprint. However, doing it the way you suggest erases both the destructor and the object pointer — making the latter also be invisible loses the ability to later query the object pointer to determine if the object lives in a given memory allocation. Also I think the code is simpler the way it is in the post and in the repo.

  2. @Vishal We store the destructor corresponding to the constructor, and therefore to the complete object. The correct complete-object destructor will be called even if the last deferred_ptr to the object is to a base or member of the object. I’ve added a fourth note bullet to mention this.

  3. My question was about inheritance and if the have deferred_ptr of type base but pass an object of type derived that is inherited from base when we call the destructor which destructor are we calling the base class’s or the derived class’s destructor because for this to work we would need to call the derived class’s destructor or else we have resource leaks for the object the derived object used.

    Have is a example

    class base
    {
    ...
    };
    
    class  derived : public base
    {
    ...
    };
    
    deferred_ptr<base> ptr(new derived); //does this calls the bases of the derived classes destructor?
    
  4. why not the old fashion way which involves less cheating:

    
    template<typename T>
    void dtor_(void const* ptr)
    { reinterpret_cast<const T*>(ptr)->~T(); };
    
    typedef decltype(std::bind(dtor_<int>,(void*)nullptr)) destructor; 
    
    dtors.push_back(std::bind(dtor_<T>,std::addressof(t)));
    
    for(auto d:dtors)
         d();//invoke all dtors
    
    [code]
    
    or the more conservative implementation:
    
    [code]
    
    typedef std::tuple<void const & , void(*)(void const & )> destructor; 
    
    template<typename T>
    void dtor_(T & ref)
    { ref->~std::remove_reference_t< std::remove_cv_t<T> > (); };
    
    dtors.push_back(reinterpret_cast<destructor & > (std::tie(t,&dtor_<decltype(t)>)));
    
    for(auto d:dtors)
         d.second(d.first);//invoke all dtors
    
    
  5. @Vishal I didn’t quite parse your question, but the key is that we are constructing a T object and know the correct type of the complete object, so we store that destructor. Regardless of whether the last deferred_ptr keeping it alive is a deferred_ptr or something else, such as a deferred_ptr or deferred_ptr or deferred_ptr, the correct T destructor will be called to clean up the complete object. I’ll add a note about this to the post. Thanks.

  6. Maybe this is a stupid question but “should you store the exact object type because if the object was an inherited type then would calling the destructor for the base only invokes the bases destructor and not the object’s destructor or would declaring the destructor as virtual would allow the correct destructor through a lookup in the v-table?”

  7. @Greg: vector<T, deferred_allocator<T>> can be useful for other types, though the following is a more speculative use case right now: Notably, with a deferred_allocator, the memory stays live even when the container is no longer using it as long as some iterator is, and so simply dereferencing an invalidated iterator is type- and memory-safe (a stale-data problem rather than undefined behavior); however, incrementing/navigating using an invalidated iterator is no better than today. Check out this section of gcpp readme: https://github.com/hsutter/gcpp#speculative-stl-iterator-safety .

  8. I just watched the talk, and I was reminded of a question I had the first time I read through the slides. Is the deferred_vector only intended to store deferred_ptr objects, or can it be used for other things? If it’s only for deferred_ptr, would it be useful to make this explicit and have it be deferred_vector being std::vector<deferred_ptr, deferred_allocator<deferred_ptr>>? I was thinking that might not be immediately obvious as containing pointers, so maybe deferred_ptr_vector.

  9. @Greg: Yes, I run across emplace’s lack of support for aggregate init occasionally but regularly.

    @Howard: Well, nobody wants to be lumped in with a visual like that… fixed.

  10. “I think you’d have to write a constructor”

    I keep forgetting, until I write it and the compiler yells at me, that emplace_back doesn’t work with aggregate initialization. Is that something that could possibly be fixed someday?

    “just to be able to make the add-a-destructor call site slightly longer as well”

    If you normally put spaces inside the braces, it’s actually one character shorter… ;)

    push_back({ foo, bar });
    emplace_back(foo, bar);
    

    I can’t wait to see this talk get posted. I always enjoy your talks. I miss C++ and Beyond. The timing of CppCon just hasn’t worked for me. :)

  11. `static_cast` can be used in place of `reinterpret_cast` here. Same functionality. It is not a bug to use `reinterpret_cast` here. Just it is nice to softly state that you are changing types, as opposed to screaming it while holding a gun to a puppy’s head.

  12. Thanks Herb, FYI this confused me greatly, as when trying it out I wrote this…

    // From Herb Sutter type erasing a destructor to call it later...
    class destructor
    {
      const void* p;
      void(*destroy)(const void*);
    public:
      destructor() : p(nullptr), destroy([](const void*){}) {}
      destructor(const destructor& rhs) : p(rhs.p), destroy(rhs.destroy) {}
      
      template <typename T>
      destructor(T& t) : p{std::addressof(t)}, destroy{[](const void* x) { if (x) reinterpret_cast<const T*>(x)->~T();}} {}
      
      void operator()() const { destroy(p); }
    };
    
    class A
    {
    public:
      A() { std::cout << "A being constructed\n"; }
      ~A() { std::cout << "A being destructed\n"; }
    };
    
    int main()
    {
      A a;
      std::vector<destructor> destructors;
      destructors.emplace_back(a);
      
      for (auto x : destructors)
        x();
      return 0;
    }
    

    Because I was missing the ref-qualifier on x it really screwed me up because it called my template constructor to incorrectly copy x from the vector. Once I either added the ref-qualifier there or removed the const qualifier on rhs for my copy constructor things worked much more as expected.

  13. @Greg: I do use emplace in other places, but here I think you’d have to write a constructor on “destructor” to make emplace work == an extra line of code just to be able to make the add-a-destructor call site slightly longer as well. This seemed simpler.

  14. Is there any difference here between dtors.push_back({obj, func}) and dtors.emplace_back(obj, func)?

Comments are closed.