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 usenewdirectly with aunique_ptr. - Pass by value (with
std::move) when transferring ownership. - Pass by
const std::unique_ptr<T>&or byT*(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
-
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 withstd::make_unique<std::vector<int>>(std::initializer_list<int>{1,2,3,4,5}). -
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
- Implement a
ScopedTimerclass (similar to theTimerin 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
- Implement a simple reference-counted
SharedBufferclass without usingstd::shared_ptr. Store the data in achar*and the reference count in anint*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
deleteinstead ofdelete[]for arrays.int* arr = new int[10]; delete arr;is undefined behavior. Matchnew[]withdelete[], always. -
Using a moved-from object. After
auto b = std::move(a);,ais 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_ptrcycles withoutweak_ptr. Two objects holdingshared_ptrs to each other never reach a reference count of zero and both leak. Break cycles withweak_ptr. -
Unnecessary use of
shared_ptrinstead ofunique_ptr. The overhead is real (atomic reference count, double allocation). Default tounique_ptr; only promote toshared_ptrwhen genuine shared ownership is needed. -
Taking a raw pointer from a
unique_ptrand deleting it separately.unique_ptrwill then also delete it when it goes out of scope — double free. -
Confusing
std::movewith actual movement.std::moveis 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 emptyshared_ptrif 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