A few years ago, I was fortunate enough to have Jon Kalb explain move semantics to me. This answered an annoyance I’ve had with C++ for over a decade.
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 temporary 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 (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 still has the unique_ptr<>
fluff, but now the destructor is called once, just the way we want. Solved, right?
Well, 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 actually got really close. 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 make a copy of the object, 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
We’ve just invented a move-only object. C++11 gave us move semantics to do exactly this, but combined with an ‘rvalue’ concept to help distinguish between intentional copying and moving. To make it work we have to have a way for the destructor to know whether an object is null or not. Another bool would work, but a value like ‘nullptr’ can work too. For buffers we can use 0
. Lets update the destructor to check for 0
and add 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 null ("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.