Create your own
Lesson illustration

Eliminating Resource Leaks with RAII and Smart Pointers

Hello. In the previous lesson, you separated an object’s storage duration from its scope and learned why a local raw pointer and the dynamically allocated object it points to have different lifetimes. That distinction explains the classic leak: the local pointer dies at scope exit, but its dynamically allocated pointee does not.

This lesson turns that diagnosis into a design technique. You will use RAII and std::unique_ptr to repair a leaking C++ component so that cleanup occurs on every exit path, including return and exceptions. This is a core habit for reliable systems code: make ownership explicit, then make cleanup automatic.


From “remember to clean up” to RAII

Consider a component that opens a conceptual connection, uses it, and is supposed to close it afterward:

bool publish(std::string_view message) {
    Connection* connection = new Connection{"audit-db"};

    if (message.empty()) {
        return false;  // leak
    }

    if (message == "FAIL") {
        throw std::runtime_error{"publishing failed"};  // leak
    }

    connection->send(message);

    delete connection;  // reached only on the success path
    return true;
}

The problem is not that delete is missing entirely. It is present, but cleanup has been placed at one particular point in control flow. Every later return, exception, or newly added branch must remember to reach that point. In a real component, this becomes fragile quickly.

RAII stands for Resource Acquisition Is Initialization. Its central design is:

  1. Put responsibility for a resource into an object.
  2. Acquire the resource when that object is constructed.
  3. Release the resource when that object is destroyed.

The prior lesson established the key guarantee that makes this work: automatic local objects are destroyed whenever their scope is exited, including during exception unwinding.

For a dynamically allocated object, std::unique_ptr<T> is the standard RAII owner. It is itself an automatic local object, while the object it manages has dynamic storage duration. When the unique_ptr is destroyed, it destroys the managed object too.

The diagram shows exclusive ownership of one object: after ownership moves from Pointer 1 to Pointer 2, Pointer 1 no longer owns or may use the object as its managed resource.

A unique_ptr therefore answers an important design question directly:

Which object is responsible for eventually destroying this dynamically allocated resource?

Exactly one unique_ptr has that responsibility at a time.


A short visual introduction

Watch the opening of unique_ptr: C++'s simplest smart pointer by mCoding. It illustrates why raw-pointer cleanup in a collection is error-prone, then refactors the design around unique ownership.

unique_ptr: C++'s simplest smart pointer

Watch unique_ptr: C++'s simplest smart pointer by mCoding for a compact visual account of the ownership problem and the unique_ptr solution.

Watch raw pointer risks to see how leaks and double deletion arise when raw pointers act as owners. Then watch the unique refactor, focusing on the distinction between an owning unique_ptr and code that merely accesses an object through a pointer or reference.

One subtle but important point from the video: unique ownership is not exclusive access. Many parts of a program can temporarily use an object through references or non-owning pointers. What must be unique is the responsibility for ending that object’s lifetime.


Lab: repair a leaking component

Create a file named raii.cpp in WSL. This small Connection class prints acquisition and release events so that lifetime behavior is visible.

1. Reproduce the leak

Start with the raw-pointer version:

#include <iostream>
#include <stdexcept>
#include <string_view>

class Connection {
public:
    explicit Connection(std::string_view endpoint)
        : endpoint_(endpoint) {
        std::cout << "acquire connection to " << endpoint_ << '\n';
    }

    ~Connection() {
        std::cout << "release connection to " << endpoint_ << '\n';
    }

    void send(std::string_view message) const {
        std::cout << "send: " << message << '\n';
    }

private:
    std::string_view endpoint_;
};

bool publish(std::string_view message) {
    Connection* connection = new Connection{"audit-db"};

    if (message.empty()) {
        return false;
    }

    if (message == "FAIL") {
        throw std::runtime_error{"publishing failed"};
    }

    connection->send(message);

    delete connection;
    return true;
}

int main() {
    publish("");

    try {
        publish("FAIL");
    } catch (const std::runtime_error& error) {
        std::cout << "caught: " << error.what() << '\n';
    }

    publish("completed");
}

Build it with warnings and sanitizers:

g++ -std=c++20 -Wall -Wextra -Wpedantic -g -O0 \
    -fsanitize=address,undefined -fno-omit-frame-pointer \
    raii.cpp -o raii_san

ASAN_OPTIONS=detect_leaks=1 ./raii_san

The exact wording and addresses in the sanitizer report depend on your setup. The important observations are:

  • Each call to publish constructs a Connection.
  • Only the "completed" path reaches delete connection.
  • The empty-message path and exception path skip delete.
  • LeakSanitizer should report two unreleased allocations.

