RAII π‘ΒΆ
Resulting code: gist
Ever tired of managing the foo.release()
to make sure that it matches the device.createFoo()
and that you donβt have dangling resources? This manual mechanism is required for a C API, but in C++ we can wrap it into a more convenient interface thanks to destructors.
The programming idiom presented here is called RAII, which stands for βResource Acquisition Is Initializationβ; it applies to many other non-WebGPU related contexts!
The idea is to write a C++ class that we force by construction to be such that the underlying resource it represents exists if and only if the instance of the class is still alive.
Motivational examplesΒΆ
FunctionΒΆ
Let us start with some examples, where we assume that we have a class raii::Buffer
that wraps the wgpu::Buffer
resource using the RAII idiom.
void foo(Device device) {
BufferDescriptor bufferDesc = /* ... */;
// Create an instance of our RAII class, which by construction allocates
// a new wgpu::Buffer.
raii::Buffer buffer(device, bufferDesc);
// [...] Do stuff with `buffer`
}
Note how there is no need here to call buffer.release()
: as soon as the variable goes out of scope the buffer it wraps is automatically released.
ClassΒΆ
If we need the buffer to live longer, we can typically define it as a class member:
class Foo {
private:
// Class has a buffer attribute
raii::Buffer m_buffer;
public:
// Constructor must initialize m_buffer
Foo(Device device)
: m_buffer(device, describeBuffer())
{}
void doStuff() {
// [...] Do stuff with `buffer`
}
private:
BufferDescriptor describeBuffer() { /* ... */ }
};
This time, the buffer is released whenever the instance of Foo
is destructed: it automatically calls the destructor of raii::Buffer
, which releases the buffer resource.
Important
Since the raii::Buffer
cannot exist without an underlying buffer, so it must be initialized by the constructor.
Smart pointersΒΆ
Sometimes we really want to be able to store a βnullβ buffer, so the need to initialize it as soon as we define the RAII instance can be annoying. The typical solution is to use the smart pointers provided by the standard library:
#include <memory> // for smart pointers
class Foo {
private:
// Buffer attribute is a unique smart pointer
std::unique_ptr<raii::Buffer> m_buffer;
public:
// No need for a constructor here
void doStuff(Device device) {
if (!m_buffer) initBuffer(device);
// [...] Do stuff with `buffer`
}
private:
void initBuffer(Device device) {
// The make_unique<T> utility function takes the same arguments
// than the constructor of type T.
m_buffer = std::make_unique<raii::Buffer>(device, describeBuffer());
}
BufferDescriptor describeBuffer() { /* ... */ }
};
The smart pointer ensures that the object it points to is destructed when nothing points to it (e.g., when Foo
gets destructed).
Note
The standard type std::optional
can also be an interesting option to store a βmaybe bufferβ.
ImplementationΒΆ
So how do we write this raii::Buffer
wrapper? It looks easy but there are some caveats to avoid!
namespace raii {
// WARNING: This RAII class is not complete.
class Buffer {
public:
// Whenever a RAII instance is created, we create an underlying resource
Buffer(wgpu::Device device, const wgpu::BufferDescriptor& bufferDesc)
: m_raw(device.createBuffer(bufferDesc))
{}
// And whenever it gets destroyed, we free the resource
~Buffer() {
m_raw.destroy();
m_raw.release();
}
private:
// Raw resources that is wrapped by the RAII class
// (Remember that `wgpu::Buffer` is actually just a pointer)
wgpu::Buffer m_raw;
};
// namespace raii
This somehow works⦠until we meet this kind of scheme:
raii::Buffer buffer1;
if (something) {
raii::Buffer buffer2 = buffer1;
}
// Can I still use buffer1 here?
At the end of the if
block, the variable buffer2
goes out of scope, so its m_raw
member is released. Except that it was the same m_raw
as buffer1
due to the =
assignment!
This snippet will actually systematically crash with a double free error because even if you donβt use buffer1
after the if
, whenever it gets out of scope on its turn its destructor attempt to release a second time m_raw
.
Rule of threeΒΆ
There is a general rule of thumb in C++ that accurately applies in our case, namely the Rule of three. It says in short:
If a class needs either a user-defined destructor (
~Foo()
), a user-defined copy assignment operator (operator=(const Foo& other)
or a user-defined copy constructor (Foo(const Foo& other)
), then it very likely need the three of them.
In our case we obviously need a custom destructor, to release the resource, so the rule tells us that we should also manually define how to copy RAII buffers.
// Rule of three
class Buffer {
public:
// We define a destructor...
~Buffer();
// ...so we need a copy assignment operator...
Buffer& operator=(const Buffer& other);
// ...and a copy constructor.
Buffer(const Buffer& other);
// [...]
};
How do we implement these copy operations? We have three options:
Option A: We create a new buffer and copy the content of the previous one.
Option B: We deactivate the possibility to copy buffers.
Option C: We count references.
The problem of Option A is that it turns a seemingly innocent line of code like buffer2 = buffer1
into a time and memory consuming operation. And it requires the buffer object to hold a reference to the Device
object that must be used to create the new buffer.
Option B make things clearer to the user of the API by forcing the use of a more explicit method (e.g., buffer.copyFrom(device, other)
). In this case we simply delete the copy operator/constructor:
// Delete copy semantics
Buffer& operator=(const Buffer& other) = delete;
Buffer(const Buffer& other) = delete;
Option C consists in using a counter to keep track of how many different RAII instances use the same m_raw
, so that we release it only when no body else is using it.
One possibility for this is to implement Option B but then use std::shared_pointer<raii::Buffer>
, because a shared pointer is precisely a (smart) pointer with a reference counter.
Another possibility is to use the buffer.reference()
(or wgpuBufferReference
) procedure to increase an internal counter on the WebGPU backend side.
Rule of fiveΒΆ
Defining custom copy operator/constructor deactivates the automatic creation of move operator/constructor. A variant of the Rule of three, called the Rule of five, states that should also take care of these move semantics.
These defines what happens when we do, among others, buffer2 = std::move(buffer1)
, which means that buffer1
will never be used again. It also enables one to create a function that returns a raii::Buffer
without performing any copy.
In such a case, we can simply move the value of m_raw
from buffer1
to buffer2
, removing it from buffer1
so that it can no longer release it.
Buffer&& operator=(Buffer&& other) {
m_raw = other.m_raw;
other.m_raw = nullptr;
}
Buffer(Buffer&& other) {
m_raw = other.m_raw;
other.m_raw = nullptr;
}
// And we need to slightly change the destructor:
~Buffer() {
if (!m_raw) return;
m_raw.destroy();
m_raw.release();
}
Note
We created a way to have a RAII object that points to no underlying resource, but this is done in a way that follows the C++ lifetime semantics so the compiler can be aware of it.
ConclusionΒΆ
We have seen how to create a RAII wrapper around a WebGPU buffer, and from this one implementing other classes is straightforward (you could even automate it). And keep this design pattern in mind even for other projects, as it is a very common and powerful idiom!
Resulting code: gist