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_ptr or 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.