Notice what is not leaked: the raw pointer variable connection itself is automatic and disappears at function exit. The leaked object is the dynamic Connection allocated by new.


The RAII refactor with std::unique_ptr

Now include <memory> and replace the owning raw pointer with a unique_ptr created by std::make_unique:

#include <iostream>
#include <memory>
#include <stdexcept>
#include <string_view>

class Connection {
public:
    explicit Connection(std::string_view endpoint)
        : endpoint_(endpoint) {
        std::cout << "acquire connection to " << endpoint_ << '\n';
    }

    ~Connection() {
        std::cout << "release connection to " << endpoint_ << '\n';
    }

    void send(std::string_view message) const {
        std::cout << "send: " << message << '\n';
    }

private:
    std::string_view endpoint_;
};

bool publish(std::string_view message) {
    auto connection = std::make_unique<Connection>("audit-db");

    if (message.empty()) {
        return false;
    }

    if (message == "FAIL") {
        throw std::runtime_error{"publishing failed"};
    }

    connection->send(message);
    return true;
}

int main() {
    publish("");

    try {
        publish("FAIL");
    } catch (const std::runtime_error& error) {
        std::cout << "caught: " << error.what() << '\n';
    }

    publish("completed");
}

Build and run the same command again:

g++ -std=c++20 -Wall -Wextra -Wpedantic -g -O0 \
    -fsanitize=address,undefined -fno-omit-frame-pointer \
    raii.cpp -o raii_san

ASAN_OPTIONS=detect_leaks=1 ./raii_san

Now every acquire connection message should have a corresponding release connection message, including for the empty message and "FAIL" cases. The leak report should be gone.

The refactor changed the lifetime model, not merely the spelling of delete.

Event in publishWhat happens with raw Connection*What happens with std::unique_ptr<Connection>
Normal completionExplicit delete is requiredunique_ptr is destroyed at scope exit
Early returndelete is skipped unless manually addedunique_ptr is destroyed automatically
Exceptiondelete is skipped unless manually handledStack unwinding destroys the unique_ptr
Future branch addedA developer must remember cleanupCleanup remains tied to scope exit

Under the hood, the essential events are:

  1. std::make_unique<Connection>("audit-db") constructs a Connection dynamically and immediately gives its ownership to a unique_ptr.
  2. connection is an automatic local variable.
  3. When publish exits by any route, C++ destroys connection.
  4. The unique_ptr destructor invokes its deleter, which by default performs the equivalent of delete on the managed Connection.
  5. Connection’s destructor runs and its dynamic storage is released.

No branch needs to contain cleanup code.


Why prefer make_unique?

You can construct a unique_ptr directly from new:

std::unique_ptr<Connection> connection{
    new Connection{"audit-db"}
};

But modern C++ normally prefers:

auto connection = std::make_unique<Connection>("audit-db");

make_unique has three practical advantages:

  • It is shorter and does not repeat the type name.
  • It makes the ownership transfer explicit at the allocation site.
  • It avoids exception-safety hazards that can occur when raw allocation and smart-pointer construction are separated inside more complex expressions.

The relevant parts of Learn C++’s std::unique_ptr chapter provide a useful written reference for the basic model, moving ownership, and the make_unique recommendation.

22.5 — std::unique_ptr – Learn C++

Read this Learn C++ reference after completing the first lab run. It reinforces why a smart pointer should usually be an automatic object, why unique_ptr cannot be copied, and why make_unique is the standard construction form.

In the opening discussion, read the smart pointer setup. Focus on why putting an owner on the heap defeats the automatic cleanup guarantee. Next, in the ownership-transfer discussion, read the transfer rules; connect the disabled copy operations to preventing double deletion. Finally, find the subsection titled “std::make_unique” and read the make unique rationale.


Ownership versus access

A unique_ptr is an owner, but most functions should not be forced to know that an object happens to be owned by a unique_ptr.

Suppose a helper only needs to use an existing connection:

void write_audit_record(Connection& connection,
                        std::string_view message) {
    connection.send(message);
}

Call it like this:

auto connection = std::make_unique<Connection>("audit-db");
write_audit_record(*connection, "user signed in");

The helper receives a reference, which communicates:

  • the helper does not take ownership;
  • a valid Connection must exist for the call;
  • the caller remains responsible for its lifetime.

If a function can reasonably accept “no object,” use a non-owning pointer instead:

void maybe_write_audit_record(const Connection* connection,
                              std::string_view message) {
    if (connection != nullptr) {
        connection->send(message);
    }
}

The call site can borrow the underlying raw pointer temporarily:

