Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

RAII Wrappers and Ownership Transfer for Non-Memory Resources

Hello! Last lesson, you traced an object’s lifetime separately from the pointer or view that refers to it. This lesson uses that distinction to solve a practical problem: a Linux file descriptor must be closed, but the integer holding it can be copied or left behind without the compiler noticing.

You will implement a small RAII type that owns one descriptor, closes it when its lifetime ends, and transfers ownership safely when moved. Allow about 40–45 minutes, including the selected resource segments.


Make cleanup follow the owner’s lifetime

A resource is something you acquire and must later release. For a file descriptor, the pair is typically open and close. Manual pairing becomes fragile as soon as a function has an early return or throws:

int fd = ::open(path, O_RDONLY);
if (fd == -1) {
    // Handle failure.
}

// An early return or exception here could skip close(fd).
::close(fd);

RAII puts the responsibility into an object: successful acquisition establishes an owner, and destroying that owner performs cleanup. The owner can be a local variable, but RAII is not limited to local variables; what matters is that the owning object itself has a well-defined lifetime.

Read the C++ Core Guidelines’ short explanation and contrasting examples.

R: Resource management

Read rule R.1 in the C++ Core Guidelines to see why acquisition and release should be paired by an object rather than by instructions scattered through a function.

In “R.1: Manage resources automatically using resource handles and RAII,” start at the “Reason” paragraph beginning “To avoid leaks and the complexity of manual resource management.” Read the comparison through the paragraph immediately after the good send example. Focus on what happens to the lock and port if execution leaves the function unexpectedly; you can stop before the subsequent Port class definition.

Andre Kostur’s Back to Basics: RAII in C++ develops the same idea with a mutex. The important observation is that adding a missing unlock fixes one exit path but does not protect the next exit path someone adds.

Back to Basics: RAII in C++ - Andre Kostur - CppCon 2022

Watch the opening RAII demonstration from Andre Kostur’s CppCon talk. It connects manual cleanup failures to the lifetime rules from the previous lesson.

Watch manual cleanup for the resource definition and the mutex example with early returns and exceptions. Then watch scope-based cleanup for the replacement using std::lock_guard. Notice that the guard’s destructor, rather than every branch of the function, is responsible for unlocking.

There is one crucial step beyond “put close in a destructor”: decide which object owns the descriptor. If two objects each believe they own the same descriptor, both will close it. If neither believes it owns it, the descriptor leaks.


Build a unique owner for a Linux file descriptor

A Linux file descriptor is a nonnegative integer referring to an entry in a process’s descriptor table. open returns -1 on failure; zero is a valid descriptor. We will use -1 to mean “this wrapper owns nothing.”

Here is a complete C++20 wrapper. The public adopt operation is deliberately named: passing it an integer means handing over responsibility for closing an already-owned descriptor.

#include <cassert>
#include <cerrno>
#include <fcntl.h>
#include <system_error>
#include <utility>
#include <unistd.h>

class UniqueFd {
public:
    UniqueFd() noexcept = default; // Empty: owns no descriptor.

    [[nodiscard]] static UniqueFd adopt(int owned_fd) noexcept {
        // Precondition: owned_fd is an open descriptor whose ownership
        // the caller is transferring to this wrapper.
        assert(owned_fd >= 0);
        return UniqueFd{owned_fd};
    }

    [[nodiscard]] static UniqueFd open_read_only(const char* path) {
        const int fd = ::open(path, O_RDONLY);
        if (fd == -1) {
            throw std::system_error(
                errno, std::generic_category(), "open");
        }
        return adopt(fd);
    }

    ~UniqueFd() noexcept {
        reset();
    }

    UniqueFd(const UniqueFd&) = delete;
    UniqueFd& operator=(const UniqueFd&) = delete;

    UniqueFd(UniqueFd&& other) noexcept
        : fd_(other.release()) {}

    UniqueFd& operator=(UniqueFd&& other) noexcept {
        if (this != &other) {
            reset();                 // Close what *this previously owned.
            fd_ = other.release();   // Take ownership; empty other.
        }
        return *this;
    }

    [[nodiscard]] int get() const noexcept {
        return fd_; // Borrowed handle; ownership stays here.
    }

    [[nodiscard]] explicit operator bool() const noexcept {
        return fd_ >= 0;
    }

    [[nodiscard]] int release() noexcept {
        return std::exchange(fd_, -1); // Caller takes ownership.
    }

    void reset() noexcept {
        const int old = std::exchange(fd_, -1);
        if (old >= 0) {
            (void)::close(old);
        }
    }

private:
    explicit UniqueFd(int fd) noexcept : fd_(fd) {}

    int fd_ = -1;
};

