C++ Memory Management
- Description: A note on the C++ memory management — stack vs heap,
malloc/freevsnew/delete, smart pointers (incl.weak_ptr), RAII, copy/move semantics, and polymorphic allocators (PMR) - My Notion Note ID: K2A-B1-6
- Created: 2018-09-27
- Updated: 2026-02-28
- License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io
Table of Contents
- 1. Stack vs Heap
- 2. Manual Memory Management:
malloc/freevsnew/delete - 3. Smart Pointers
- 4. RAII (Resource Acquisition Is Initialization)
- 5. Rule of Five and Rule of Zero
- 6. Polymorphic Allocators (PMR)
- 6.1 Mental Model: Allocator = Handle, Resource = Behavior
- 6.2 Why It's Called Polymorphic
- 6.3 Default Resource Pitfalls
- 6.4 Automatic Rebinding
- 6.5 Allocator Propagation Into Nested PMR Objects
- 6.6 Copy Construction Surprise
- 6.7 Move Can Degrade to Copy
- 6.8 Returning PMR Containers by Value
- 6.9 Making Your Own Type Allocator-Aware
- 6.10 Resource Selection: What to Use When
- 6.11 Practical Checklist for Introducing PMR
- 6.12 One-Liner Summary
- 7. Advanced Allocation: Placement New and
std::launder
1. Stack vs Heap
Understanding where objects live in memory is fundamental to writing correct and efficient C++ code.
| Aspect | Stack | Heap |
|---|---|---|
| Allocation | Automatic, at compile time | Manual, at runtime |
| Management | System-managed (LIFO) | Programmer-managed (or via smart pointers) |
| Lifetime | Scoped to the enclosing block | Until explicitly freed (or smart pointer destructs) |
| Speed | Fast (pointer bump) | Slower (allocator overhead, possible fragmentation) |
| Size | Limited (typically 1–8 MB) | Limited only by available system memory |
void example() {
int x = 10; // Stack allocation
int* p = new int(20); // Heap allocation
// ...
delete p; // Must manually free heap memory
} // x is automatically destroyed when scope ends
2. Manual Memory Management: malloc/free vs new/delete
Both allocate and deallocate heap memory, but new/delete are C++ operators that understand object lifecycles.
| Aspect | malloc/free |
new/delete |
|---|---|---|
| Language | C and C++ | C++ only |
| Type safety | Returns void*; requires cast |
Returns typed pointer T* |
| Constructors/Destructors | Does not call them | Calls constructor/destructor |
| What it does | Allocates/frees raw memory | Creates/destroys objects |
| Implementation | OS-level allocation | Typically uses malloc/free internally |
| Failure | Returns nullptr |
Throws std::bad_alloc (by default) |
// C-style (avoid in modern C++)
int* arr1 = (int*)malloc(10 * sizeof(int));
free(arr1);
// C++ style
int* arr2 = new int[10];
delete[] arr2; // Note: delete[] for arrays, delete for single objects
// Modern C++ (preferred)
auto arr3 = std::make_unique<int[]>(10); // No manual delete needed
3. Smart Pointers
Smart pointers (C++11) wrap raw pointers and manage their lifetime automatically via RAII. They eliminate most manual delete calls and prevent memory leaks.
3.1 Common Operations
#include <memory>
auto sp = std::make_shared<int>(42);
auto up = std::make_unique<int>(42);
*sp; // Dereference: access the value
sp->member; // Member access (for class types)
sp.get(); // Get the raw pointer (use sparingly)
3.2 std::shared_ptr
Shared ownership via reference counting. The managed object is destroyed when the last shared_ptr owning it is destroyed or reset.
#include <memory>
#include <iostream>
auto sp1 = std::make_shared<std::string>("hello");
auto sp2 = sp1; // Reference count: 2
std::cout << sp1.use_count() << std::endl; // 2
3.3 std::unique_ptr
Exclusive ownership. Cannot be copied, only moved. Lighter weight than shared_ptr (no reference counting overhead).
#include <memory>
auto up = std::make_unique<std::string>("hello");
// auto up2 = up; // ERROR: cannot copy unique_ptr
auto up2 = std::move(up); // OK: transfer ownership
// up is now nullptr
3.4 std::weak_ptr
std::weak_ptr<T> is a non-owning observer of an object managed by std::shared_ptr. It does not contribute to the reference count, so it cannot keep the object alive on its own. There are two main reasons to reach for it:
- Breaking cycles — two objects holding
shared_ptrto each other will never be destroyed (their reference counts never reach zero). Replacing one direction withweak_ptrbreaks the cycle. - Caches and observers — store a reference that should not extend the lifetime of the target.
To access the underlying object, call lock(), which atomically checks if the object is still alive and returns a (possibly empty) shared_ptr. Never use weak_ptr like a raw pointer; always go through lock().
#include <memory>
#include <iostream>
auto sp = std::make_shared<int>(42);
std::weak_ptr<int> wp = sp;
std::cout << wp.use_count() << std::endl; // 1 (weak_ptr doesn't count)
std::cout << wp.expired() << std::endl; // false
if (auto locked = wp.lock()) { // upgrade to shared_ptr
std::cout << *locked << std::endl; // 42
}
sp.reset(); // destroys the int
std::cout << wp.expired() << std::endl; // true
if (auto locked = wp.lock()) {
// not entered: locked is empty
} else {
std::cout << "object gone" << std::endl;
}
Breaking Cycles: Parent ↔ Child
A common cycle: a parent owns its children, and each child needs a back-pointer to its parent. If both pointers are shared_ptr, the cycle leaks both objects.
#include <memory>
#include <vector>
struct Node {
std::vector<std::shared_ptr<Node>> children;
std::weak_ptr<Node> parent; // NOT shared_ptr — breaks the cycle
};
auto root = std::make_shared<Node>();
auto child = std::make_shared<Node>();
root->children.push_back(child);
child->parent = root; // weak: doesn't keep root alive
// When 'root' goes out of scope, both Node objects are destroyed correctly.
If parent were shared_ptr<Node>, the parent would own the child and the child would own the parent — neither could ever be released.
3.5 Converting unique_ptr to shared_ptr
A unique_ptr can be implicitly converted to a shared_ptr. This is a common pattern in factory functions: return unique_ptr for flexibility, and callers can convert to shared_ptr if they need shared ownership.
#include <memory>
#include <string>
// Factory function returns unique_ptr
std::unique_ptr<std::string> createString() {
return std::make_unique<std::string>("Hello World");
}
int main() {
// Implicit conversion from temporary (moved automatically)
std::shared_ptr<std::string> sp1 = createString();
// Explicit move from lvalue
auto up = std::make_unique<std::string>("Hello");
std::shared_ptr<std::string> sp2 = std::move(up); // up is now nullptr
return 0;
}
Note: You cannot convert a shared_ptr back to a unique_ptr. The conversion is one-way.
3.6 unique_ptr<T[]> and Custom Deleters
std::unique_ptr has an array specialization for managing dynamically-allocated arrays:
auto arr = std::make_unique<int[]>(10); // allocates int[10], value-initialized (zero for int)
// for uninitialized: std::make_unique_for_overwrite<int[]>(10) (C++20)
arr[0] = 42;
arr[9] = 99;
// auto-deletes with delete[] (NOT delete) when arr goes out of scope
Don't use unique_ptr<T[]> for fixed-size arrays — std::array<T, N> is better. Use it only when the size is a runtime value and you can't use std::vector<T>.
Custom deleters
By default, unique_ptr uses delete (or delete[] for the array form). For non-new-allocated resources, supply a custom deleter:
// FILE* from C library: needs fclose
auto file_deleter = [](FILE* f) { if (f) std::fclose(f); };
std::unique_ptr<FILE, decltype(file_deleter)> file{
std::fopen("data.txt", "r"), file_deleter
};
// Or with a function-pointer deleter (saves storage in C++20):
std::unique_ptr<FILE, decltype(&std::fclose)> file2{
std::fopen("data.txt", "r"), &std::fclose
};
Custom deleters let unique_ptr wrap any C-style "open/close" handle: file pointers, sockets, OpenGL textures, OS handles, etc.
std::shared_ptr accepts custom deleters too, but they're stored in the control block (so the deleter type isn't part of the shared_ptr type — same shared_ptr<FILE> regardless of deleter).
3.7 std::enable_shared_from_this
A class managed by shared_ptr sometimes needs to hand out shared_ptr<This> from a member function. Constructing a fresh shared_ptr from this is a bug — it creates a separate control block, leading to double-deletion.
class Node {
public:
std::shared_ptr<Node> badGetSelf() {
return std::shared_ptr<Node>(this); // BUG: separate control block!
}
};
The right way: derive from std::enable_shared_from_this<T> and use shared_from_this().
class Node : public std::enable_shared_from_this<Node> {
public:
std::shared_ptr<Node> getSelf() {
return shared_from_this(); // safe: shares control block
}
};
auto n = std::make_shared<Node>();
auto same = n->getSelf(); // points to the same node, ref count = 2
Caveat: shared_from_this() only works after the object is owned by a shared_ptr. Calling it from the constructor or before any shared_ptr exists throws std::bad_weak_ptr.
3.8 make_shared vs make_unique vs new
Three ways to create a smart pointer:
std::unique_ptr<T> a = std::make_unique<T>(args...); // preferred
std::unique_ptr<T> b{new T(args...)}; // works, but verbose
std::shared_ptr<T> c = std::make_shared<T>(args...); // preferred
std::shared_ptr<T> d{new T(args...)}; // works, but slower
Why prefer make_*:
- Exception safety.
f(make_unique<A>(), make_unique<B>())is safe;f(unique_ptr<A>(new A), unique_ptr<B>(new B))can leak if onenewthrows after the other allocates. - No naked
new. The codebase becomes easier to audit for leaks: just grep fornew. - Faster
make_shared.make_sharedallocates the object and the control block in one allocation.shared_ptr<T>(new T)allocates twice (the object first vianew, then the control block when theshared_ptris constructed).
When NOT to use make_*:
- Custom deleters.
make_*doesn't accept them; useunique_ptr<T, D>(p, d)directly. - Aliasing constructor of
shared_ptr. Use the constructor directly when you needshared_ptr<int>pointing into a member of an existingshared_ptr<Struct>. - Long-lived weak_ptrs to a short-lived object.
make_sharedkeeps the whole allocation alive as long as anyweak_ptrexists (the control block is in the same block as the object). For occasional leaks-of-weak-pointers patterns, plainnew+shared_ptrreleases the object memory earlier.
4. RAII (Resource Acquisition Is Initialization)
RAII is a core C++ idiom: tie resource lifetime to object lifetime. Acquire resources in constructors, release them in destructors. When the object goes out of scope, cleanup happens automatically.
Smart pointers are the most common example, but RAII applies to any resource: file handles, locks, database connections, etc.
#include <fstream>
#include <mutex>
void example(std::mutex& mtx) {
std::lock_guard<std::mutex> lock(mtx); // Lock acquired
// ... critical section ...
} // Lock automatically released when lock_guard is destroyed
void readFile() {
std::ifstream file("data.txt"); // File opened
// ... read from file ...
} // File automatically closed when ifstream is destroyed
5. Rule of Five and Rule of Zero
For the language-level mechanics of copy/move constructors, lvalue and rvalue references, value categories, and the special member functions table, see K2A-B1-5 C++ Reference, Copy, Move and Forwarding. This section focuses on applying those mechanics when designing classes that own resources.
Classes that own resources (heap memory, file handles, sockets, mutexes) need a clear policy for copy, move, and destruction. The compiler-generated copy/move/destroy do member-wise operations, which is correct when every member already manages itself (smart pointers, STL types) but wrong when a member is a raw pointer to an owned resource.
5.1 The Rule of Five
If your class manages a resource that the compiler cannot copy/move correctly on its own, you typically need to write all five "non-default" special members (destructor + the four copy/move operations) consistently. This is the Rule of Five (Rule of Three before C++11 added move).
#include <algorithm>
#include <cstddef>
class Buffer {
public:
explicit Buffer(std::size_t n)
: size_(n), data_(new int[n]) {}
~Buffer() { delete[] data_; }
// Copy constructor: deep copy
Buffer(const Buffer& other)
: size_(other.size_), data_(new int[other.size_]) {
std::copy(other.data_, other.data_ + size_, data_);
}
// Copy assignment: deep copy with self-assignment guard
Buffer& operator=(const Buffer& other) {
if (this == &other) return *this;
delete[] data_;
size_ = other.size_;
data_ = new int[size_];
std::copy(other.data_, other.data_ + size_, data_);
return *this;
}
// Move constructor: steal the pointer, leave 'other' empty
Buffer(Buffer&& other) noexcept
: size_(other.size_), data_(other.data_) {
other.size_ = 0;
other.data_ = nullptr;
}
// Move assignment: same pattern
Buffer& operator=(Buffer&& other) noexcept {
if (this == &other) return *this;
delete[] data_;
size_ = other.size_;
data_ = other.data_;
other.size_ = 0;
other.data_ = nullptr;
return *this;
}
private:
std::size_t size_;
int* data_;
};
Why noexcept on move? STL containers like std::vector only use move (instead of copy) during reallocation if the move operations are noexcept. Without it, you silently lose performance on vector::push_back reallocations.
Implicit deletion rules. Declaring any of the five disables some of the others. Most importantly, declaring a custom destructor suppresses the implicit move constructor and move assignment. That is why "Rule of Five" exists: once you write one, you should consider all five together.
5.2 The Rule of Zero (Preferred)
The best resource-managing class is one that doesn't manage resources directly. Use existing RAII types (std::unique_ptr, std::shared_ptr, std::vector, std::string) as members, and the compiler-generated copy/move/destroy will do the right thing automatically — no special member functions needed.
#include <vector>
class Buffer {
std::vector<int> data_; // RAII member: manages itself
public:
explicit Buffer(std::size_t n) : data_(n) {}
// No destructor, no copy/move ctor or assignment — defaults are correct.
};
The Rule of Five is for the rare case where you have to wrap a non-RAII resource (a C library handle, an OS primitive, hardware). The Rule of Zero is the default in modern C++.
5.3 Quick Decision Guide
- Class doesn't own anything → Rule of Zero. Don't write any of the five.
- Class owns a single resource you can wrap in a smart pointer or container → Rule of Zero. Use the wrapper (
unique_ptr,vector, …). - Class wraps a raw, non-RAII resource (C handle, hardware, custom protocol) → Rule of Five. Write all five carefully, mark moves
noexcept. - Class is non-copyable but movable (e.g., a
unique_ptr-like type) → Default the destructor and move operations,=deletethe copy operations. - Class is non-copyable and non-movable (e.g.,
std::mutex, types pinning to a memory address) →=deleteboth copy and move; default the destructor.
6. Polymorphic Allocators (PMR)
std::pmr (C++17, <memory_resource>) is a unified allocator framework that lets containers select an allocation strategy at runtime instead of at compile time.
6.1 Mental Model: Allocator = Handle, Resource = Behavior
std::pmr::polymorphic_allocator<T>is an STL-compatible allocator that forwards every allocation to a runtime-selectedstd::pmr::memory_resource.- The resource provides the actual allocation strategy (pooling, monotonic, tracking, limits, etc.).
#include <memory_resource>
std::pmr::monotonic_buffer_resource R;
std::pmr::polymorphic_allocator<int> a{&R};
auto* r = a.resource(); // points to R
// a.allocate(n) will allocate via R
6.2 Why It's Called Polymorphic
PMR is "polymorphic" because allocations go through a runtime polymorphic interface:
std::pmr::memory_resourceis an abstract base class with virtual allocation functions.polymorphic_allocatorstores amemory_resource*and calls it virtually.
The key outcome: the container type stays the same while the allocation strategy changes at runtime.
Contrast with classic allocators (compile-time). Classic allocators are part of the container's type:
std::vector<int, MyAlloc>andstd::vector<int, YourAlloc>are different types.
With PMR:
std::pmr::vector<int>is one type, and the resource is chosen at runtime.
6.3 Default Resource Pitfalls
A default-constructed PMR allocator or container uses std::pmr::get_default_resource(), which is typically new_delete_resource() (i.e. plain heap). This is a common source of unexpected heap allocations.
#include <memory_resource>
#include <vector>
std::pmr::vector<int> v; // uses get_default_resource() — heap!
Testing trick to catch accidental allocations: temporarily set the default resource to std::pmr::null_memory_resource() so any allocation throws std::bad_alloc and fails fast.
std::pmr::set_default_resource(std::pmr::null_memory_resource());
6.4 Automatic Rebinding
Even if you store or accept polymorphic_allocator<std::byte>, it can be converted ("rebound") to allocate other types as needed. This is why allocator-aware classes often use:
using allocator_type = std::pmr::polymorphic_allocator<std::byte>;
6.5 Allocator Propagation Into Nested PMR Objects
If you construct a PMR container with a resource, nested allocator-aware elements (like std::pmr::string) will automatically use the same resource. This is called uses-allocator construction.
#include <memory_resource>
#include <string>
#include <vector>
std::pmr::monotonic_buffer_resource R;
std::pmr::vector<std::pmr::string> v(&R);
v.emplace_back("hello"); // the string's allocation comes from R
6.6 Copy Construction Surprise
std::pmr::polymorphic_allocator::select_on_container_copy_construction() is specified to return a default-constructed allocator (using the default resource) — so a copy-constructed PMR container always uses the default resource, not the source's resource.
std::pmr::monotonic_buffer_resource R1;
std::pmr::vector<int> a(&R1);
std::pmr::vector<int> b = a; // b uses the DEFAULT resource, not R1
The safer explicit copy with a chosen resource:
std::pmr::vector<int> b{a, &R1}; // copy using R1
Practical guideline: avoid copy-by-value patterns with PMR-heavy types unless you're deliberate about the resource.
6.7 Move Can Degrade to Copy
Allocator-aware move operations may fall back to allocating and copying when the source and destination resources differ.
std::pmr::monotonic_buffer_resource R1, R2;
std::pmr::vector<int> a(&R1);
std::pmr::vector<int> b(&R2);
b = std::move(a); // may copy if R1 != R2
Guideline: keep objects that are moved between each other on the same resource to ensure moves stay cheap.
6.8 Returning PMR Containers by Value
Returning by value isn't forbidden, but you usually need to pass the resource explicitly or use output parameters.
-
Take a resource parameter:
std::pmr::vector<int> make_vec(std::pmr::memory_resource* r) { std::pmr::vector<int> v(r); v.push_back(1); return v; // OK: v already uses r } -
Output parameter (often preferred in strict PMR codebases):
void make_vec(std::pmr::vector<int>& out) { out.clear(); out.push_back(1); }
Guideline: avoid pass-by-value and return-by-value if it risks creating temporaries that use the default resource.
6.9 Making Your Own Type Allocator-Aware
To participate in allocator propagation, define allocator_type and provide allocator-aware constructors that pass the allocator to PMR members.
#include <memory_resource>
#include <string>
class MyType {
public:
using allocator_type = std::pmr::polymorphic_allocator<std::byte>;
MyType() = default;
explicit MyType(allocator_type alloc)
: name_(alloc), alloc_(alloc) {}
MyType(MyType const& other, allocator_type alloc = {})
: name_(other.name_, alloc), alloc_(alloc) {}
MyType(MyType&& other, allocator_type alloc)
: name_(std::move(other.name_), alloc), alloc_(alloc) {}
MyType& operator=(MyType const& other) {
name_ = other.name_; // do NOT change alloc_
return *this;
}
MyType& operator=(MyType&& other) {
name_ = std::move(other.name_); // do NOT change alloc_
return *this;
}
private:
std::pmr::string name_;
allocator_type alloc_;
};
Key rule: if you store the allocator, assignment must not change which allocator the object uses.
6.10 Resource Selection: What to Use When
std::pmr::monotonic_buffer_resource— per-iteration scratch ("allocate a bunch, free all at once"); very fast.std::pmr::unsynchronized_pool_resource— many small allocations, single-thread only.std::pmr::synchronized_pool_resource— pooled and thread-safe (slower than unsynchronized).std::pmr::null_memory_resource— tests and guards; fail fast on unexpected allocations.- Custom
memory_resource— for tracking, limits, logging, etc.
Lifetime rule: a PMR container must not outlive the resource it allocates from.
6.11 Practical Checklist for Introducing PMR
- Identify hot allocations:
std::vector,std::string, maps, temporary buffers. - Convert to PMR versions:
std::pmr::vector,std::pmr::string, etc. - Create a suitable
memory_resourceat the right scope (per-cycle, per-call, or per-component). - Construct top-level objects with that resource; rely on allocator propagation for nested objects.
- Add tests that set the default resource to
null_memory_resource()to catch accidental default allocations.
6.12 One-Liner Summary
PMR keeps container types stable but makes their allocation strategy runtime-pluggable via a polymorphic memory_resource, enabling performance gains, tracking, and strict "no accidental heap allocations" guarantees.
7. Advanced Allocation: Placement New and std::launder
7.1 Placement New
The "placement new" operator constructs an object in pre-allocated memory without allocating on its own. The header is <new>.
#include <new>
#include <cstddef>
alignas(int) std::byte buffer[sizeof(int)];
int* p = new (buffer) int{42}; // construct an int in 'buffer'
std::cout << *p; // 42
std::destroy_at(p); // explicit destroy (no-op for int, but mandatory for non-trivial types)
// no delete: 'buffer' is automatic storage
Common uses:
- Custom allocators —
std::vectoruses placement new internally to construct elements in its allocated capacity. - Object pools — pre-allocate a big buffer, construct objects in slots.
- Embedded systems — direct control over where an object lives (specific memory regions, hardware buffers).
Placement new is low-level. Most code should use std::vector, std::pmr (see § 6), or std::construct_at (C++20) instead.
7.2 std::launder (C++17)
std::launder<T>(p) tells the compiler "I just constructed a new object at p's address, even though p has the type of the previous object that was there." It refreshes the compiler's tracking of object identity.
#include <new>
struct Widget { const int id; };
alignas(Widget) std::byte buf[sizeof(Widget)];
Widget* p = new (buf) Widget{1};
p->~Widget(); // destroy
Widget* q = new (buf) Widget{2}; // construct a new Widget in same place
// p still points to the address but to a DIFFERENT object now.
// Reading *p directly is technically UB because of the const member.
// std::launder fixes that:
std::cout << std::launder(p)->id; // 2
You only need std::launder when you've reused storage for a different object AND the old object isn't "transparently replaceable" through the old pointer. Replacement is not transparent in any of these cases:
- The original object's type contained
constor reference subobjects. - The original object was a base-class subobject.
- The original object was a complete object that's
const-qualified.
For most code, just use the new pointer (q above). std::launder is for library code that has to expose pointers obtained before reuse.
7.3 std::construct_at and std::destroy_at
C++20 added std::construct_at and std::destroy_at as constexpr-friendly wrappers around placement new and explicit destructor calls.
#include <memory>
std::byte buf[sizeof(Widget)];
Widget* p = std::construct_at(reinterpret_cast<Widget*>(buf), 1);
std::destroy_at(p);
Use these in constexpr contexts (where placement new wasn't usable before C++20) and in allocator implementations.