C++ Move-Only Types and the Rule of Five: A Practical Guide
Why Move-Only Types Matter
In high‑performance C++ code you often need objects that own a resource but must never be copied. A classic example is a wrapper around a file descriptor or a GPU buffer. Copying such a handle would duplicate ownership and lead to double‑close bugs. Making the type move‑only forces the compiler to enforce single ownership at compile time.
The Rule of Five in a Nutshell
When a class manages a resource you normally provide five special members: destructor, copy constructor, copy assignment, move constructor, and move assignment. For a move‑only type you delete the copy operations and implement the move operations yourself. The destructor releases the resource.
Real‑World Scenario: A Scoped Thread Pool Handle
Imagine a thread‑pool abstraction that hands out a TaskHandle to the caller. The handle stores an index into the pool’s internal queue. Only the thread that created the task may cancel it, so copying the handle would break that guarantee. By making TaskHandle move‑only you get compile‑time safety and zero‑overhead moves.
Production‑Ready Implementation
class TaskHandle {
public:
// Construction from the pool – only the pool can create a valid handle
explicit TaskHandle(std::size_t idx, ThreadPool* pool) noexcept
: index_(idx), pool_(pool) {}
// Delete copy operations
TaskHandle(const TaskHandle&) = delete;
TaskHandle& operator=(const TaskHandle&) = delete;
// Move constructor – transfers ownership
TaskHandle(TaskHandle&& other) noexcept
: index_(other.index_), pool_(other.pool_) {
other.index_ = invalid_index;
other.pool_ = nullptr;
}
// Move assignment – strong exception guarantee
TaskHandle& operator=(TaskHandle&& other) noexcept {
if (this != &other) {
release();
index_ = other.index_;
pool_ = other.pool_;
other.index_ = invalid_index;
other.pool_ = nullptr;
}
return *this;
}
// Destructor – returns the slot to the pool
~TaskHandle() noexcept {
release();
}
// Public API
bool valid() const noexcept { return pool_ != nullptr; }
void cancel() noexcept {
if (valid()) pool_->cancelTask(index_);
}
private:
static constexpr std::size_t invalid_index = static_cast(-1);
std::size_t index_ = invalid_index;
ThreadPool* pool_ = nullptr;
void release() noexcept {
if (valid()) {
pool_->returnSlot(index_);
index_ = invalid_index;
pool_ = nullptr;
}
}
};
Why This Works
- Deleted copy ops make any attempt to copy a compile error, catching bugs early.
- Move ops are noexcept so containers like
std::vectorcan relocate elements without falling back to copies. - Self‑assignment guard in move assignment prevents double release when a handle is moved onto itself.
- Release logic centralized in a private
release()method avoids duplication and guarantees the resource is returned exactly once.
Common Pitfalls and How to Avoid Them
Forgetting to reset the source object’s members after a move leaves a dangling pointer that the destructor will later try to release. The pattern above sets both index_ and pool_ to sentinel values. Another mistake is making the move constructor explicit; that prevents implicit moves in return statements and forces unnecessary std::move calls. Keep move constructors implicit.
Extending the Pattern
If the managed resource requires a custom deleter (e.g., a Vulkan buffer), store a std::function or a small lambda in the handle and invoke it from release(). The same move‑only skeleton applies, only the cleanup code changes.
Final Thoughts
Move‑only types are a cornerstone of modern C++ resource management. By following the Rule of Five with deleted copies and well‑behaved moves you get deterministic ownership, no hidden copies, and code that scales cleanly across threads. The TaskHandle example is a template you can drop into any codebase that hands out opaque tokens – just replace the pool interaction with your own resource logic.