maybe_write_audit_record(connection.get(), "optional event");

.get() does not transfer ownership. It only exposes the managed address for an interface that expects a raw pointer. The returned pointer must never be manually deleted, stored beyond the owner’s lifetime, or used after the unique_ptr has released its resource.

A useful default rule is:

NeedBest expression of ownership
Object need not be dynamically allocatedStore a plain value, such as Connection connection{"audit-db"};
One object or component owns a dynamic resourcestd::unique_ptr<T>
A function only uses an existing objectT& when null is invalid; T* when null is meaningful
A function takes ownershipTake std::unique_ptr<T> by value

The first row matters. RAII is broader than smart pointers. If there is no genuine need for dynamic allocation, the simplest repair is often to remove new entirely:

bool publish(std::string_view message) {
    Connection connection{"audit-db"};

    if (message.empty()) {
        return false;
    }

    if (message == "FAIL") {
        throw std::runtime_error{"publishing failed"};
    }

    connection.send(message);
    return true;
}

This is also RAII: Connection is an automatic object whose destructor runs at scope exit. Use unique_ptr when the design actually needs indirection, optional ownership, a lifetime that moves between components, or another reason the object cannot simply be stored by value.


Moving a uniquely owned resource

Two unique_ptr objects must never own the same raw object. If copying were allowed, both destructors would attempt to delete it. That is why this fails to compile:

auto first = std::make_unique<Connection>("audit-db");

// auto second = first;  // error: copying is forbidden

If ownership should change hands, move it deliberately:

#include <memory>
#include <utility>

void hand_off(std::unique_ptr<Connection> connection) {
    connection->send("ownership received");
}  // this function's local owner is destroyed here

int main() {
    auto connection = std::make_unique<Connection>("audit-db");

    hand_off(std::move(connection));

    // connection no longer owns the Connection.
}

std::move does not itself move or destroy an object. It expresses that connection may be moved from; initializing the by-value parameter then transfers ownership into hand_off.

After a move, treat the source unique_ptr as no longer owning a resource. Do not dereference it. If later code genuinely needs to know whether it still owns something, test it:

if (connection) {
    connection->send("still owned here");
}

For the component repaired in this lesson, no transfer is needed: the unique_ptr stays local and cleans up when publish exits. Moving becomes relevant when the owner must be returned from a factory or passed into a longer-lived component.


Mistakes RAII does not permit—or cannot repair

unique_ptr makes several errors harder to write, but it cannot fix an unclear ownership design automatically.

Do not manually delete a managed object

This is undefined behavior:

auto connection = std::make_unique<Connection>("audit-db");

delete connection.get();  // wrong

The unique_ptr still believes it owns the address. When it is destroyed, it attempts to delete the same object again.

Do not create two owners from one raw address

This is also undefined behavior:

Connection* raw = new Connection{"audit-db"};

std::unique_ptr<Connection> first{raw};
std::unique_ptr<Connection> second{raw};  // wrong

Both owners would delete raw. Prefer make_unique, which avoids exposing a raw owning pointer in the first place.

Avoid release() in ordinary code

release() gives up ownership and returns the raw pointer without deleting it:

Connection* raw = connection.release();

At that moment, RAII protection has ended. There are legitimate interoperability cases, but routine application code should almost never need this. If you use release(), the next owner and its cleanup responsibility should be immediately clear.

A borrow must not outlive its owner

This remains dangerous:

Connection* borrowed = nullptr;

{
    auto owner = std::make_unique<Connection>("audit-db");
    borrowed = owner.get();
}

// borrowed points to a destroyed Connection here.

unique_ptr correctly destroys the resource at the end of the inner block. The error is using a non-owning pointer after the owner’s lifetime has ended.


Takeaways

  • RAII connects resource cleanup to object destruction. It works because automatic objects are destroyed on normal returns and during exception unwinding.
  • A raw pointer is not an owner by itself; it cannot clean up a dynamically allocated object automatically.
  • std::unique_ptr<T> expresses exclusive ownership of a dynamic T. Its destructor releases the managed object.
  • Prefer std::make_unique<T>(...) for constructing a uniquely owned dynamic object.
  • Do not use delete on .get(), create multiple unique_ptr owners for one address, or casually call release().
  • Pass T& or T* to code that only uses an object. Pass std::unique_ptr<T> by value only when ownership itself is being transferred.
  • Before reaching for a smart pointer, ask whether a plain automatic value would model the lifetime more simply.

Next, you will shift from resource ownership to machine-level correctness: encoding fixed-width signed and unsigned integers in binary, including the crucial distinction between defined unsigned wraparound and undefined signed overflow.

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

Sign up