Recommended resources
The freely available resources this lesson will direct you to.
Hello! Last lesson established a reusable Engine library with separate editor, sandbox, and test executables. The next question is what happens inside that library when an engine object owns something that must eventually be released.
This lesson is about making that responsibility explicit. You’ll use RAII for automatic cleanup, choose between unique, shared, and non-owning access, and add a small ownership test to the project you already built. The example uses simulated texture resources; no graphics API is needed yet. Allow about 40–45 minutes, including the videos and implementation.
Tie cleanup to an object’s lifetime
An engine accumulates resources with very different lifetimes: a temporary upload buffer might exist for one operation, a texture might remain in use by several scene objects, and a renderer might hold an operating-system or graphics handle for most of the application’s life. Each resource needs a clear answer to two questions: Who releases it? When does that owner cease to exist?
Resource Acquisition Is Initialization (RAII) ties release to the lifetime of a C++ object. An owning object acquires or receives a resource when it becomes valid and releases it in its destructor. The important benefit is not merely shorter code: cleanup still occurs when a function returns early or an exception unwinds its scope.
Back to Basics: RAII in C++ - Andre Kostur - CppCon 2022
Watch “Back to Basics: RAII in C++” from CppCon for the connection between object lifetimes and reliable cleanup.
In scope cleanup, follow how a lock guard removes the need to place an unlock call on every exit path. The same reasoning will apply to the engine resources below.
RAII is broader than smart pointers. A std::vector manages its allocation, and a lock guard manages a mutex lock. Use an ordinary value member when an object naturally belongs inside another object; use a smart pointer when its lifetime or location calls for dynamic allocation. Not every engine object needs to be heap-allocated.
RAII also does not decide dependencies for you. If a graphics texture needs a graphics device during its release, the device must remain alive until the texture has been released. Ownership makes cleanup dependable only after you choose a valid lifetime order.
Start with one owner
std::unique_ptr<T> says that one pointer owns a dynamically allocated T. You cannot copy that ownership, but you can move it. Prefer std::make_unique<T>(...) to construct an ordinary C++ object under unique ownership.
Back to Basics: Smart Pointers and RAII - Inbal Levi - CppCon 2021
Watch the unique_ptr portion of CppCon’s “Back to Basics: Smart Pointers and RAII” for early-return cleanup and ownership transfer.
Watch unique ownership. Focus on why a copy is rejected and what changes when ownership is moved. After moving from a std::unique_ptr, the source pointer is empty; do not dereference it.
A useful engine rule is to make unique ownership the default when a resource needs dynamic allocation. A short-lived staging object can belong to the operation using it; a subsystem can hold a unique_ptr to an object it alone controls.
Function signatures can express the distinction between using and taking a resource:
void inspect(const TextureResource& texture); // Borrow; do not retain.
void install(std::unique_ptr<TextureResource> texture); // Take ownership.
A caller can pass *owned to inspect and keep ownership. Passing std::move(owned) to install transfers it; afterward, owned is empty. A reference—or a raw pointer for an optional borrow—is appropriate when the function does not retain or release the object. That borrow must not outlive its owner.