The invariant is compact: fd_ >= 0 means this object alone is responsible for closing that descriptor; fd_ == -1 means it is empty. The destructor checks the invariant, so destroying a default-constructed or moved-from wrapper is harmless. It must test >= 0, not > 0.

open_read_only also establishes a clean failure boundary. If open fails, no descriptor was acquired and no UniqueFd owner is returned. If it succeeds, the returned wrapper owns the descriptor. The wrapper’s constructor and adopt do not throw, so there is no intervening operation here that can strand a successfully opened descriptor.


Move responsibility without duplicating it

Suppose the compiler generated a copy constructor for UniqueFd. It would copy fd_ as an ordinary integer. That would not create a second independently owned descriptor: two wrappers would hold the same descriptor number, and the first destructor would close it out from under the second. A later close could even affect an unrelated resource if that number had been reused. That is why both copy operations are deleted.

A move has a different meaning. It transfers the duty to close:

#include <utility>

UniqueFd source = UniqueFd::open_read_only("/dev/null");
const int number = source.get();

UniqueFd destination = std::move(source);

// destination owns number; source is empty.
assert(destination.get() == number);
assert(!source);

std::move(source) does not itself change source; it enables the move constructor to run. The constructor calls source.release(), which obtains the integer and sets source to -1. When source is eventually destroyed, it therefore does not close destination’s descriptor.

Move assignment has one extra responsibility. If the destination already owns a descriptor, it must close that old descriptor before taking the source’s:

UniqueFd first  = UniqueFd::open_read_only("/dev/null");
UniqueFd second = UniqueFd::open_read_only("/dev/null");

second = std::move(first);
// second's former descriptor has been closed.
// second owns first's former descriptor; first is empty.

The self-assignment check avoids closing the descriptor in fd = std::move(fd). Both move operations are noexcept: transferring these integer handles does not require allocation or a throwing cleanup operation.

For a broader look at the choices behind a custom RAII type, return to Kostur’s talk:

Back to Basics: RAII in C++ - Andre Kostur - CppCon 2022

Watch the design discussion in Back to Basics: RAII in C++. Use it to compare the decisions in UniqueFd with other possible resource wrappers.

Watch wrapper design for empty states, adopting resources, copy and move policy, and access to the underlying handle. Then watch releasing ownership for why an explicit release operation deserves care. Ask, in particular, which of those operations merely exposes a handle and which changes who must clean it up.

The distinction between the two accessors is worth keeping sharp:

Operation What the caller receives Who must eventually close it?
get() A borrowed descriptor number The UniqueFd owner
release() The descriptor with ownership relinquished The recipient
Move construction or assignment An owning UniqueFd The destination wrapper

For example, ::read(fd.get(), buffer, size) can use the borrowed descriptor while fd stays alive; the caller must not close that borrowed number. Conversely, this explicitly hands ownership to another wrapper:

UniqueFd next = UniqueFd::adopt(destination.release());
// destination is empty; next will close the descriptor.

An independently created UniqueFd::adopt(destination.get()) would be a bug: get() did not surrender ownership. The type prevents accidental copies of owners, but a raw-handle adoption API still has a contract its callers must respect.


Know what the wrapper guarantees

This wrapper ensures that an owned descriptor is submitted to close when its owner is destroyed or reset, including on ordinary early returns and during stack unwinding from an exception. It also makes ownership transfer explicit and prevents ordinary copying. It does not make a borrowed integer safe after its owner closes or moves away—the previous lesson’s lifetime reasoning still applies.

There is also a deliberate limit to this example: reset() cannot report a close error because it is used by a non-throwing destructor. For a write path where reporting a delayed I/O failure matters, the interface needs an explicit, error-reporting close policy rather than pretending that destruction proves data was persisted. On Linux, do not blindly retry a failed close: the descriptor number may already have been released and reused.

Finally, check for an existing RAII type before writing one. std::lock_guard owns a lock for a scope, and std::unique_ptr with a custom deleter can manage a pointer-shaped resource such as a FILE*. A POSIX descriptor is an integer handle, so a small dedicated type gives it a clearer interface than treating the integer as a heap-allocated pointer.


Conclusion

RAII works when the cleanup obligation belongs to an object with a clear lifetime. For UniqueFd, the essential rules are: acquire into an owner, close only when nonempty, forbid copying, and empty the source when transferring ownership. Move assignment must also dispose of the destination’s previous resource. get() borrows; release() hands over responsibility.

The next lesson will widen this ownership question from one OS handle to object graphs: when should an object be held by value, owned uniquely, shared, or observed without ownership?

Can't find a good explanation? Sign up and we'll make it for you

Sign up