For years C++ destructors felt like an academic solution that didn’t fit a lot of real problems. Then I was fortunate enough to have Jon Kalb explain move semantics to me and a decade long annoyance was resolved. Many thanks to friends and coleagues for reviewing and improving this.
As a graphics programmer, I started with this:
GLuint myBuffer;
glGenBuffers(1, &myBuffer);
...
glDeleteBuffers(1, &myBuffer);
Standard C. But C++ is meant to be object oriented. Can I make a Buffer class?
struct Buffer {
Buffer() {
glGenBuffers(1, &m_buffer);
}
~Buffer() {
glDeleteBuffers(1, &m_buffer);
}
GLuint m_buffer = 0;
};
Yes! This is great. Now I don’t have to remember to call delete, the code is clean and short:
struct Mesh {
Buffer indices;
Buffer positions;
};
Mesh loadObj(const char* filename)
{
...
Buffer indicesBuf = toBuffer(indices);
Buffer positionsBuf = toBuffer(positions);
return Mesh{indicesBuf, positionsBuf};
}
Can you spot the bug? The temporary buffers in loadObj() will have their destructors called at the end of the function. The caller will be left with dangling buffer handles in the Mesh. After seeing this I simply stopped using destructors. I suspect many others did too, before C++11. Even after, we’re all still stuck in this mindset that destructors are dangerous and have weird side effects.
A seeming workaround is to create the object on the heap. Then you’re just moving around a pointer to it and you should only call the destructor once.
Mesh* loadObj(const char* filename)
{
...
Buffer* indicesBuf = toBuffer(indices);
Buffer* positionsBuf = toBuffer(positions);
return new Mesh{indicesBuf, positionsBuf};
}
Mesh* mesh = loadObj("teapot.obj");
... do something with mesh->indices->m_buffer
delete mesh->indices;
delete mesh->positions;
delete mesh;
😂 don’t leave. It’s a joke. Of course we’d use unique_ptr
for this (second joke). To those screaming: bear with me; this is going somewhere.
struct Mesh {
std::unique_ptr<Buffer> indices;
std::unique_ptr<Buffer> positions;
};
Mesh loadObj(const char* filename)
{
...
std::unique_ptr<Buffer> indicesBuf = toBuffer(indices);
std::unique_ptr<Buffer> positionsBuf = toBuffer(positions);
return Mesh{std::move(indicesBuf), std::move(positionsBuf)};
}
Mesh mesh = loadObj("teapot.obj");
... do something with mesh.indices->m_buffer
We can even modify Buffer so that we don’t accidentally copy it and get hit by the double-delete bug from before:
struct Buffer {
...
Buffer(const Buffer&) = delete;
Buffer& operator=(const Buffer&) = delete;
};
It looks a bit better. It has some unique_ptr<>
fluff, but now the destructor is called once, just the way we want. Solved, right?
Not quite. There’s a lot more pointer chasing happening now. The CPU will load the memory for Mesh
but then has to follow the indices
and positions
pointers to read the GLuint m_buffer
from each. Oh, and now we’re doing a pile of tiny heap allocations for every unique_ptr
. This is actually quite bad for performance if we’re creating a lot of buffers (ignoring the fact OpenGL buffers themselves are expensive). Clearly C++ destructors just can’t be used efficiently. We should all return to calling a free()
function manually /s.
We’re actually getting really close to a great solution. unique_ptr
is already doing what we need, but very indirectly. I included it because it’s so frequently used for this exact case, when people want a move-only object but can’t be bothered or haven’t experienced how to write one. What we want to do is make sure that there is only one valid GLuint m_buffer
for a conceptual object at any moment. E.g. if we copy the GLuint
memory, mark the copied-from object as null so that the destructor won’t delete it. We still call the destructor multiple times, but only one call will actually do the deleting.
Buffer src = ...;
Buffer dst(src); // mark 'src' as null so we only delete one of them
Lets first expand what this should do and then factor it.
dst.m_buffer = src.m_buffer;
src.m_buffer = 0; // now 'src' can know not to call glDeleteBuffers()
...
~Buffer() {
if(m_buffer != 0)
glDeleteBuffers(1, &m_buffer);
}
We’ve just invented a move-only object. C++11 gave us move semantics to help do exactly this, combined with an “rvalue” concept to help distinguish between intentional copying and moving. For example, the Buffer&& other
parameter below indicates other
will not be used by the caller again. Ownership is being transferred, e.g. it’s a temporary, return value or has been explicitly cast with std::move()
.
Buffer(Buffer&& other)
{
m_buffer = other.m_buffer;
other.m_buffer = 0; // mark other as "moved-from"
}
In general, to make it work we have to have a way for the destructor to know whether an object is valid or not.
- One example is
std::optional
which simply has an internalbool
to know if the object is “engaged”. - A special value like ‘nullptr’ can work too, which is what
std::unique_ptr
uses.
For GL buffers we can use 0
because that’s not a valid result of glGenBuffers()
. Lets combine everything: the destructor to check for 0
and a move constructor that sets the src
buffer to 0
. For completeness we also need a move assignment operator. It’s nearly the same, but it needs to handle freeing an existing resource first before moving from the other object.
struct Buffer {
Buffer() {
glGenBuffers(1, &m_buffer);
}
~Buffer() {
// only delete if not null ("moved-from")
if (m_buffer != 0)
glDeleteBuffers(1, &m_buffer);
// don't set 'm_buffer = 0' - wasteful
}
Buffer(Buffer&& other)
: m_buffer(other.m_buffer) // regular copy
{
other.m_buffer = 0; // mark other as null ("moved-from")
}
Buffer& operator=(Buffer&& other)
{
// beware: moving to self will fail - could check 'this == &other', but costs
// free the existing buffer if not moved-from
// could factor with the destructor into a private free() method
if (m_buffer != 0)
glDeleteBuffers(1, &m_buffer);
m_buffer = other.m_buffer; // regular copy
other.m_buffer = 0; // mark other as null ("moved-from")
}
// prevent copying
// could implement, but it's rare to want to create two identical OpenGL buffers
Buffer(const Buffer&) = delete;
Buffer& operator=(const Buffer&) = delete;
GLuint m_buffer = 0;
};
Now what happens if we use this for Mesh
?
struct Mesh {
Buffer indices;
Buffer positions;
};
Mesh loadObj(const char* filename)
{
...
Buffer indicesBuf = toBuffer(indices);
Buffer positionsBuf = toBuffer(positions);
return Mesh{std::move(indicesBuf), std::move(positionsBuf)};
}
Mesh mesh = loadObj("teapot.obj");
... do something with mesh.indices.m_buffer
The Mesh
object is now literally just two integers. A mere 8 bytes in memory. No pointer chasing, no heap allocations. The compiler can often remove all the shuffling operations such as copying the int, setting other’s to null, checking it before deleting. You get the same code as if you’d done it in C but with less lines and, most importantly, less chance for mistakes.
Did you notice that Mesh
is now back to having just two buffers? It doesn’t need a destructor!!! Once your “leaf” objects are copy and move safe, any composed objects do not need any extra code. They just work 🤤.
We’ve just encountered the rules of 3, 5, and zero:
- Initially we had a bug because we added a destructor without implementing (or deleting) the copy constructor and assignment operator — the rule of 3
- We then added a move constructor and move assignment operator — the rule of 5
- Now we can use that object without implementing any of them — the rule of zero ✨
This has been an introduction to RAII. Continuing this convention can have some powerful consequences for object dependencies, lifetime and modular software design.