Solving the Copyability Dilemma: Owning vs. Non-Owning Resource Types in C++
The Dilemma: When a Type is Sometimes Owning, Sometimes a Reference
In systems programming and API wrapper design, managing low-level handles (such as CUDA events, Vulkan objects, OpenGL handles, or OS file descriptors) is a common challenge. Often, developers want a single convenient wrapper class that can either:
- Own the resource: Allocate or create the underlying handle and destroy it upon scope exit (acting as a RAII value type).
- Borrow the resource: Wrap an existing handle created elsewhere without destroying it when out of scope (acting as a non-owning reference type).
While bundling both behaviors into a single class seems user-friendly at first, it creates a tricky architectural dilemma around copy semantics. If your class dynamically tracks ownership via an internal flag, how should copy construction and copy assignment behave?
The Traps of a Single Dynamic Ownership Class
Before jumping into solutions, let us examine why making a single type switch dynamically between value semantics and reference semantics leads to subtle bugs:
- Move-Only Semantics: Making the wrapper move-only (similar to
std::unique_ptr) preserves strict ownership safety. However, it severely degrades the developer experience for borrowed handles, requiring cumbersome move mechanics or raw reference passing for objects that could otherwise be trivially copyable. - Copies Become Non-Owning: If you allow copies but mark copies as non-owning, you invite accidental lifetime bugs. Simple standard algorithms like
std::generate()or passing by value can strip the ownership from the original resource unintentionally, causing premature resource destruction. - Shared Ownership (Reference Counting): Replicating resources or introducing an internal
std::shared_ptror custom atomic counter incurs heap allocation, synchronization overhead, and potential performance degradation. More importantly, it imposes shared-ownership semantics onto APIs that may not support them natively.
The Modern C++ Idiomatic Solution: The "Owner + View" Pattern
The standard C++ philosophy (as documented in the C++ Core Guidelines) recommends keeping ownership explicit. Mixing owning and non-owning semantics in the same runtime type violates the Single Responsibility Principle and introduces runtime branching into what should ideally be a zero-cost abstraction.
Instead of trying to force both behaviors into one type, follow the standard C++ model established by std::string and std::string_view, or std::vector and std::span: separate the owning type from the non-owning view, linked via implicit conversion.
Why This Solves the Ergonomics Problem
Separating the types does not have to compromise user convenience. If your non-owning type provides an implicit constructor accepting the owning type, function interfaces can simply accept the view by value.
#include <utility> // For std::exchange
// Native API simulated declarations
using native_handle_t = int;
native_handle_t create_handle();
void destroy_handle(native_handle_t h);
// 1. Non-owning View / Reference Type
// Trivially copyable, does not release resources
class event_view {
public:
constexpr event_view(native_handle_t handle = 0) noexcept
: handle_(handle) {}
[[nodiscard]] native_handle_t native_handle() const noexcept {
return handle_;
}
[[nodiscard]] explicit operator bool() const noexcept {
return handle_ != 0;
}
private:
native_handle_t handle_;
};
// 2. Owning Resource Type
// Move-only, enforces RAII lifetime
class event {
public:
event() : handle_(create_handle()) {}
~event() {
if (handle_) {
destroy_handle(handle_);
}
}
// Disable copy semantics to prevent double-free
event(const event&) = delete;
event& operator=(const event&) = delete;
// Enable move semantics
event(event&& other) noexcept
: handle_(std::exchange(other.handle_, 0)) {}
event& operator=(event&& other) noexcept {
if (this != &other) {
if (handle_) destroy_handle(handle_);
handle_ = std::exchange(other.handle_, 0);
}
return *this;
}
// Implicit conversion to non-owning view
operator event_view() const noexcept {
return event_view(handle_);
}
[[nodiscard]] native_handle_t native_handle() const noexcept {
return handle_;
}
private:
native_handle_t handle_;
};How Users Interact With This API
With this setup, the API offers complete safety and optimal ergonomic convenience:
// Functions accept views by value (cheap, register-sized)
void inspect_event(event_view ev) {
if (ev) {
// Inspect or trigger native handle
}
}
void example() {
// Owning case: creates and cleans up safely
event my_event;
inspect_event(my_event); // Implicit conversion to event_view
// Borrowed/wrapped case: trivial view construction
native_handle_t foreign_handle = 42;
event_view foreign_view(foreign_handle);
inspect_event(foreign_view); // Direct pass-through
}Alternative: Tagged Move-Only Wrapper with a Custom Deleter
If business constraints strictly mandate a single type across the entire library, the safest design is to make the class move-only and store a custom deleter (or ownership flag):
class event_t {
public:
// Owning factory
static event_t create() {
return event_t(create_handle(), true);
}
// Non-owning factory
static event_t wrap(native_handle_t handle) {
return event_t(handle, false);
}
~event_t() {
if (is_owning_ && handle_) {
destroy_handle(handle_);
}
}
// Disallow copies to prevent ownership bugs
event_t(const event_t&) = delete;
event_t& operator=(const event_t&) = delete;
// Move operations preserve the ownership state
event_t(event_t&& other) noexcept
: handle_(std::exchange(other.handle_, 0)),
is_owning_(std::exchange(other.is_owning_, false)) {}
private:
event_t(native_handle_t h, bool owning)
: handle_(h), is_owning_(owning) {}
native_handle_t handle_;
bool is_owning_;
};While this avoids creating two types, it sacrifices the ability to pass reference objects by value or store them in copyable containers.
Conclusion: Best Practices
Attempting to make a single type behave conditionally as both an owning value and a lightweight reference breaks fundamental C++ invariants. The cleanest, zero-overhead, and most idiomatic solution is to:
- Use an owning, move-only RAII type (e.g.,
event) for lifecycle management. - Use a trivially copyable view type (e.g.,
event_view) for borrowed observation. - Provide an implicit conversion from the owner to the view so consumer functions only need to accept the view.