Boring C++
Boring C++

Memory – Ownership and Lifetime

13 min read

Chapter 6: Memory – Ownership and Lifetime

This is the chapter most people read twice. That is not a warning — it is a description of what usually happens. Memory management is the part of C++ that has no real equivalent in Python, Java, or JavaScript. Those languages handle it automatically. C++ does not, or rather: it gives you the tools to handle it correctly and then lets you decide how to use them.

The good news is that modern C++ has made the situation dramatically better than the "old" C++ you may have heard horror stories about. With smart pointers and RAII, you can write correct, leak-free C++ without ever calling delete yourself. But to use those tools well, you need to understand what they are doing.

6.1 Stack versus heap

Every C++ program has two main memory regions: the stack and the heap.

The stack is fast, limited in size (typically 1–8 MB), and automatically managed. Variables declared inside a function live on the stack and are destroyed automatically when the function returns. No allocation call, no free call — the compiler generates the code to do it.

void stackExample() {
    int x = 42;           // on the stack
    double pi = 3.14159;  // on the stack
    // x and pi are destroyed automatically when this function returns
}

The heap (also called the free store) is larger (gigabytes on modern systems), slower to allocate, and requires explicit management. The classic way to allocate on the heap:

#include <iostream>

int main() {
    // Allocate a single int on the heap
    int* p = new int{42};
    std::cout << "Value: " << *p << "\n";
    delete p;       // free it — REQUIRED
    p = nullptr;    // good practice: null the pointer after freeing

    // Allocate an array on the heap
    int n = 10;
    int* arr = new int[n]{};  // value-initialized to zero
    for (int i = 0; i < n; ++i) arr[i] = i * i;
    for (int i = 0; i < n; ++i) std::cout << arr[i] << " ";
    std::cout << "\n";
    delete[] arr;   // REQUIRED: use delete[] for arrays, not delete
    arr = nullptr;

    return 0;
}

The asymmetry — new with delete, new[] with delete[] — is one source of bugs. Mixing them is undefined behavior.

In modern C++, you almost never write new or delete directly. Section 6.3 through 6.4 show the smart pointer wrappers that make heap allocation safe.

6.2 Raw pointers (and why to avoid them)

A pointer is a variable that holds the address of something in memory. You declare a pointer with *, dereference it with *, and take an address with &:

#include <iostream>

int main() {
    int x = 42;
    int* p = &x;  // p holds the address of x

    std::cout << "x = " << x << "\n";       // 42
    std::cout << "p = " << p << "\n";       // some address like 0x7ffd...
    std::cout << "*p = " << *p << "\n";     // 42 (dereference)

    *p = 100;  // modify x through p
    std::cout << "x after *p=100: " << x << "\n";  // 100

    return 0;
}

Pointers are necessary to understand — they underlie references, arrays, polymorphism, and smart pointers. But raw pointers used for ownership management lead to three classic bugs:

Dangling pointer: a pointer to memory that has already been freed.

int* danglingExample() {
    int x = 42;
    return &x;  // x is destroyed when this function returns
    // the caller receives a pointer to garbage
}

Double free: calling delete twice on the same pointer.

int* p = new int{42};
delete p;
delete p;  // undefined behavior — often crashes

Memory leak: forgetting to call delete.

void leakExample() {
    int* p = new int{42};
    // ... forgot to delete p
    // the memory is gone until the program exits
}

All three of these bugs can be subtle, hard to reproduce, and catastrophic in production. Smart pointers eliminate all three.

6.3 unique_ptr

std::unique_ptr<T> owns a heap-allocated T exclusively. When the unique_ptr goes out of scope, it automatically calls delete on the managed pointer. It cannot be copied (enforcing exclusive ownership), but it can be moved.

#include <iostream>
#include <memory>
#include <string>

class Resource {
public:
    explicit Resource(const std::string& name) : name_{name} {
        std::cout << "Resource '" << name_ << "' acquired\n";
    }
    ~Resource() {
        std::cout << "Resource '" << name_ << "' released\n";
    }
    void use() const {
        std::cout << "Using: " << name_ << "\n";
    }
private:
    std::string name_;
};

