C++Memory

C++ Memory: Stack vs Heap, Smart Pointers, unique_ptr, shared_ptr & Move Semantics

TT
TopicTrick Team
C++ Memory: Stack vs Heap, Smart Pointers, unique_ptr, shared_ptr & Move Semantics

C++ Memory: Stack vs Heap, Smart Pointers, unique_ptr, shared_ptr & Move Semantics


Table of Contents


Stack vs Heap: The Performance Anatomy

Stack allocation:

cpp
void compute() {
    int result = 0;              // 4 bytes on stack
    double matrix[64][64];       // 32KB on stack — instant allocation
    std::array<int, 1000> data;  // 4KB on stack — still instant
    
    // ALL cleaned up automatically when compute() returns
    // Zero allocator overhead, maximum cache locality
}

Heap allocation cost:

  • Mutex acquisition (thread-safe heap)
  • Free block search in allocator's tree/list
  • Page fault on first access (OS maps physical page)
  • Cache miss (newly allocated memory is cold in cache)

Rule: Default to stack. Use heap only when data must outlive its scope, or when the size exceeds the stack limit (~8MB total).


RAII: The Foundation of Modern C++ Memory

RAII (Resource Acquisition Is Initialization) is the core C++ memory safety idiom: resources (memory, file handles, locks, network connections) are acquired in constructors and released in destructors. Since destructors always run when an object leaves scope — even when exceptions are thrown — RAII guarantees no leaks:

cpp
#include <cstdio>
#include <stdexcept>

class FileHandle {
    FILE* file_;
public:
    explicit FileHandle(const char* path, const char* mode)
        : file_(std::fopen(path, mode)) {
        if (!file_) throw std::runtime_error("Cannot open file");
    }
    
    ~FileHandle() {
        if (file_) std::fclose(file_);  // ALWAYS called — even on exception
    }
    
    // Delete copy (resource can't be duplicated)
    FileHandle(const FileHandle&) = delete;
    FileHandle& operator=(const FileHandle&) = delete;
    
    // Allow move
    FileHandle(FileHandle&& other) noexcept : file_(other.file_) {
        other.file_ = nullptr;
    }
    
    FILE* get() { return file_; }
};

void write_data() {
    FileHandle f("output.txt", "w"); // Acquired in constructor
    std::fprintf(f.get(), "Hello!\n");
    throw std::runtime_error("oops"); // Exception thrown
    // ~FileHandle() runs here, file is properly closed!
    // No leak, no matter what
}

unique_ptr: Exclusive Ownership

std::unique_ptr<T> is a zero-overhead smart pointer providing exclusive ownership. Exactly one unique_ptr owns an object at any time. When the unique_ptr is destroyed, the owned object is automatically deleted:

cpp
#include <memory>
#include <vector>
#include <string>

struct Player {
    std::string name;
    int health;
    explicit Player(std::string n, int hp) : name(std::move(n)), health(hp) {}
    ~Player() { /* called automatically */ }
};

int main() {
    // Create with make_unique (C++14) — preferred over new
    auto player = std::make_unique<Player>("Alice", 100);
    
    // Access through unique_ptr
    player->health -= 10;  // Dereference with ->
    (*player).name = "Alice the Survivor"; // Or with *
    
    // Transfer ownership with move (cannot copy!)
    auto new_owner = std::move(player); // player is now null
    // player == nullptr after move
    
    // Containers of unique_ptr
    std::vector<std::unique_ptr<Player>> party;
    party.push_back(std::make_unique<Player>("Bob", 80));
    party.push_back(std::make_unique<Player>("Carol", 90));
    
    for (const auto& p : party) {
        std::cout << p->name << ": " << p->health << '\n';
    }
    // All Players destroyed when 'party' goes out of scope
    
    // Factory function pattern
    auto make_player = [](std::string name) -> std::unique_ptr<Player> {
        return std::make_unique<Player>(std::move(name), 100);
    };
    
    auto hero = make_player("Diana");
    return 0; // hero destroyed here — Player's dtor called
}

Zero overhead: At compile time, unique_ptr with a default deleter is exactly as efficient as a raw pointer. It generates no additional machine code compared to manual new/delete — verified on Compiler Explorer.


shared_ptr: Shared Reference-Counted Ownership

std::shared_ptr<T> allows multiple owners. A reference count tracks how many shared_ptr instances own the object; when the last one is destroyed, the object is deleted:

cpp
#include <memory>
#include <vector>

struct Config {
    std::string server_url;
    int timeout_ms;
};

class Service {
    std::shared_ptr<Config> config_;
public:
    explicit Service(std::shared_ptr<Config> cfg) : config_(std::move(cfg)) {}
    void process() { /* uses config_ */ }
};

int main() {
    // Create shared_ptr
    auto config = std::make_shared<Config>("https://api.example.com", 5000);
    // Reference count: 1
    
    {
        Service svc1(config); // Reference count: 2
        Service svc2(config); // Reference count: 3
        
        // Both services share the same Config object
        std::cout << config.use_count() << '\n'; // 3
    }
    // svc1 and svc2 destroyed — count back to 1
    
    std::cout << config.use_count() << '\n'; // 1
    
    return 0; // count reaches 0, Config deleted
}

shared_ptr overhead: Each make_shared<T> allocates one control block (reference count + deleter + optional allocator). Copying a shared_ptr is atomic increment — thread-safe but not free (~10ns per copy on modern hardware due to the atomic operation). Avoid copying shared_ptr in hot paths; use const shared_ptr& for read-only access.


weak_ptr: Breaking Reference Cycles

