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
- RAII: The Foundation of Modern C++ Memory
- unique_ptr: Exclusive Ownership
- shared_ptr: Shared Reference-Counted Ownership
- weak_ptr: Breaking Reference Cycles
- Move Semantics: Eliminating Unnecessary Copies
- std::move and std::forward
- Perfect Forwarding
- std::span: Safe Non-Owning Memory Views (C++20)
- The Rule of Zero, Three, and Five
- Frequently Asked Questions
- Key Takeaway
Stack vs Heap: The Performance Anatomy
Stack allocation:
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:
#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:
#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:
#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:
#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:
#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
#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:
#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.
