Documentation Index
Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt
Use this file to discover all available pages before exploring further.
Pointers & Memory Management
This is the chapter that separates C++ from most other languages. In languages like Python or Java, memory is managed for you. In C++, you are in control. Understanding memory is the key to writing high-performance, crash-free applications.
1. The Memory Model: Stack vs. Heap
Every C++ program uses two main areas of memory. Understanding the difference is critical — it affects performance, safety, and how you design your programs.
Stack vs Heap at a Glance
| Property | Stack | Heap |
|---|
| Allocation speed | ~1 CPU instruction (move a pointer) | 50-100x slower (allocator bookkeeping) |
| Deallocation | Automatic (scope exit) | Manual (delete) or smart pointer |
| Typical size limit | 1-8 MB (OS-dependent) | Limited by system RAM (gigabytes) |
| Fragmentation | None | Can fragment over time |
| Thread safety | Each thread has its own stack | Shared across threads (allocator must synchronize) |
| Cache behavior | Excellent (contiguous, predictable) | Poor (scattered allocations) |
| Object lifetime | Tied to scope | Controlled by programmer |
| Risk of leaks | Impossible | Easy if not using RAII |
The Stack (Automatic Memory)
Think of the stack like a stack of cafeteria trays. You put things on top and take them off the top — strictly last-in, first-out. Every time you call a function, a new “frame” (tray) is pushed onto the stack with space for that function’s local variables. When the function returns, the frame is popped and all those variables vanish instantly.
- Fast: Allocation is just moving a pointer. No searching for free space, no bookkeeping. A stack allocation takes roughly 1 CPU instruction.
- Automatic: Variables are destroyed when they go out of scope (e.g., function ends). You cannot leak stack memory.
- Small: Limited size (typically 1-8 MB, depending on your OS). Exceed it and you get a stack overflow.
- Usage: Local variables, function parameters, small temporary objects.
void func() {
int x = 10; // Allocated on the stack (fast, automatic)
double arr[100]; // 800 bytes on the stack -- fine
// Both x and arr are destroyed automatically when func() returns
}
The Heap (Free Store)
Think of the heap like a warehouse with a front desk. You walk up and say “I need 400 bytes.” The clerk finds a free section, marks it as used, and hands you a ticket (pointer). When you are done, you hand the ticket back and the clerk marks it free. If you lose the ticket, that space is reserved forever — a memory leak.
- Slower: Requires the memory allocator to search for free space, update bookkeeping data structures, and may even ask the OS for more pages. Heap allocation can be 50-100x slower than stack allocation.
- Manual: You control lifetime (or use smart pointers to automate it).
- Large: Limited only by system RAM (gigabytes).
- Usage: Large objects, dynamic arrays, objects that must outlive the scope that created them.
void func() {
int* ptr = new int(10); // Allocated on the heap -- you now hold the "ticket"
// ... use ptr ...
delete ptr; // Hand the ticket back. Memory is freed.
// Forget this line and you have a memory leak.
}
When to use the heap: Only allocate on the heap when you need to. Specifically: when the object is too large for the stack, when its size is unknown at compile time, or when it must outlive the function that creates it. If none of these apply, the stack is always the better choice.
2. Pointers vs. References
Both refer to memory addresses, but they have different rules and use cases.
Pointers (*)
A pointer is a variable that stores a memory address.
- Can be null: It might point to nothing (
nullptr).
- Reassignable: It can point to object A, then later to object B.
- Explicit: You must dereference (
*ptr) to get the value.
int x = 10;
int* ptr = &x; // ptr holds address of x
std::cout << ptr; // Prints address (e.g., 0x7ffee...)
std::cout << *ptr; // Prints value (10)
ptr = nullptr; // Can be null
References (&)
A reference is an alias for an existing variable. It’s like a nickname.
- Cannot be null: Must always refer to a valid object.
- Immutable binding: Once initialized, it cannot refer to something else.
- Syntactic sugar: No dereferencing needed; use it like the original variable.
int x = 10;
int& ref = x; // ref is an alias for x
ref = 20; // x is now 20
Rule of Thumb: Use references by default (safer, cleaner). Use pointers only when you need to handle nullptr or reassign the address.
Pointer vs Reference — Quick Comparison
| Feature | Pointer (T*) | Reference (T&) |
|---|
| Can be null | Yes (nullptr) | No (must always bind to a valid object) |
| Can be reassigned | Yes (point to a different object) | No (bound at initialization, forever) |
| Syntax to access value | Dereference: *ptr | Direct: ref (no special syntax) |
| Can represent “nothing” | Yes (idiomatic for optional parameters) | No |
| Can do arithmetic | Yes (ptr + 1 moves to next element) | No |
| Common use | Dynamic allocation, optional params, polymorphism | Function parameters, aliases, range-for loops |
Edge case — dangling references: A reference to a local variable becomes dangling when the variable goes out of scope. The compiler will not always catch this. Example: int& bad() { int x = 5; return x; } — the returned reference points to a destroyed stack frame. Clang and GCC will warn with -Wreturn-local-addr, but more subtle cases (returning a reference to a member of a temporary) can slip through. Always ensure the referred-to object outlives the reference.
3. Raw Pointers (The Old Way)
In “Legacy C++” (pre-C++11), we used new and delete to manage heap memory. This was the source of an enormous class of bugs that plagued C++ programs for decades.
// Allocate -- you now own this memory and are responsible for it
int* arr = new int[100];
// Use
arr[0] = 5;
// Deallocate (CRITICAL! Every 'new' must have exactly one matching 'delete')
delete[] arr; // Note: delete[] for arrays, delete for single objects
The Three Deadly Sins of Manual Memory Management:
- Memory Leaks: If you forget
delete, memory is never freed. In a long-running server, this means memory usage grows until the process is killed. Studies of pre-C++11 codebases found that memory leaks were the single most common category of bugs.
- Dangling Pointers: If you
delete but keep using the pointer, you get undefined behavior — crashes, data corruption, or worse, it “works” in testing and fails in production.
- Exception Safety: If an exception is thrown between
new and delete, the delete never runs. The memory leaks silently.
void dangerous() {
int* data = new int[1000];
processData(data); // What if this throws an exception?
delete[] data; // This line never executes! Memory leaked.
}
Modern C++ Solution: Never use new/delete directly. Use Smart Pointers.
4. Smart Pointers (The Modern Way)
Smart pointers (in <memory>) wrap raw pointers and automatically manage memory using the RAII pattern. They are as fast as raw pointers but safe.
std::unique_ptr — The Default Choice
Think of unique_ptr as a safe deposit box with exactly one key. Whoever holds the key owns the contents. You can hand the key to someone else (std::move), but you cannot duplicate it. When the key holder leaves, the box is automatically emptied.
- Exclusive ownership: Only one pointer owns the object at any time.
- Zero overhead: Same size and speed as a raw pointer. The compiler generates identical machine code.
- Automatically deletes when it goes out of scope — even if an exception is thrown.
- Cannot be copied, only moved. This prevents accidental double-deletion.
#include <memory>
void process() {
// Create unique_ptr using make_unique (preferred since C++14)
// This is exception-safe: if allocation fails, no memory leaks
auto ptr = std::make_unique<int>(10);
// Use it exactly like a raw pointer
*ptr = 20;
std::cout << *ptr; // 20
// No delete needed! Automatically freed when ptr goes out of scope.
}
// Transfer ownership with std::move
auto ptr1 = std::make_unique<int>(42);
auto ptr2 = std::move(ptr1); // ptr1 is now nullptr, ptr2 owns the int
// auto ptr3 = ptr1; // COMPILE ERROR: cannot copy a unique_ptr
Start with unique_ptr. In practice, 90%+ of heap allocations need single ownership. Only reach for shared_ptr when you genuinely have multiple owners with independent lifetimes. Over-using shared_ptr is a common sign of unclear ownership design.
std::shared_ptr — When Ownership is Truly Shared
Think of shared_ptr as a shared apartment lease. Multiple tenants (pointers) can be on the lease. The apartment (memory) stays available as long as at least one tenant is on the lease. When the last tenant moves out, the apartment is released.
- Shared ownership: Multiple pointers can point to the same object.
- Reference counting: An internal counter tracks how many
shared_ptrs point to the object. The object is deleted only when the counter reaches zero.
- Overhead: Two extra pointer-sized words for the control block (reference count + weak count), plus atomic increment/decrement on every copy. This is small, but not free — in hot loops, it adds up.
auto p1 = std::make_shared<int>(10); // Ref count = 1
{
auto p2 = p1; // Ref count = 2 (p2 shares ownership)
auto p3 = p1; // Ref count = 3
} // p2 and p3 destroyed, Ref count = 1
// p1 destroyed, Ref count = 0 -> Memory freed
Circular reference trap: If object A holds a shared_ptr to B, and B holds a shared_ptr to A, neither will ever be freed — their reference counts never reach zero. This is a memory leak. Break cycles using std::weak_ptr, which observes without owning.
Smart Pointer Comparison Table
| Feature | unique_ptr | shared_ptr | weak_ptr | Raw pointer |
|---|
| Ownership | Exclusive (single owner) | Shared (reference counted) | None (observer only) | None (manual) |
| Copyable? | No (move only) | Yes (increments ref count) | Yes | Yes |
| Overhead vs raw pointer | Zero (same size, same speed) | Two extra words + atomic ref count ops | Same as shared_ptr control block | Zero |
| Thread-safe ref counting? | N/A | Yes (count is atomic) | Yes | N/A |
| Can be null? | Yes | Yes | Yes (expired check via lock()) | Yes |
| Auto-deletes? | Yes (on scope exit) | Yes (when ref count hits 0) | No | No |
| Custom deleter? | Yes (no extra overhead) | Yes (stored in control block) | No | N/A |
| Use in containers? | Yes (move into container) | Yes | Yes | Yes (but dangerous) |
When to Use Which — Decision Framework
Start at the top and stop at the first match:
- Can the object live on the stack? Do that. No pointer needed. This is the fastest, safest option.
- Does the object need to outlive its creating scope, with a single clear owner? Use
std::unique_ptr. This covers ~90% of heap allocation needs.
- Is ownership genuinely shared across multiple independent lifetimes? Use
std::shared_ptr. Common in caches, observer registries, and async callbacks where you cannot predict which owner dies last.
- Do you need to observe a shared object without preventing its destruction? Use
std::weak_ptr. This is the standard way to break circular references and implement non-owning caches.
- Are you interfacing with a C API or legacy code that requires raw pointers? Use
raw_pointer.get() to extract from a smart pointer. Never let the C API delete your memory — the smart pointer owns it.
Edge case — make_shared vs shared_ptr constructor: std::make_shared<T>(args) allocates the object and the control block in a single allocation (one malloc call). std::shared_ptr<T>(new T(args)) does two allocations. The single-allocation form is faster and exception-safe. However, because the control block and object share memory, the memory is not freed until both the last shared_ptr AND the last weak_ptr are gone. If you have long-lived weak_ptrs observing large objects, this can hold memory longer than expected. In that rare case, prefer the two-allocation form.
5. RAII (Resource Acquisition Is Initialization)
RAII is the single most important idiom in C++. It is the reason C++ can be both low-level and safe. Once you internalize RAII, you will understand why C++ programmers are skeptical of garbage collectors — they have something more powerful.
The real-world analogy: Think of a hotel room. When you check in (constructor), you get a room key (resource). When you check out (destructor), the key is returned and the room is cleaned. You do not need a “room cleaning service” running in the background periodically checking which rooms are abandoned (that is what a garbage collector does). The checkout process itself guarantees cleanup. RAII works the same way — acquiring a resource and binding its release to an object’s destructor means cleanup is deterministic, immediate, and guaranteed.
Principle:
- Acquire resource in the constructor.
- Release resource in the destructor.
Since stack objects are automatically destroyed when they go out of scope, RAII guarantees cleanup — even if an exception is thrown, even if you return early, even if the code path is complex. The destructor always runs.
class FileHandler {
FILE* file;
public:
// Constructor: acquire the resource
FileHandler(const char* name) {
file = fopen(name, "r");
if (!file) throw std::runtime_error("Cannot open file");
}
// Destructor: release the resource -- guaranteed to run
~FileHandler() {
if (file) {
fclose(file);
}
}
// Prevent copying (a copied FileHandler would double-close the file)
FileHandler(const FileHandler&) = delete;
FileHandler& operator=(const FileHandler&) = delete;
};
void func() {
FileHandler fh("data.txt"); // File opened
// ... do work ...
// Even if an exception is thrown HERE, fh's destructor runs.
// File is always closed. No leak. No "finally" block needed.
}
RAII is not limited to memory. It is used for every kind of resource in C++:
- Memory:
std::unique_ptr, std::shared_ptr, std::vector
- Files:
std::fstream (closes on destruction)
- Mutexes:
std::lock_guard, std::unique_lock (unlocks on destruction)
- Network sockets: RAII wrappers ensure connections are closed
- Database transactions: Commit on success, rollback on destruction if not committed
This is why experienced C++ developers say “if you understand RAII, you understand C++.” It is the foundation that makes everything else work.
6. Memory Gotchas That Bite in Production
These are the edge cases that do not appear in textbooks but cause real outages:
Gotcha 1: Use-after-move
auto ptr = std::make_unique<Widget>();
processWidget(std::move(ptr));
ptr->doSomething(); // Undefined behavior! ptr is nullptr after the move.
The compiler does not prevent use-after-move for most types. The sanitizer flag -fsanitize=address can catch some cases at runtime, but the real fix is discipline: after moving, treat the variable as dead.
Gotcha 2: Returning a reference to a local unique_ptr’s contents
const Widget& getWidget() {
auto w = std::make_unique<Widget>();
return *w; // Dangling! w is destroyed at function exit, taking the Widget with it.
}
This compiles without warning on many compilers. The reference immediately dangles because the unique_ptr destructor fires when the function returns.
Gotcha 3: shared_ptr to this
class Node : public std::enable_shared_from_this<Node> {
std::shared_ptr<Node> getPtr() { return shared_from_this(); }
};
// WRONG: Node n; n.getPtr(); -- crashes, because n is not owned by a shared_ptr
// RIGHT: auto n = std::make_shared<Node>(); n->getPtr(); -- works
shared_from_this() only works if the object is already managed by a shared_ptr. Calling it on a stack-allocated or unique_ptr-managed object is undefined behavior.
Gotcha 4: Array deletion mismatch
int* arr = new int[100];
delete arr; // WRONG! Must use delete[] for arrays.
delete[] arr; // Correct.
Using delete instead of delete[] on an array is undefined behavior. It may appear to work in testing and corrupt memory in production. This is one more reason to use std::vector or std::make_unique<int[]>(100) instead.
Exercises
- Stack overflow experiment: Write a recursive function with no base case. Run it and observe the stack overflow. Then add a counter to see how many frames deep your system allows before crashing. On your OS, what is the approximate stack size? (On Linux, check with
ulimit -s.)
- Leak detection: Write a program that intentionally leaks memory in a loop (allocate with
new, never delete). Run it under Valgrind (valgrind --leak-check=full ./program) or AddressSanitizer (-fsanitize=address). Read the output and understand each line of the leak report.
- Smart pointer refactor: Take the
dangerous() function from the raw pointers section and rewrite it using unique_ptr. Verify that it is exception-safe by having processData throw an exception.
- Circular reference detection: Create two classes
A and B where A holds a shared_ptr<B> and B holds a shared_ptr<A>. Observe the leak. Then fix it by changing one of them to weak_ptr. Confirm the leak is gone using a tool or by adding print statements in destructors.
- Thought experiment: You are designing a cache that holds 10,000 objects. Multiple threads read from the cache. Objects can be evicted at any time. What smart pointer type(s) would you use and why? What happens if a thread is using an object when another thread evicts it?
Summary
- Stack is fast and automatic; Heap is manual and large.
- Prefer References (
&) over Pointers (*) when possible.
- Avoid
new/delete. Use std::make_unique or std::make_shared.
- RAII ensures resources are always cleaned up.
Next, we’ll apply these concepts to build robust structures using Object-Oriented Programming.