Deep Dive: Memory Management & Smart Pointers

Memory management is the defining challenge of C++. It's the source of the language's greatest power — and its most notorious bugs. Get it right, and your code runs at hardware speed with zero overhead. Get it wrong, and you face crashes, leaks, security vulnerabilities, and hours of debugging.
Modern C++ has solved most of these problems. The techniques in this guide — RAII, smart pointers, and ownership semantics — eliminate entire classes of bugs at compile time. If you've been writing new and delete by hand, it's time to stop.
Time commitment: 1-2 weeks, 1-2 hours daily
Prerequisites: Phase 3: Modern C++ Features
Related reading: For foundational topics, see C++ Stack vs Heap Memory and C++ Pointers: The Complete Guide.
What You'll Learn
✅ Understand the C++ memory model: stack, heap, and object lifetime
✅ Apply RAII to manage every kind of resource automatically
✅ Use std::unique_ptr for exclusive ownership
✅ Use std::shared_ptr for shared ownership with reference counting
✅ Use std::weak_ptr to break circular references
✅ Write custom deleters for non-standard resources
✅ Apply ownership semantics to design clear APIs
✅ Avoid the top memory safety pitfalls in C++
1. The C++ Memory Model
Every object in C++ has a storage duration that determines when it's created and destroyed:
| Storage Duration | Where | Lifetime | Example |
|---|---|---|---|
| Automatic | Stack | Scope-based | Local variables |
| Dynamic | Heap | Manual / smart pointer | new-allocated objects |
| Static | Data segment | Program lifetime | static and global variables |
| Thread | Thread-local | Thread lifetime | thread_local variables |
Stack vs Heap — The Quick Version
#include <memory>
void example() {
// Stack: fast allocation, automatic cleanup
int x = 42; // destroyed at end of scope
double arr[100]; // fixed size, on the stack
// Heap: flexible size, must manage lifetime
int* p = new int(42); // YOU must delete this
delete p; // manual cleanup — error-prone
// Heap with smart pointer: automatic cleanup
auto sp = std::make_unique<int>(42); // deleted automatically
} // x, arr, sp all cleaned up here. If you forgot 'delete p', it leaks.Key insight: Stack allocation is nearly free (just a pointer bump). Heap allocation involves the memory allocator and is orders of magnitude slower. Prefer the stack when possible — use the heap when you need dynamic lifetime, polymorphism, or large/variable-size data.
Object Lifetime
Every C++ object follows a strict lifecycle:
For stack objects, the compiler handles all five steps. For heap objects, you are responsible for destruction and deallocation — unless you use RAII.
2. RAII: The Foundation of Safe C++
RAII (Resource Acquisition Is Initialization) is the single most important idiom in C++. The idea is simple: tie every resource to an object's lifetime. Acquire the resource in the constructor, release it in the destructor. When the object goes out of scope, cleanup happens automatically.
The Problem RAII Solves
// WITHOUT RAII — manual resource management
void processFile(const std::string& path) {
FILE* f = fopen(path.c_str(), "r");
if (!f) return;
char* buffer = new char[1024];
// What if this throws?
processData(f, buffer);
delete[] buffer; // skipped if exception thrown!
fclose(f); // skipped if exception thrown!
}This code has three bugs:
- If
processDatathrows,bufferleaks - If
processDatathrows,fis never closed - If you add an early
return, you might forget cleanup
The RAII Solution
#include <fstream>
#include <vector>
#include <string>
// WITH RAII — automatic resource management
void processFile(const std::string& path) {
std::ifstream file(path); // opens file in constructor
if (!file) return;
std::vector<char> buffer(1024); // allocates in constructor
processData(file, buffer);
} // file closed, buffer freed — AUTOMATICALLY
// Even if processData throws — destructors always runNo leaks. No forgotten cleanup. No bugs from early returns or exceptions. RAII makes resource management correct by construction.
Writing Your Own RAII Class
Any resource that needs cleanup can be wrapped in RAII:
#include <cstdio>
#include <stdexcept>
#include <string>
class FileHandle {
public:
explicit FileHandle(const std::string& path, const char* mode)
: file_(fopen(path.c_str(), mode))
{
if (!file_) {
throw std::runtime_error("Failed to open: " + path);
}
}
// Destructor — always runs when object goes out of scope
~FileHandle() {
if (file_) {
fclose(file_);
}
}
// Delete copy operations — file handles aren't copyable
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
// Enable move operations — transfer ownership
FileHandle(FileHandle&& other) noexcept : file_(other.file_) {
other.file_ = nullptr;
}
FileHandle& operator=(FileHandle&& other) noexcept {
if (this != &other) {
if (file_) fclose(file_);
file_ = other.file_;
other.file_ = nullptr;
}
return *this;
}
FILE* get() const { return file_; }
void write(const std::string& data) {
fwrite(data.c_str(), 1, data.size(), file_);
}
private:
FILE* file_;
};
// Usage
void writeReport() {
FileHandle report("report.txt", "w");
report.write("Hello, RAII!\n");
} // file automatically closed hereRAII for Non-Memory Resources
RAII works for any resource — not just memory:
#include <mutex>
#include <iostream>
// Mutex locking
std::mutex mtx;
void safeOperation() {
std::lock_guard<std::mutex> lock(mtx); // locks mutex
// ... critical section ...
} // mutex automatically unlocked
// Database transactions
class Transaction {
public:
explicit Transaction(Database& db) : db_(db) {
db_.begin();
}
~Transaction() {
if (!committed_) {
db_.rollback(); // auto-rollback if not committed
}
}
void commit() {
db_.commit();
committed_ = true;
}
private:
Database& db_;
bool committed_ = false;
};The RAII rule: If you acquire it, wrap it. If it has a close(), release(), unlock(), or delete — it belongs in a destructor.
3. std::unique_ptr — Exclusive Ownership
std::unique_ptr is the workhorse of modern C++ memory management. It owns a heap-allocated object exclusively — exactly one unique_ptr points to the object at any time. When the unique_ptr is destroyed, the object is deleted.
Basic Usage
#include <memory>
#include <iostream>
#include <string>
struct User {
std::string name;
int age;
User(std::string n, int a) : name(std::move(n)), age(a) {
std::cout << "User created: " << name << "\n";
}
~User() {
std::cout << "User destroyed: " << name << "\n";
}
};
int main() {
// Create with make_unique (preferred — exception-safe)
auto alice = std::make_unique<User>("Alice", 30);
std::cout << alice->name << " is " << alice->age << "\n";
// Access the raw pointer (doesn't transfer ownership)
User* raw = alice.get();
std::cout << raw->name << "\n";
// Check if it owns something
if (alice) {
std::cout << "alice is not null\n";
}
} // alice destroyed here → User destroyed: AliceTransfer Ownership with std::move
unique_ptr cannot be copied — ownership is exclusive. But you can move it:
#include <memory>
#include <iostream>
#include <vector>
auto createUser(std::string name, int age) {
return std::make_unique<User>(std::move(name), age);
}
int main() {
auto user = createUser("Bob", 25);
// Transfer ownership
auto user2 = std::move(user);
// user is now nullptr!
if (!user) {
std::cout << "user is null after move\n";
}
// Store in containers
std::vector<std::unique_ptr<User>> users;
users.push_back(std::make_unique<User>("Charlie", 35));
users.push_back(std::move(user2));
} // all users destroyed when vector is destroyedunique_ptr and Polymorphism
unique_ptr is perfect for polymorphic objects:
#include <memory>
#include <iostream>
#include <vector>
#include <string>
class Shape {
public:
virtual ~Shape() = default;
virtual double area() const = 0;
virtual std::string name() const = 0;
};
class Circle : public Shape {
public:
explicit Circle(double r) : radius_(r) {}
double area() const override { return 3.14159 * radius_ * radius_; }
std::string name() const override { return "Circle"; }
private:
double radius_;
};
class Rectangle : public Shape {
public:
Rectangle(double w, double h) : width_(w), height_(h) {}
double area() const override { return width_ * height_; }
std::string name() const override { return "Rectangle"; }
private:
double width_, height_;
};
int main() {
// Factory function returning unique_ptr
auto makeShape(std::string type) -> std::unique_ptr<Shape>;
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(5.0));
shapes.push_back(std::make_unique<Rectangle>(3.0, 4.0));
for (const auto& shape : shapes) {
std::cout << shape->name() << ": area = "
<< shape->area() << "\n";
}
}unique_ptr with Arrays
#include <memory>
#include <iostream>
int main() {
// Array version — calls delete[] automatically
auto arr = std::make_unique<int[]>(100);
for (int i = 0; i < 100; ++i) {
arr[i] = i * i;
}
std::cout << "arr[10] = " << arr[10] << "\n"; // 100
}
// But prefer std::vector in most cases:
// std::vector<int> arr(100);Custom Deleters
Sometimes you need cleanup other than delete. Custom deleters handle this:
#include <memory>
#include <cstdio>
#include <iostream>
// Custom deleter for FILE*
struct FileDeleter {
void operator()(FILE* f) const {
if (f) {
std::cout << "Closing file\n";
fclose(f);
}
}
};
// Type includes the deleter
using FilePtr = std::unique_ptr<FILE, FileDeleter>;
FilePtr openFile(const char* path, const char* mode) {
return FilePtr(fopen(path, mode));
}
int main() {
auto file = openFile("data.txt", "w");
if (file) {
fprintf(file.get(), "Hello from unique_ptr!\n");
}
} // file closed by FileDeleter
// Lambda deleter (more concise)
auto closerLambda = [](FILE* f) {
if (f) fclose(f);
};
// Note: lambda deleters change the unique_ptr type
std::unique_ptr<FILE, decltype(closerLambda)> file2(
fopen("data.txt", "r"), closerLambda
);When to Use unique_ptr
Use unique_ptr as your default for heap allocation:
- Factory functions that create objects
- Class members with polymorphic types
- Containers of polymorphic objects
- Pimpl (Pointer to Implementation) idiom
- Any time you'd write
new— wrap it inmake_uniqueinstead
Cost: Zero overhead compared to raw new/delete. Same size as a raw pointer (unless you have a stateful deleter). Same performance.
4. std::shared_ptr — Shared Ownership
Sometimes multiple parts of your code need to share ownership of an object. std::shared_ptr uses reference counting: the object is destroyed when the last shared_ptr pointing to it is destroyed.
Basic Usage
#include <memory>
#include <iostream>
#include <string>
struct Config {
std::string database_url;
int max_connections;
Config(std::string url, int max)
: database_url(std::move(url)), max_connections(max) {}
~Config() { std::cout << "Config destroyed\n"; }
};
int main() {
// Create with make_shared (single allocation, cache-friendly)
auto config = std::make_shared<Config>("postgres://localhost/db", 10);
std::cout << "ref count: " << config.use_count() << "\n"; // 1
{
// Copy — increments reference count
auto config2 = config;
std::cout << "ref count: " << config.use_count() << "\n"; // 2
auto config3 = config;
std::cout << "ref count: " << config.use_count() << "\n"; // 3
} // config2, config3 destroyed → ref count back to 1
std::cout << "ref count: " << config.use_count() << "\n"; // 1
} // config destroyed → ref count 0 → Config destroyedmake_shared vs shared_ptr Constructor
Always prefer std::make_shared:
#include <memory>
struct Widget {
int x, y;
};
// GOOD: single allocation (object + control block together)
auto w1 = std::make_shared<Widget>();
// LESS GOOD: two allocations (object + control block separate)
std::shared_ptr<Widget> w2(new Widget());
// DANGEROUS: potential leak in function arguments before C++17
// process(std::shared_ptr<Widget>(new Widget()), mightThrow());
// If mightThrow() throws after new but before shared_ptr constructor,
// the Widget leaks. make_shared avoids this entirely.Why make_shared is better:
- One allocation instead of two (object + control block in one memory block)
- Exception safe — no leak window between
newand smart pointer construction - Better cache locality — object and reference count are adjacent in memory
Sharing Between Objects
#include <memory>
#include <iostream>
#include <string>
#include <vector>
class Logger {
public:
void log(const std::string& msg) {
std::cout << "[LOG] " << msg << "\n";
}
};
class Service {
public:
explicit Service(std::string name, std::shared_ptr<Logger> logger)
: name_(std::move(name)), logger_(std::move(logger)) {}
void doWork() {
logger_->log(name_ + " doing work");
}
private:
std::string name_;
std::shared_ptr<Logger> logger_; // shared ownership
};
int main() {
auto logger = std::make_shared<Logger>();
// Multiple services share the same logger
Service auth("AuthService", logger);
Service data("DataService", logger);
Service cache("CacheService", logger);
std::cout << "Logger ref count: " << logger.use_count() << "\n"; // 4
auth.doWork();
data.doWork();
cache.doWork();
}
// Logger destroyed only after ALL services and 'logger' are goneThe Aliasing Constructor
A lesser-known feature: create a shared_ptr that shares ownership of one object but points to a member:
#include <memory>
#include <iostream>
#include <string>
struct Pair {
std::string first;
std::string second;
};
int main() {
auto pair = std::make_shared<Pair>(Pair{"hello", "world"});
// Aliasing constructor: shares ownership of 'pair',
// but points to pair->first
std::shared_ptr<std::string> first(pair, &pair->first);
pair.reset(); // release our handle
// 'first' keeps the whole Pair alive!
std::cout << *first << "\n"; // "hello" — still valid!
std::cout << "ref count: " << first.use_count() << "\n"; // 1
}Performance Considerations
shared_ptr is not free:
// Cost breakdown:
// 1. Extra allocation for control block (avoided with make_shared)
// 2. Atomic reference count increment/decrement (thread-safe but ~10-50ns)
// 3. Two pointers per shared_ptr (16 bytes on 64-bit vs 8 for raw/unique)
// 4. Control block stores: strong count, weak count, deleter, allocator
// When it matters:
// - Tight loops with millions of copies → measurable overhead
// - Single-threaded code → atomic ops are unnecessary overhead
// - Large collections → 2x pointer storage adds upRule of thumb: Start with unique_ptr. Only use shared_ptr when you genuinely need shared ownership. If you're unsure, you probably don't need it.
5. std::weak_ptr — Non-Owning Observer
std::weak_ptr is a non-owning reference to a shared_ptr-managed object. It can observe the object without extending its lifetime. This is critical for breaking circular references.
The Circular Reference Problem
#include <memory>
#include <iostream>
#include <string>
struct Node {
std::string name;
std::shared_ptr<Node> next; // owns the next node
std::shared_ptr<Node> prev; // PROBLEM: circular ownership!
Node(std::string n) : name(std::move(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; // A owns B
b->prev = a; // B owns A → CIRCULAR REFERENCE!
std::cout << "a ref count: " << a.use_count() << "\n"; // 2
std::cout << "b ref count: " << b.use_count() << "\n"; // 2
}
// a goes out of scope → ref count drops to 1 (b->prev still holds it)
// b goes out of scope → ref count drops to 1 (a->next still holds it)
// NEITHER gets destroyed! MEMORY LEAK!The Fix: weak_ptr for Back-References
#include <memory>
#include <iostream>
#include <string>
struct Node {
std::string name;
std::shared_ptr<Node> next; // owns the next node
std::weak_ptr<Node> prev; // FIX: non-owning back-reference
Node(std::string n) : name(std::move(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; // A owns B
b->prev = a; // weak reference — doesn't affect ref count
std::cout << "a ref count: " << a.use_count() << "\n"; // 1
std::cout << "b ref count: " << b.use_count() << "\n"; // 2
// Access through weak_ptr — must lock first
if (auto prev = b->prev.lock()) {
std::cout << "B's prev: " << prev->name << "\n";
}
}
// a destroyed → a ref count 0 → Node A destroyed
// → a->next (shared_ptr to B) destroyed → b ref count drops to 1
// b destroyed → b ref count 0 → Node B destroyed
// No leak!Using weak_ptr Safely
weak_ptr doesn't guarantee the object is alive. You must check before accessing:
#include <memory>
#include <iostream>
void useWeakPtr(std::weak_ptr<int> weak) {
// Method 1: lock() — returns shared_ptr or nullptr
if (auto shared = weak.lock()) {
std::cout << "Value: " << *shared << "\n";
} else {
std::cout << "Object already destroyed\n";
}
// Method 2: expired() — check without locking
if (weak.expired()) {
std::cout << "Object is gone\n";
}
}
int main() {
std::weak_ptr<int> weak;
{
auto shared = std::make_shared<int>(42);
weak = shared;
useWeakPtr(weak); // prints "Value: 42"
} // shared destroyed here
useWeakPtr(weak); // prints "Object already destroyed"
}Observer Pattern with weak_ptr
A classic use case — observers that don't keep subjects alive:
#include <memory>
#include <vector>
#include <algorithm>
#include <iostream>
#include <string>
class EventEmitter {
public:
using Listener = std::shared_ptr<std::function<void(const std::string&)>>;
void subscribe(Listener listener) {
listeners_.push_back(listener);
}
void emit(const std::string& event) {
// Clean up expired listeners and notify active ones
listeners_.erase(
std::remove_if(listeners_.begin(), listeners_.end(),
[](const std::weak_ptr<std::function<void(const std::string&)>>& w) {
return w.expired();
}),
listeners_.end()
);
for (auto& weak : listeners_) {
if (auto listener = weak.lock()) {
(*listener)(event);
}
}
}
private:
std::vector<std::weak_ptr<std::function<void(const std::string&)>>> listeners_;
};Cache with weak_ptr
#include <memory>
#include <unordered_map>
#include <string>
#include <iostream>
class ImageCache {
public:
std::shared_ptr<Image> load(const std::string& path) {
// Check if image is still in memory
auto it = cache_.find(path);
if (it != cache_.end()) {
if (auto img = it->second.lock()) {
std::cout << "Cache hit: " << path << "\n";
return img; // still alive — reuse it
}
// expired — remove stale entry
cache_.erase(it);
}
// Load new image
std::cout << "Cache miss: " << path << "\n";
auto img = std::make_shared<Image>(path);
cache_[path] = img; // store weak reference
return img;
}
private:
std::unordered_map<std::string, std::weak_ptr<Image>> cache_;
};6. Smart Pointer Comparison
Here's when to use each:
| Pointer | Ownership | Overhead | Use When |
|---|---|---|---|
unique_ptr | Exclusive | Zero (same as raw new/delete) | Default choice for heap allocation |
shared_ptr | Shared | Atomic ref count + control block | Multiple owners needed |
weak_ptr | None (observer) | Lock check before access | Breaking cycles, caches, observers |
Raw pointer T* | None (non-owning) | None | Observing, optional references |
Reference T& | None (non-owning) | None | Guaranteed non-null access |
Decision Flowchart
7. Ownership Semantics in APIs
How you pass smart pointers communicates ownership intent. This is one of the most important design signals in C++.
Function Parameters
#include <memory>
#include <string>
class Widget {
public:
std::string name;
};
// 1. Take by raw pointer or reference — "I just use it, I don't own it"
void inspect(const Widget& w) {
// read-only access, caller retains ownership
std::cout << w.name << "\n";
}
void modify(Widget& w) {
// read-write access, caller retains ownership
w.name = "modified";
}
// 2. Take unique_ptr by value — "Give me ownership (sink parameter)"
void consume(std::unique_ptr<Widget> w) {
// I now own this widget. It's destroyed when I'm done.
std::cout << "Consumed: " << w->name << "\n";
}
// 3. Take unique_ptr by reference — "I might take ownership"
void maybeConsume(std::unique_ptr<Widget>& w) {
if (w->name == "disposable") {
w.reset(); // take and destroy
}
}
// 4. Take shared_ptr by value — "I need shared ownership"
void share(std::shared_ptr<Widget> w) {
// I now co-own this widget
// (ref count incremented on call, decremented when function returns)
}
// 5. Take shared_ptr by const ref — "I might share ownership"
void maybeShare(const std::shared_ptr<Widget>& w) {
// Cheap to call — no ref count change
// Can copy if I decide to keep it
}Return Values
#include <memory>
#include <string>
// Factory — return unique_ptr (caller gets ownership)
std::unique_ptr<Widget> createWidget(std::string name) {
auto w = std::make_unique<Widget>();
w->name = std::move(name);
return w; // moved automatically (NRVO or implicit move)
}
// Shared factory — when callers will share the result
std::shared_ptr<Widget> getSharedWidget() {
static auto instance = std::make_shared<Widget>();
return instance;
}Class Members
#include <memory>
#include <string>
#include <vector>
class Engine {
public:
void run() { /* ... */ }
};
class Car {
public:
// Exclusive ownership — Car owns the Engine
Car() : engine_(std::make_unique<Engine>()) {}
// Provide non-owning access
Engine& engine() { return *engine_; }
private:
std::unique_ptr<Engine> engine_;
};
class Team {
public:
void addMember(std::shared_ptr<Player> player) {
members_.push_back(std::move(player));
}
private:
// Shared ownership — players can be on multiple teams
std::vector<std::shared_ptr<Player>> members_;
};The Core Guidelines
From the C++ Core Guidelines:
| Guideline | Rule |
|---|---|
| R.30 | Take smart pointers as parameters only to express ownership |
| R.32 | Take unique_ptr<Widget> to express that a function assumes ownership |
| R.33 | Take unique_ptr<Widget>& to express that a function reseats the widget |
| R.34 | Take shared_ptr<Widget> to express shared ownership |
| R.36 | Take const shared_ptr<Widget>& to express that it might retain a reference |
| F.7 | For general use, take T* or T& rather than smart pointers |
The most common mistake: Passing shared_ptr everywhere "just in case." This pollutes your API with unnecessary reference counting. If a function just reads or modifies an object, take it by reference.
8. Common Memory Safety Pitfalls
Pitfall 1: Dangling Pointers
#include <memory>
#include <iostream>
// BUG: returning reference to local object
int& dangling() {
int x = 42;
return x; // x destroyed at end of scope!
}
// BUG: keeping raw pointer after unique_ptr is moved
void danglingFromMove() {
auto ptr = std::make_unique<int>(42);
int* raw = ptr.get();
auto ptr2 = std::move(ptr); // ownership transferred
// raw is NOW DANGLING — ptr no longer owns the object
// *raw is undefined behavior!
}
// FIX: don't outlive the owner
void safe() {
auto ptr = std::make_unique<int>(42);
int* raw = ptr.get();
// Use raw only while ptr is alive and not moved
std::cout << *raw << "\n"; // OK
// Don't move ptr while raw is in use
}Pitfall 2: Double Deletion
#include <memory>
// BUG: two shared_ptrs from same raw pointer
void doubleFree() {
int* raw = new int(42);
std::shared_ptr<int> sp1(raw);
std::shared_ptr<int> sp2(raw); // DANGER: separate control blocks!
// When sp1 is destroyed → deletes raw
// When sp2 is destroyed → deletes raw AGAIN → crash!
}
// FIX: always use make_shared, or create from existing shared_ptr
void safe() {
auto sp1 = std::make_shared<int>(42);
auto sp2 = sp1; // same control block, ref count = 2
}Pitfall 3: Circular References with shared_ptr
#include <memory>
struct Parent;
struct Child;
struct Parent {
std::shared_ptr<Child> child;
~Parent() { std::cout << "Parent destroyed\n"; }
};
struct Child {
std::shared_ptr<Parent> parent; // BUG: circular!
~Child() { std::cout << "Child destroyed\n"; }
};
// FIX: use weak_ptr for the back-reference
struct ChildFixed {
std::weak_ptr<Parent> parent; // non-owning
~ChildFixed() { std::cout << "ChildFixed destroyed\n"; }
};Pitfall 4: Using shared_ptr from this
#include <memory>
#include <vector>
#include <iostream>
// BUG: creating shared_ptr from raw 'this'
class BadWidget {
public:
void addToList(std::vector<std::shared_ptr<BadWidget>>& list) {
// DANGER: creates new control block from raw pointer!
list.push_back(std::shared_ptr<BadWidget>(this));
}
};
// FIX: inherit from enable_shared_from_this
class GoodWidget : public std::enable_shared_from_this<GoodWidget> {
public:
void addToList(std::vector<std::shared_ptr<GoodWidget>>& list) {
// Safe: uses existing control block
list.push_back(shared_from_this());
}
};
// Usage — MUST be managed by shared_ptr first
auto widget = std::make_shared<GoodWidget>();
std::vector<std::shared_ptr<GoodWidget>> list;
widget->addToList(list); // safe!Pitfall 5: Slicing with Smart Pointers
#include <memory>
#include <iostream>
class Base {
public:
virtual ~Base() = default;
virtual void speak() { std::cout << "Base\n"; }
};
class Derived : public Base {
public:
void speak() override { std::cout << "Derived\n"; }
int extra_data = 42;
};
// OK: smart pointers to base class work correctly
std::unique_ptr<Base> ptr = std::make_unique<Derived>();
ptr->speak(); // prints "Derived" — polymorphism works!
// BUG: value-type slicing (not a smart pointer issue, but common confusion)
Base base = Derived(); // SLICED! extra_data lost, speak() calls Base::speak9. Advanced Memory Patterns
Pimpl Idiom (Pointer to Implementation)
Hide implementation details behind a unique_ptr:
// widget.h — public header
#pragma once
#include <memory>
#include <string>
class Widget {
public:
Widget(std::string name);
~Widget(); // must be declared in header, defined in .cpp
// Move operations
Widget(Widget&&) noexcept;
Widget& operator=(Widget&&) noexcept;
// No copy
Widget(const Widget&) = delete;
Widget& operator=(const Widget&) = delete;
void doSomething();
std::string name() const;
private:
struct Impl; // forward declaration
std::unique_ptr<Impl> pimpl_; // pointer to implementation
};// widget.cpp — implementation (can change without recompiling users)
#include "widget.h"
#include <iostream>
#include <vector>
struct Widget::Impl {
std::string name;
std::vector<int> data;
// ... any private members, even heavy headers
};
Widget::Widget(std::string name)
: pimpl_(std::make_unique<Impl>()) {
pimpl_->name = std::move(name);
}
Widget::~Widget() = default; // must be in .cpp where Impl is complete
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;
void Widget::doSomething() {
pimpl_->data.push_back(42);
std::cout << pimpl_->name << " did something\n";
}
std::string Widget::name() const {
return pimpl_->name;
}Benefits: Faster compilation (changes to Impl don't recompile users), ABI stability, clean public headers.
Object Pool
Reuse objects instead of allocating/deallocating repeatedly:
#include <memory>
#include <vector>
#include <iostream>
#include <functional>
template<typename T>
class ObjectPool {
public:
using Deleter = std::function<void(T*)>;
using Ptr = std::unique_ptr<T, Deleter>;
explicit ObjectPool(size_t initial_size = 10) {
for (size_t i = 0; i < initial_size; ++i) {
pool_.push_back(std::make_unique<T>());
}
}
Ptr acquire() {
if (pool_.empty()) {
// Pool exhausted — create new object
return Ptr(new T(), [this](T* obj) {
pool_.push_back(std::unique_ptr<T>(obj));
});
}
// Take from pool — return with custom deleter that recycles
auto obj = pool_.back().release();
pool_.pop_back();
return Ptr(obj, [this](T* obj) {
pool_.push_back(std::unique_ptr<T>(obj));
});
}
size_t available() const { return pool_.size(); }
private:
std::vector<std::unique_ptr<T>> pool_;
};
// Usage
struct Connection {
void reset() { /* reset state for reuse */ }
};
int main() {
ObjectPool<Connection> pool(5);
std::cout << "Available: " << pool.available() << "\n"; // 5
{
auto conn1 = pool.acquire();
auto conn2 = pool.acquire();
std::cout << "Available: " << pool.available() << "\n"; // 3
} // conn1, conn2 returned to pool
std::cout << "Available: " << pool.available() << "\n"; // 5
}std::pmr Polymorphic Allocators (C++17)
For performance-critical code, you can control where memory is allocated:
#include <memory_resource>
#include <vector>
#include <iostream>
int main() {
// Stack-based buffer — no heap allocation at all
char buffer[4096];
std::pmr::monotonic_buffer_resource pool(buffer, sizeof(buffer));
// Vector that allocates from our buffer
std::pmr::vector<int> vec(&pool);
for (int i = 0; i < 100; ++i) {
vec.push_back(i);
}
std::cout << "Size: " << vec.size() << "\n";
// All allocations came from 'buffer' — zero heap allocations!
}aligned_alloc and Alignment
For SIMD or hardware-specific requirements:
#include <memory>
#include <cstdlib>
#include <iostream>
// Custom deleter for aligned memory
struct AlignedDeleter {
void operator()(void* ptr) const {
std::free(ptr);
}
};
// Allocate aligned memory with smart pointer
auto makeAligned(size_t alignment, size_t size) {
void* ptr = std::aligned_alloc(alignment, size);
return std::unique_ptr<void, AlignedDeleter>(ptr);
}
int main() {
// 64-byte aligned buffer (cache line aligned)
auto buffer = makeAligned(64, 1024);
std::cout << "Aligned: "
<< (reinterpret_cast<uintptr_t>(buffer.get()) % 64 == 0)
<< "\n"; // 1 (true)
}
// C++17: alignas in classes
struct alignas(64) CacheAligned {
int data[16];
};
auto ptr = std::make_unique<CacheAligned>();
// Note: make_unique respects alignas since C++1710. Memory Debugging Tools
No matter how careful you are, memory bugs happen. These tools catch them:
AddressSanitizer (ASan)
The most important tool for catching memory bugs at runtime:
# Compile with ASan
g++ -fsanitize=address -g -O1 main.cpp -o main
clang++ -fsanitize=address -g -O1 main.cpp -o main
# Run — ASan reports errors with stack traces
./mainASan detects:
- Use after free
- Heap buffer overflow
- Stack buffer overflow
- Memory leaks
- Double free
// Example: ASan catches use-after-free
#include <memory>
int main() {
auto ptr = std::make_unique<int>(42);
int* raw = ptr.get();
ptr.reset(); // frees the memory
return *raw; // ASan: "use-after-free"
}Valgrind
# Run under Valgrind (slower but more thorough)
valgrind --leak-check=full --show-leak-kinds=all ./mainLeakSanitizer (LSan)
# Often included with ASan, or standalone:
g++ -fsanitize=leak -g main.cpp -o main
./mainCompiler Warnings
# Turn on all the warnings
g++ -Wall -Wextra -Wpedantic -Werror -std=c++20 main.cpp
clang++ -Wall -Wextra -Wpedantic -Werror -std=c++20 main.cpp11. Best Practices Summary
The Modern C++ Memory Rules
- Never write
new/deletedirectly — usemake_uniqueormake_shared unique_ptris your default — useshared_ptronly when genuinely needed- Functions that don't own should take
T&orT*— not smart pointers - Use
weak_ptrto break circular references in shared ownership graphs - Every resource gets RAII — files, locks, connections, transactions, everything
- Prefer stack allocation — heap only when you need dynamic lifetime or size
- Use ASan in development — compile with
-fsanitize=addressand run your tests
Code Review Checklist
When reviewing C++ code for memory safety:
✅ No raw new/delete outside RAII wrappers
✅ No raw pointer ownership (raw pointers are observers only)
✅ shared_ptr cycles broken with weak_ptr
✅ enable_shared_from_this used when objects need shared_from_this()
✅ Factory functions return unique_ptr (not raw pointers)
✅ No use of get() pointer beyond the lifetime of the smart pointer
✅ Virtual destructors on base classes
✅ Move semantics implemented for resource-holding classes
Migration Path: Legacy Code → Modern C++
If you're working with legacy code that uses raw new/delete:
// Step 1: Wrap existing raw pointers in unique_ptr at creation
// BEFORE:
Widget* w = new Widget();
// ... 200 lines later ...
delete w;
// AFTER:
auto w = std::make_unique<Widget>();
// ... 200 lines later ...
// (automatic cleanup)
// Step 2: Replace raw pointer parameters with references
// BEFORE:
void process(Widget* w) { w->doThing(); }
// AFTER:
void process(Widget& w) { w.doThing(); }
// Step 3: Replace ownership transfers with unique_ptr
// BEFORE:
Widget* create() { return new Widget(); } // caller must delete
// AFTER:
std::unique_ptr<Widget> create() { return std::make_unique<Widget>(); }Practice Exercises
Exercise 1: RAII Socket Wrapper
Write an RAII wrapper for a network socket:
class Socket {
public:
explicit Socket(int port); // opens socket
~Socket(); // closes socket
Socket(Socket&&) noexcept;
Socket& operator=(Socket&&) noexcept;
// No copies
Socket(const Socket&) = delete;
Socket& operator=(const Socket&) = delete;
void send(const std::string& data);
std::string receive();
private:
int fd_ = -1; // file descriptor
};Exercise 2: Tree with Smart Pointers
Build a tree where:
- Parent owns children (
unique_ptr) - Children reference parent (
weak_ptror raw pointer)
struct TreeNode {
int value;
std::vector<std::unique_ptr<TreeNode>> children;
TreeNode* parent = nullptr; // non-owning back reference
void addChild(int val) {
auto child = std::make_unique<TreeNode>();
child->value = val;
child->parent = this;
children.push_back(std::move(child));
}
};Exercise 3: Type-Erased Callback
Use shared_ptr and weak_ptr to build a callback system that auto-unregisters when the listener is destroyed:
class Button {
public:
using Callback = std::function<void()>;
void onClick(std::weak_ptr<Callback> cb) {
callbacks_.push_back(cb);
}
void click() {
// Remove expired, invoke active
// ... implement this
}
private:
std::vector<std::weak_ptr<Callback>> callbacks_;
};Summary
✅ RAII is the foundation — every resource gets a destructor
✅ unique_ptr for exclusive ownership — zero overhead, default choice
✅ shared_ptr for shared ownership — use sparingly, make_shared always
✅ weak_ptr to observe without owning — breaks cycles, enables caches
✅ Ownership semantics communicate intent — your API's most important signal
✅ Never write raw new/delete — the compiler and smart pointers handle it
✅ Use ASan and Valgrind — trust but verify
What's Next?
- Deep Dive: STL Containers & Algorithms → — Master the standard library's data structures and algorithms
- Deep Dive: Templates & Generic Programming → — Write flexible, type-safe generic code
Additional Resources
- cppreference: Smart Pointers — Definitive reference
- C++ Core Guidelines: Resource Management — Official best practices
- Herb Sutter: GotW #89 — Smart Pointers — Parameter passing guidelines
- Effective Modern C++ by Scott Meyers — Items 18-22 cover smart pointers in depth
- Compiler Explorer — See the zero-overhead of
unique_ptrvs raw pointers
Series: Modern C++ Learning Roadmap
Previous: Phase 3: Modern C++ Features
Next: Deep Dive: STL Containers & Algorithms
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.