std::unique_ptr<Resource> createResource(const std::string& name) {
    return std::make_unique<Resource>(name);  // move on return
}

void processResource(std::unique_ptr<Resource> res) {
    res->use();
    // res is destroyed here — resource is released
}

int main() {
    // Basic: make_unique is the right way to create unique_ptr
    auto r1 = std::make_unique<Resource>("FileHandle");
    r1->use();

    // Transfer ownership with std::move
    auto r2 = std::move(r1);  // r1 is now null; r2 owns the resource
    if (!r1) std::cout << "r1 is now empty\n";
    r2->use();

    // Function returning a unique_ptr
    auto r3 = createResource("DatabaseConnection");
    r3->use();

    // Transfer ownership to a function
    processResource(std::move(r3));  // r3 is now empty
    if (!r3) std::cout << "r3 is now empty\n";

    // r2 is destroyed at end of scope — resource is released automatically
    return 0;
}

Rules for unique_ptr:

  • Always create with std::make_unique<T>(args...). Never use new directly with a unique_ptr.
  • Pass by value (with std::move) when transferring ownership.
  • Pass by const std::unique_ptr<T>& or by T* (via .get()) when you only need to use the resource, not take ownership.
  • Pass by T& when you need to modify the object but not take ownership.

6.4 shared_ptr and weak_ptr

std::shared_ptr<T> allows multiple owners. It maintains a reference count; when the last shared_ptr to an object is destroyed, the object is deleted.

#include <iostream>
#include <memory>
#include <string>

class Config {
public:
    explicit Config(const std::string& name) : name_{name} {
        std::cout << "Config '" << name_ << "' created\n";
    }
    ~Config() {
        std::cout << "Config '" << name_ << "' destroyed\n";
    }
    const std::string& name() const { return name_; }
private:
    std::string name_;
};

int main() {
    auto config1 = std::make_shared<Config>("production");
    std::cout << "use_count: " << config1.use_count() << "\n";  // 1

    auto config2 = config1;  // shared ownership
    std::cout << "use_count: " << config1.use_count() << "\n";  // 2

    {
        auto config3 = config1;  // another owner
        std::cout << "use_count: " << config1.use_count() << "\n";  // 3
        std::cout << "Name: " << config3->name() << "\n";
    }  // config3 destroyed — count goes back to 2
    std::cout << "use_count: " << config1.use_count() << "\n";  // 2

    config2.reset();  // config2 releases ownership
    std::cout << "use_count: " << config1.use_count() << "\n";  // 1

    // config1 goes out of scope — count reaches 0 — object destroyed

    return 0;
}

The cycle problem: if two objects hold shared_ptrs to each other, neither count ever reaches zero and both leak. Use std::weak_ptr to break cycles:

#include <iostream>
#include <memory>
#include <string>

class Node {
public:
    std::string name;
    std::shared_ptr<Node> next;      // strong ownership
    std::weak_ptr<Node> parent;      // weak — does NOT keep parent alive

    explicit Node(const std::string& n) : name{n} {
        std::cout << "Node '" << name << "' created\n";
    }
    ~Node() {
        std::cout << "Node '" << name << "' destroyed\n";
    }
};

int main() {
    {
        auto a = std::make_shared<Node>("A");
        auto b = std::make_shared<Node>("B");

        a->next = b;
        b->parent = a;  // weak_ptr — does NOT increment a's reference count

        std::cout << "a use_count: " << a.use_count() << "\n";  // 1
        std::cout << "b use_count: " << b.use_count() << "\n";  // 2 (a->next and b)

        // Access through weak_ptr: lock() returns a shared_ptr if still alive
        if (auto parent = b->parent.lock()) {
            std::cout << "b's parent: " << parent->name << "\n";
        }

    }  // both nodes destroyed cleanly

    return 0;
}

When to use shared_ptr vs unique_ptr: start with unique_ptr. Switch to shared_ptr only when ownership genuinely needs to be shared among multiple owners with no clear single owner. shared_ptr has overhead: two heap allocations (object + control block), atomic reference count operations, and more complex lifetime semantics.

