4

Suppose we have the following class template:

template<typename T>
class Object
{
public:
    Object() = default;
    Object(const Object&) = delete;
    Object(Object&& other) noexcept
    {
        if (this != &other)
        {
            static_cast<T*>(this)->Release();
            m_Id = std::exchange(other.m_Id, 0);
        }
    }

    auto operator=(const Object&) = delete;
    Object& operator=(Object&& other) noexcept
    {
        if (this != &other) {
            static_cast<T*>(this)->Release();
            m_Id = std::exchange(other.m_Id, 0);
        }

        return *this;
    }

    ~Object()
    {
        static_cast<T*>(this)->Release();
        m_Id = 0;
    }

protected:
    std::uint32_t m_Id;
};

(Please ignore the duplication in the move constructor and move assignment operator for the moment)

This class is meant to act as a base class for OpenGL object wrappers. Following is an example use:

class VertexBuffer : public Object<VertexBuffer>
{
public:
    VertexBuffer()
    {
        glGenBuffers(1, &m_Id);
        ...
    }

    void Release()
    {
        glDeleteBuffers(1, &m_Id);
    }
};

The Object<T> class template is supposed to take care of the bookkeeping.

The reason for doing this is that the pattern in the Object<T> class is repeated the exact same way for (almost) every OpenGL object wrapper that might be written. The only difference is the creation and deletion of the objects which is handled by the constructor and the Release() function in this example.

Now the question is whether this (Object<T>::~Object() to be specific) taps into UB land? Undefined Behavior Sanitizer doesn't report any errors but I've never done this, so I though of asking people with more experience to make sure.

nullptr
  • 68
  • 6
  • 3
    Is there a reason you don't use virtual functions and polymorphism? – Some programmer dude Sep 05 '22 at 06:38
  • 1
    @Someprogrammerdude Because the behavior is known at compile-time. How would polymorphism help? Also the objects are not really meant to be used through references/pointers to the `Object` class template. The template is simply meant to save me from copy-pasting the exact same piece of code dozens of times. – nullptr Sep 05 '22 at 06:46
  • 6
    At the time you call the base class destructor the derived object no longer exists, therefore you have UB – john Sep 05 '22 at 06:49
  • @john But the destructor doesn't access any member variables of the derived class (assuming that `Release()` does not!), and [member functions are not stored per instance](https://stackoverflow.com/a/15572492/17710400). How would it be UB? – nullptr Sep 05 '22 at 06:54
  • 2
    Calling a method on an object which does not exist is UB. You might have an argument that *in practice* there would not be a problem, but this is still UB. – john Sep 05 '22 at 06:56
  • Alright, makes sense. Would it be a better idea to push the "Release" part down the hierarchy and let the derived classes handle the destruction part? So, leaving everything as-is except with `Object::~Object` defaulted and let the derived classes implement the logic? – nullptr Sep 05 '22 at 07:01
  • @nullptr Seems reasonable to me, but you're in the best position to judge. – john Sep 05 '22 at 07:03
  • Alright. I'll do that and see how things go. Feel free to make your comments into an answer in case others have a similar query. – nullptr Sep 05 '22 at 07:19
  • Hang on! Are you saying there's a common pattern of generating and releasing but it may not always be buffers but some other resource type? You haven't provided 2 examples so we're left guessing what is common and what is variable. Or is it all classes generate buffers but potential different (but fixed) numbers of buffers? – Persixty Sep 05 '22 at 11:50
  • 1
    @Persixty Most OpenGL resources are handled through IDs (`GLuint`s) and not concrete types. The only difference is the allocation and deallocation functions (`gl(Gen|Delete)Buffers` for buffer objects. `gl(Gen|Delete)Textures` for textures, `gl(Gen|Delete)FrameBuffers` for framebuffers, etc.). (no-)Copying and Moving is the same for this type of resources. So yes, there is a common pattern and the classes don't necessarily need to be handles to buffer objects. – nullptr Sep 06 '22 at 02:33
  • 1
    @nullptr That's what I thought but it's not in the question. I've not use OpenGL but Windows API is similar with lots of `CreateXYZ()` functions and a generic `DeleteObject()`. Your question doesn't fully explain that's the challenge. One option not yet offered is to just make the Create()/Destroy() members `static` taking `m_Id` as a parameter. Or even the whole object. – Persixty Sep 06 '22 at 06:41
  • 1
    @Persixty Making the member functions static and passing the IDs around sounds like the closest thing to what I'm trying to achieve. Only the `Destroy()` part is necessary, though. As `Object` doesn't really care how the GL object is created, that's up to the specific wrapper. The `Destory()` part is necessary in order to implement the "move" special members, however. – nullptr Sep 06 '22 at 14:56

4 Answers4

3

Don't do that. That'll cause an undefined behavior.

Instead, implement the template class as a derived class, like the following example.

class BufferGrandBase {
protected:
    GLuint id;
};

template<class B>
class Buffer : public B {
public:
    Buffer() {
        B::Create();
    }
    ~Buffer() {
        B::Destroy();
    }
};

class VertexBufferBase : public BufferGrandBase {
public:
    void Create() { glGenBuffers(1, &id); }
    void Destroy() { glDeleteBuffers(1, &id); }
};
typedef Buffer<VertexBufferBase> VertexBuffer;

This pattern will also simplify implementing constructors and operators.

relent95
  • 3,703
  • 1
  • 14
  • 17
3