Match the wrapper to the release operation
A smart pointer’s default cleanup for a C++ object created with new is deletion. An operating-system or library resource may instead require a particular release function. Do not put such a handle into a default unique_ptr and assume delete is correct.
Back to Basics: RAII in C++ - Andre Kostur - CppCon 2022
Return to “Back to Basics: RAII in C++” for an example of adapting RAII to a resource with its own release function.
In custom disposal, follow the FILE* example through its custom deleter and factory function. The talk briefly shows a C++20 lambda form; your project uses C++17, so use the named deleter form illustrated below.
For example, a C file is closed with std::fclose, not delete:
#include <cstdio>
#include <memory>
#include <stdexcept>
struct FileCloser {
void operator()(std::FILE* file) const noexcept {
if (file) {
std::fclose(file);
}
}
};
using File = std::unique_ptr<std::FILE, FileCloser>;
File open_read_only(const char* path) {
std::FILE* raw = std::fopen(path, "rb");
if (!raw) {
throw std::runtime_error("Could not open file");
}
return File(raw);
}
The factory checks acquisition before handing the resource to its owner. A call such as file.get() may be needed to pass a borrowed FILE* to a C function; the receiving function must not close or retain it. For a writable file, cleanup alone is not a substitute for explicitly checking operations whose errors matter, such as writing and closing: a destructor is not a good place to report a recoverable failure.
Later, Windows handles and graphics interfaces will need release operations appropriate to their APIs. Today’s principle is enough: the owner’s cleanup must match how the resource was acquired.
Share only when lifetimes genuinely overlap
Suppose the engine caches a texture while two scene objects also need to keep using it. std::shared_ptr<T> represents that specific arrangement: the cache and the two users can each own the same object. It is destroyed when the last shared owner relinquishes it—not necessarily when the cache removes its entry.
std::weak_ptr<T> is an observer of an object managed by shared pointers. It does not extend the object’s lifetime. Call lock() when you need to use it; a successful lock gives you a temporary shared_ptr that keeps the object alive for that use.
Back to Basics: Smart Pointers and RAII - Inbal Levi - CppCon 2021
Return to “Back to Basics: Smart Pointers and RAII” for reference-counted ownership and weak observation.
Watch shared ownership for the control block and the warning against independently wrapping one raw pointer twice. Continue with weak access, concentrating on lock() rather than checking expiration and assuming the object will remain available.
Copy an existing shared_ptr to add an owner; never create two independent shared_ptr instances from the same raw owning pointer. Also, reference counting protects the lifetime of the object, not unsynchronized changes to the object itself. We will address thread ownership and data races in the concurrency module.
Shared ownership has a cost and a design consequence: it can make shutdown timing less obvious. Use it when users really must keep a resource alive independently of its original container. A weak reference is useful for an observer that must not do so. A weak pointer cannot observe an object owned only by unique_ptr; in that case, arrange a bounded borrow or a different handle design.
Add a lifetime test to your engine
The following small model lets EngineTests check destruction without requiring a window or GPU. It deliberately counts simulated resource creation and release; it is not a texture implementation.
Add engine/include/engine/ResourceOwnership.hpp:
#pragma once
#include <memory>
#include <string>
#include <unordered_map>
namespace engine {
struct LifetimeCounts {
int created = 0;
int destroyed = 0;
};
class TextureResource {
public:
TextureResource(std::string name, LifetimeCounts& counts)
: name_(std::move(name)), counts_(&counts) {
++counts_->created;
}
~TextureResource() {
++counts_->destroyed;
}
TextureResource(const TextureResource&) = delete;
TextureResource& operator=(const TextureResource&) = delete;
const std::string& name() const noexcept { return name_; }
private:
std::string name_;
LifetimeCounts* counts_; // Borrowed; counts must outlive this resource.
};
class TextureCache {
public:
std::shared_ptr<TextureResource> load(
const std::string& name, LifetimeCounts& counts) {
auto found = textures_.find(name);
if (found != textures_.end()) {
return found->second;
}
auto texture = std::make_shared<TextureResource>(name, counts);
textures_.emplace(name, texture);
return texture;
}
void unload(const std::string& name) {
textures_.erase(name);
}
private:
std::unordered_map<std::string,
std::shared_ptr<TextureResource>> textures_;
};
} // namespace engine
Add #include <utility> above the namespace as well: std::move is declared there. In engine/CMakeLists.txt, add include/engine/ResourceOwnership.hpp to the existing PUBLIC FILE_SET HEADERS list. The existing engine target and its one-way dependencies remain unchanged.
The cache owns each stored resource. Returning a shared_ptr gives a caller independent ownership. Erasing the cache entry removes one owner, not every owner. LifetimeCounts is borrowed instrumentation: the test must keep it alive longer than all resources that refer to it. This is the kind of lifetime condition a raw, non-owning pointer does not enforce.
Replace tests/src/main.cpp with:
#include <engine/Engine.hpp>
#include <engine/ResourceOwnership.hpp>
#include <iostream>
#include <memory>
#include <utility>
int main() {
if (engine::core_revision() != 1) {
std::cerr << "Engine smoke test failed\n";
return 1;
}
engine::LifetimeCounts unique_counts;
{
auto owned = std::make_unique<engine::TextureResource>(
"temporary", unique_counts);
const engine::TextureResource& borrowed = *owned;
if (borrowed.name() != "temporary") return 1;
auto new_owner = std::move(owned);
if (owned || !new_owner) return 1;
}
if (unique_counts.created != 1 ||
unique_counts.destroyed != 1) {
std::cerr << "Unique lifetime test failed\n";
return 1;
}
engine::LifetimeCounts shared_counts;
std::weak_ptr<engine::TextureResource> observer;
{
engine::TextureCache cache;
auto first = cache.load("grass", shared_counts);
auto second = cache.load("grass", shared_counts);
observer = first;
if (first != second || shared_counts.created != 1) {
return 1;
}
auto pinned = observer.lock();
if (!pinned) return 1;
cache.unload("grass");
first.reset();
second.reset();
if (shared_counts.destroyed != 0) return 1;
pinned.reset();
if (!observer.expired() || shared_counts.destroyed != 1) {
std::cerr << "Shared lifetime test failed\n";
return 1;
}
}
return 0;
}
Build and run CTest as before:
cmake --build build --config Debug
ctest --test-dir build -C Debug --output-on-failure
Notice what the second test establishes. unload does not destroy "grass" while callers own it. Even after those callers reset their pointers, pinned—obtained through observer.lock()—keeps it alive. Resetting that final owner releases it, while the weak observer becomes expired. Keep the explicit failure checks: unlike a check relying solely on the C++ assert macro, they still run in Release builds.
One final design constraint matters beyond this test: shared pointers can form ownership cycles. If two objects each hold a shared_ptr to the other, neither reaches zero owners. A back-reference or observer that does not need to retain its target should be weak rather than shared.
Takeaways
RAII attaches resource cleanup to an owning object’s lifetime, including early exits and exception unwinding. Prefer values where possible and std::unique_ptr when one dynamically allocated owner is needed; use references or raw pointers for clearly bounded borrows. Choose std::shared_ptr only for genuinely overlapping ownership, and std::weak_ptr for observation without retention. For non-memory resources, make sure the wrapper calls the correct release operation and that dependent objects remain alive in the right order.
Next, you’ll add logging, assertions, and development-build debug behavior. Those facilities will help expose failed acquisitions and violated lifetime assumptions as the engine begins managing real Windows resources.