6.5 References versus pointers

References and pointers both refer to something indirectly, but they have different rules:

Reference Pointer
Can be null No Yes
Can be reseated No Yes
Syntax to use Same as the object Requires * and ->
Initialized to Must be at declaration Can be initialized later or to nullptr
#include <iostream>
#include <string>

// Reference: cannot be null, must always be initialized
void printUpper(const std::string& s) {
    for (char c : s) std::cout << static_cast<char>(std::toupper(c));
    std::cout << "\n";
}

// Pointer: can be null — caller signals "this is optional"
void printUpperIfPresent(const std::string* s) {
    if (s == nullptr) {
        std::cout << "(nothing to print)\n";
        return;
    }
    for (char c : *s) std::cout << static_cast<char>(std::toupper(c));
    std::cout << "\n";
}

int main() {
    std::string hello{"hello"};

    printUpper(hello);               // reference — always valid
    printUpperIfPresent(&hello);     // pass address
    printUpperIfPresent(nullptr);    // valid: prints nothing

    return 0;
}

The rule: use references when a value is required and will not be null. Use pointers (or preferably std::optional, covered in Chapter 9) when absence is a valid state.

6.6 Move semantics: what actually happens

std::move does not move anything. It is a cast to an rvalue reference — a signal that says "you may steal the resources from this object."

The actual work is done by the move constructor and move assignment operator of the type being moved. For std::string, the move constructor transfers the internal character buffer from the source to the destination — no allocation, no copy — and leaves the source in a valid but empty state.

#include <iostream>
#include <string>
#include <chrono>

int main() {
    std::string big(10'000'000, 'x');
    std::cout << "big.size() = " << big.size() << "\n";

    // Copy: allocates new memory and copies 10 million characters
    auto t1 = std::chrono::steady_clock::now();
    std::string copied = big;
    auto t2 = std::chrono::steady_clock::now();

    // Move: transfers the internal pointer — no allocation
    auto t3 = std::chrono::steady_clock::now();
    std::string moved = std::move(big);
    auto t4 = std::chrono::steady_clock::now();

    auto copyUs = std::chrono::duration_cast<std::chrono::microseconds>(t2 - t1).count();
    auto moveUs = std::chrono::duration_cast<std::chrono::microseconds>(t4 - t3).count();

    std::cout << "Copy: " << copyUs << " us, size=" << copied.size() << "\n";
    std::cout << "Move: " << moveUs << " us, size=" << moved.size() << "\n";
    std::cout << "big after move: '" << big << "' (empty)\n";

    return 0;
}

The key rule: after you move from an object, treat it as empty. Do not read it. Do not pass it to another function. You may reassign it or destroy it — those are defined. Reading a moved-from object gives you an unspecified (but valid) value.

When does the compiler move automatically (without std::move)?

  • When returning a local variable by value — this is Return Value Optimization (RVO), and the compiler may eliminate the move entirely.
  • When passing a temporary to a function that takes by value.
  • When a variable goes out of scope and its value is "last use."

Use std::move explicitly when you want to transfer ownership of a named variable to another location and you no longer need the original.

6.7 RAII: the most important pattern in C++

RAII stands for Resource Acquisition Is Initialization. The idea: every resource (memory, file handle, network connection, mutex lock, database transaction) is wrapped in an object whose constructor acquires the resource and whose destructor releases it.

This pattern is the mechanism behind unique_ptr, shared_ptr, std::fstream, std::lock_guard, and almost every other resource-handling class in the standard library.

#include <iostream>
#include <fstream>
#include <string>
#include <stdexcept>

class FileWriter {
public:
    explicit FileWriter(const std::string& filename)
        : file_{filename}
    {
        if (!file_.is_open()) {
            throw std::runtime_error{"Cannot open file: " + filename};
        }
        std::cout << "Opened: " << filename << "\n";
    }

    ~FileWriter() {
        file_.close();
        std::cout << "Closed file\n";
    }

    // Prevent copying
    FileWriter(const FileWriter&) = delete;
    FileWriter& operator=(const FileWriter&) = delete;