Short answer: Yes, this is undefined behaviour, don't do that.

Long answer:
The destruction of VertexBuffer invokes first ~VertexBuffer() and then invokes ~Object<VertexBuffer>() afterwards. When ~Object<VertexBuffer>() is invoked the VertexBuffer "part" of the object has already been destroyed, i.e. you are now doing an illegal downcast via static_cast (the remaining valid part of the object is a Object<VertexBuffer>, not a VertexBuffer).

And undefined behaviour permits the compiler to do ANYTHING - it might even (appear to) work, only to suddenly stop working (or only work when you build in Debug mode, but not when in Release). So, for your own sake, please don't do that.

CharonX
  • 2,130
  • 11
  • 33
2

If you have a "thing" that holds an Object<T> where T is the Crtp-type, that "thing" is likely a template anyway.

So instead of holding an Object<T>, why don't you just hold a T, which is the full type that inherits from Object<T>. If that is destroyed it will call T::~T() automatically.

In addition, perhaps you want to do private or protected inheritance from Object<T> to discourage users from slicing the Crtp-type.


Edit: You can simplify your Ts by moving the actual work into Object<T>:

class VertexBuffer : public Object<VertexBuffer>
{
public:
    VertexBuffer() : Object<VertextBuffer>(glGenBuffers, glDeleteBuffers, 1) {
        ... // more stuff you did in your original constructor
    }
};

Where Object looks like this in addition to what you already have:

template <typename T>
class Object {
  void (*release)(int, std::uint32_t*);
  int i;

  Object() = delete;
  Object(void (*acquire)(int, std::uint32_t*), void (*release)(int, std::uint32_t*), int i = 1) : release{release}, i{i} {
    acquire(i, &m_Id);
  }
  ~Object() {
    release(i, &m_Id);
  }

  // rest of your Object class
};
bitmask
  • 32,434
  • 14
  • 99
  • 159
  • By "thing" are you referring to client code? The code that'll end up using these wrappers? Because nothing really holds an `Object` (Well, except the classes inheriting from it). Client code is meant to use the wrappers (`VertexBuffer` in the provided example) directly. The reason for having the `Release()` function not be part of the destructor is that `Object` needs to know how to release the GL Object for when implementing the move constructor and move assignment operator, since the old object needs to be released. `private` inheritance is a good idea, though. I'll do that for sure! – nullptr Sep 06 '22 at 03:20
  • @nullptr If nobody holds a `Object`, why are you worried about `~Object()`, then? If you are holding a `T` in the first place, `~T()` will be called when it is destroyed. Why not just call your cleanup code there? – bitmask Sep 06 '22 at 09:39
  • That will work. However, the point of the `Object` template is to reduce duplication, hence why I'm looking for a way to stuff all of this logic there. Defining a custom destructor that's nothing more than `T::~T() { Release(); }` copy-pasted over and over for every `T` would defeat that purpose. – nullptr Sep 06 '22 at 13:52
  • @nullptr Ah, now I get it. Please see the latest edit with an Idea how to accomplish this and even have less typing for `T`. – bitmask Sep 06 '22 at 16:23
  • I thought of doing it that way and it works for some objects, but unfortunately, it won't for others. This is mainly due to inconsistencies in the API. Although resources are handled using `GLuint`-based IDs, the allocation and deallocation functions don't always share a similar signature. That's [not hard to solve](https://godbolt.org/z/6zsrqbsxT) but at that point I might as well follow @Persixty's suggestion and [use static members instead](https://godbolt.org/z/dPvcjqTjc). That has the added benefit that the "release" function is per class (as it should be) and not per instance. – nullptr Sep 07 '22 at 06:34
2

This is close to the model of std::unique_ptr<> with a custom deleter. But it doesn't quite fit because std::unique_ptr has to hold a pointer and this case needs an integer handle-type.

So here is a brief example. It is general good advice to prefer inclusion over inheritance so I have deliberately placed the helper object inside the owning class.

All I'm really trying to demonstrate here is that there are a number of ways to customise behaviours in templates beyond call members of instances.

#include <iostream>
#include <memory>

typedef size_t gd_size;
typedef int gd_handle;

void gen_buffers(gd_size size,gd_handle*buffs);
void del_buffers(gd_size size,gd_handle*buffs);

typedef void (*gd_func)(gd_size,gd_handle*buff);

template<gd_func gfunc,gd_func dfunc> class GenDel{
    public:
        GenDel(){gfunc(1,&buff);}
        ~GenDel(){dfunc(1,&buff);}
        int get()const{return buff;}
    private:
    int buff;
    
    GenDel(const GenDel&)=delete;
};

class BufferHolder{
public:      

   BufferHolder(){}
   
   void do_thing() const{
       std::cout<<"using "<<buffer.get()<<'\n';
   }
   
 private:
    GenDel<gen_buffers,del_buffers> buffer;
};

int main() {
    BufferHolder b;
    BufferHolder c;

    b.do_thing();
    
    return 0;
}

int seed{0};

void gen_buffers(gd_size size,gd_handle*buffs){
    for(size_t i{0};i<size;++i){
        buffs[i]=++seed;
        std::cout << "generated "<< buffs[i] << '\n';
    }
}

void del_buffers(gd_size size,gd_handle*buffs){
        for(gd_size i{0};i<size;++i){
        std::cout << "deleted "<< buffs[i] << '\n';
    }
}
Persixty
  • 8,165
  • 2
  • 13
  • 35