shared_ptr reference cycles (A holds B, B holds A) prevent both from ever being deleted — a memory leak. weak_ptr is a non-owning observer of a shared_ptr-managed object:

cpp
#include <memory>

struct Node {
    int value;
    std::shared_ptr<Node> child;    // Owns child
    std::weak_ptr<Node>   parent;   // Non-owning reference to parent
};

// With shared_ptr for parent — MEMORY LEAK:
// root->child->parent = root; // Cycle! Neither is ever deleted.

// With weak_ptr for parent — CORRECT:
auto root  = std::make_shared<Node>(1);
auto child = std::make_shared<Node>(2);

root->child = child;           // shared_ptr — root owns child
child->parent = root;          // weak_ptr — no ownership, no cycle

// To use weak_ptr, must lock() it to get a temporary shared_ptr:
if (auto parent = child->parent.lock()) { // Returns shared_ptr or nullptr
    std::cout << "Parent: " << parent->value << '\n';
} else {
    std::cout << "Parent was destroyed\n"; // safe null check
}
// When root is destroyed, child.parent.lock() returns nullptr — safe!

Move Semantics: Eliminating Unnecessary Copies

Before C++11, passing or returning a std::vector<int> of 1M elements made a complete deep copy — expensive. Move semantics transfer ownership rather than copying:

cpp
#include <vector>
#include <string>

// C++98 style — forced deep copy
std::vector<int> create_large_data_old() {
    std::vector<int> result(1'000'000, 42);
    return result;  // PRE-C++11: copies 4MB of data
}

// C++11 — move semantics: O(1) "pointer swap", zero data copying
std::vector<int> create_large_data() {
    std::vector<int> result(1'000'000, 42);
    return result; // NRVO or move — no data copied
}

void demonstrate_move() {
    std::string s1 = "Hello, World! This is a long string to demonstrate.";
    
    // Copy: s2 gets its own allocation; s1 unchanged
    std::string s2 = s1;              // Deep copy — both have data
    
    // Move: s2 steals s1's buffer; s1 becomes empty string
    std::string s3 = std::move(s1);   // O(1) — just pointer swap
    // s1 is now in a "valid but unspecified" state (typically empty)
    
    std::cout << "s1: '" << s1 << "'\n"; // "" or similar
    std::cout << "s3: '" << s3 << "'\n"; // "Hello, World!..."
}

std::move and std::forward

cpp
#include <utility>
#include <vector>
#include <string>

// std::move — unconditional cast to rvalue reference (does NOT move!)
// Just enables the move constructor/assignment to be selected
void push_string(std::vector<std::string>& vec, std::string s) {
    vec.push_back(std::move(s));  // Move s into vector — avoid copy
    // s is in "moved-from" state — don't use after this
}

// std::forward — conditional cast for perfect forwarding
template<typename T>
void wrapper(T&& arg) {
    // Forward preserves value category:
    // If arg was lvalue, forward it as lvalue
    // If arg was rvalue, forward it as rvalue
    actual_function(std::forward<T>(arg));
}

std::span: Safe Non-Owning Memory Views (C++20)

std::span<T> is a lightweight non-owning view over a contiguous sequence of T — like a pointer + size, but with bounds checking and range-for support:

cpp
#include <span>
#include <vector>
#include <array>
#include <iostream>

// Function accepts ANY contiguous container — no templates needed
void process(std::span<const int> data) {
    for (int x : data) std::cout << x << ' ';
    std::cout << '\n';
}

int main() {
    std::vector<int> vec = {1, 2, 3, 4, 5};
    std::array<int, 3> arr = {10, 20, 30};
    int raw[] = {100, 200, 300};
    
    process(vec);        // Works — span view over vector
    process(arr);        // Works — span view over array
    process(raw);        // Works — span view over C array
    process({vec.data(), 3}); // Works — first 3 elements of vec
    
    // Subspan:
    std::span<int> view(vec);
    auto first_half = view.subspan(0, vec.size() / 2);  // {1, 2}
    
    return 0;
}

Frequently Asked Questions

Should I always prefer unique_ptr over raw pointers? Yes, for owning pointers. Raw pointers are still appropriate for non-owning "observer" pointers (where the ownership is clear and lifetime is managed elsewhere). In C++, if a pointer owns its target, it should be unique_ptr or shared_ptr. If it doesn't own, it's fine to use T* or T& — just document the ownership model.

What is the difference between make_shared and shared_ptr constructor? std::make_shared<T>(args) allocates the control block and the T object in a single allocation — better cache locality, one fewer allocation. std::shared_ptr<T>(new T(args)) does two separate allocations. Always prefer make_shared unless you have a specific reason (e.g., custom deleters or aliasing constructors).

When does std::move NOT actually move? std::move is a cast — it doesn't move anything itself. It enables move semantics by casting to T&&. If the type has no move constructor or move assignment, std::move silently falls back to copy. Also, after return local_var; the compiler applies Named Return Value Optimization (NRVO) or implicit move, so explicitly writing return std::move(local_var) can actually prevent optimization.


Key Takeaway

Modern C++ Memory = RAII + Smart Pointers + Move Semantics. These eliminate manual delete, prevent double-free and use-after-free bugs, and eliminate unnecessary copies of large objects. Once you understand that unique_ptr has zero overhead, shared_ptr has minimal overhead (one atomic increment per copy), and std::move is free (just a cast), you can write memory-safe C++ that matches the performance of bare-metal C.

Read next: Modern C++ Functions: Lambdas, References & Parameter Passing →


Part of the C++ Mastery Course — 30 modules from modern C++ basics to expert systems engineering.