    void writeLine(const std::string& line) {
        file_ << line << "\n";
    }

private:
    std::ofstream file_;
};

void writeReport(const std::string& filename) {
    FileWriter fw{filename};
    fw.writeLine("Report generated at startup");
    fw.writeLine("Data: 42, 3.14, hello");
    fw.writeLine("End of report");
    // fw is destroyed here — file is closed automatically
    // even if an exception is thrown before this point
}

int main() {
    try {
        writeReport("report.txt");
        std::cout << "Report written successfully\n";
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << "\n";
    }
    return 0;
}

The crucial property: even if an exception is thrown inside writeReport, the destructor of fw still runs. The file is closed cleanly. RAII makes exception safety almost automatic.

Without RAII, you would need explicit try/finally blocks (C++ does not even have finally). With RAII, you need neither. The destructor is your finally block, and it runs whenever the object leaves scope — whether by normal return, by exception, or by early return.

6.8 Exercises

Easy

  1. Write a function sumVector(std::unique_ptr<std::vector<int>> v) that takes ownership of a vector and returns the sum of its elements. Call it with std::make_unique<std::vector<int>>(std::initializer_list<int>{1,2,3,4,5}).

  2. Write a small program that creates two shared_ptr<std::string> objects pointing to the same string, prints the use count at each step, and observes when the string is destroyed.

Medium

  1. Implement a ScopedTimer class (similar to the Timer in Section 5.3) using RAII. The constructor records the start time and prints a "starting" message. The destructor prints the elapsed time in milliseconds. Demonstrate it with a loop that runs for a measurable amount of time.

Stretch

  1. Implement a simple reference-counted SharedBuffer class without using std::shared_ptr. Store the data in a char* and the reference count in an int* allocated on the heap alongside the data. Implement the copy constructor, copy assignment, move constructor, move assignment, and destructor. Verify that the buffer is destroyed exactly once when the last owner goes out of scope.

6.9 Common mistakes

  • Using delete instead of delete[] for arrays. int* arr = new int[10]; delete arr; is undefined behavior. Match new[] with delete[], always.

  • Using a moved-from object. After auto b = std::move(a);, a is in a valid but unspecified state. Using it (other than to reassign it) is a bug. Null-check after moves on pointers; do not use other types after moving.

  • Returning a reference to a local variable. The local is destroyed when the function returns. The caller has a dangling reference.

  • Creating shared_ptr cycles without weak_ptr. Two objects holding shared_ptrs to each other never reach a reference count of zero and both leak. Break cycles with weak_ptr.

  • Unnecessary use of shared_ptr instead of unique_ptr. The overhead is real (atomic reference count, double allocation). Default to unique_ptr; only promote to shared_ptr when genuine shared ownership is needed.

  • Taking a raw pointer from a unique_ptr and deleting it separately. unique_ptr will then also delete it when it goes out of scope — double free.

  • Confusing std::move with actual movement. std::move is a cast, not an operation. The move constructor or move assignment of the target type does the actual work.

  • Forgetting to check weak_ptr::lock() result. lock() returns an empty shared_ptr if the object has been destroyed. Always check before dereferencing.

6.10 Summary

The stack is automatic and fast; the heap is larger but requires management. In modern C++, you rarely call new and delete directly.

unique_ptr owns a heap object exclusively. shared_ptr shares ownership via reference counting. weak_ptr observes a shared object without owning it and breaks reference cycles.

References are non-null aliases; pointers can be null and reseated. Use references for required parameters; use pointers or std::optional when absence is meaningful.

Move semantics transfer resources without copying. std::move is a cast; the move constructor does the work. After moving from an object, treat it as empty.

RAII is the most important pattern in C++: wrap every resource in an object whose destructor releases it. This makes resource management exception-safe by construction and eliminates entire categories of leaks.

Chapter 7 introduces templates — the mechanism for writing code that works correctly for any type, without duplicating the code. It is where the compiler error messages get longer. That is okay.

All code for this chapter is in the GitHub repository:
<TODO: book code repository URL>/tree/main/chapter06

Boring C++

Modern C++ Mastery for Programmers Who Prefer Clarity Over Cleverness.

View book →