<< Home

Chapter 19: Dynamic Memory and Smart Pointers

Dynamic storage lets programs create objects whose lifetime is not tied to scope; this gives flexibility and also risk. This chapter shows how to use new and delete safely, how to prefer smart pointers such as unique_ptr, shared_ptr, and weak_ptr, how to construct with make_… helpers and custom deleters, and how to build object pools or plug in custom allocators when you need tighter control.

new, delete, and placement new

new acquires storage and constructs an object; delete calls the destructor and releases the storage. Correct pairing is essential. Placement new constructs an object in a supplied memory buffer; this is useful for arenas and custom allocators. When you use placement new, you must also destroy the object manually, since delete does not know about the buffer you provided.

Using new and delete

new T(args) returns a pointer to a newly constructed T. Use delete p only for pointers that came from a matching nonarray new, and delete[] p only for pointers that came from new T[n]. Mixing these is undefined behavior. Prefer smart pointers so that destruction is automatic.

// Basic use (prefer smart pointers in real code)
struct Widget {
  std::string name;
  Widget(std::string s) : name(std::move(s)) {}
};

Widget* p = new Widget("w1");
delete p;

// Arrays require delete[]
int* a = new int[4]{1,2,3,4};
delete[] a;
⚠️ If a constructor throws, the memory from new is released automatically; you do not call delete. If construction succeeds but later code throws, smart pointers help you avoid leaks.

Placement new and manual destruction

Placement new constructs at a specific address. You are responsible for storage lifetime. Destroy with an explicit destructor call, then release or reuse the buffer yourself.

#include <new> // std::align_val_t and placement new

alignas(Widget) unsigned char buf[sizeof(Widget)];
Widget* pw = new (buf) Widget("in-place");  // construct in buf
pw->~Widget();                               // manual destruction
// buf remains allocated on the stack; nothing to delete

Overloads, alignment, and std::nothrow

Types can provide class-specific operator new to route allocations; do this only for measured reasons. For over-aligned types, the implementation will request suitable alignment. new(std::nothrow) T returns nullptr on failure; most code should prefer regular new with exceptions enabled.

💡 A simple rule: write code that rarely touches raw new and delete; construct objects with smart pointers or containers instead.

unique_ptr for exclusive ownership

std::unique_ptr<T> represents sole ownership. The pointed object dies when the unique_ptr is destroyed. It is movable and not copyable; move transfers ownership. Use it for clear lifetimes and to express that sharing is not allowed.

Basics and custom deleters

unique_ptr can store a custom deleter type which runs at destruction time. This is how you manage resources that need functions other than delete for cleanup.

#include <memory>
#include <cstdio>

struct FileCloser {
  void operator()(std::FILE* f) const { if (f) std::fclose(f); }
};

std::unique_ptr<std::FILE, FileCloser> file(std::fopen("data.txt","r"));
// file closes automatically in its destructor

Factory helpers and ownership transfer

Create with std::make_unique<T>(args); this is safer than calling new directly. To pass ownership out of a function, return the unique_ptr or use std::move.

std::unique_ptr<Widget> make_widget() {
  return std::make_unique<Widget>("w");
}

auto up = make_widget();    // ownership lives in up
auto sink = std::move(up);  // transfer; up becomes empty
⚠️ Do not create two unique_ptr objects that manage the same raw pointer; this double-deletes. Let the type system enforce the rules instead.

Using unique_ptr in containers

Standard containers store unique_ptr cleanly because the pointer is movable. This expresses ownership of heterogeneous objects while keeping value semantics for the container.

std::vector<std::unique_ptr<Widget>> v;
v.push_back(std::make_unique<Widget>("a"));
v.push_back(std::make_unique<Widget>("b"));

shared_ptr and weak_ptr for shared lifetime

std::shared_ptr<T> maintains a control block with strong and weak counts. When the last shared_ptr to an object goes away, the object is destroyed; when the last weak_ptr also vanishes, the control block is freed. Use weak_ptr to break cycles and to observe without extending lifetime.

Creation and control blocks

Prefer std::make_shared to construct the control block and the object in one allocation. If you need a custom deleter or a custom allocator for the control block, build the shared_ptr from a unique_ptr or use std::allocate_shared.

#include <memory>

auto sp = std::make_shared<Widget>("pooled");
std::weak_ptr<Widget> wp = sp;       // observation without ownership

if (auto locked = wp.lock()) {
  // use *locked safely
}

Cycles and enable_shared_from_this

Two shared_ptr owners that point at each other create a cycle; the counts never drop to zero. Convert one side to weak_ptr. When a type needs to hand out a shared_ptr to itself, inherit from std::enable_shared_from_this<T> and call shared_from_this() on an instance already managed by a shared_ptr.

struct Node : std::enable_shared_from_this<Node> {
  std::vector<std::shared_ptr<Node>> children;
  std::weak_ptr<Node> parent;
  std::shared_ptr<Node> add_child(std::shared_ptr<Node> c) {
    c->parent = shared_from_this();   // break the cycle with weak_ptr
    children.push_back(std::move(c));
    return children.back();
  }
};

Aliasing and thread safety

The aliasing constructor lets a shared_ptr share ownership of one control block while pointing at a subobject. Reference count updates are atomic; object mutation still needs your own synchronization.

auto spw = std::make_shared<Widget>("root");
auto name_view = std::shared_ptr<std::string>(spw, &spw->name); // aliasing
PointerOwnershipCost patternTypical use
unique_ptrExclusiveMove only; zero overhead on countClear single owner
shared_ptrSharedAtomic refcount; possible extra allocGraphs with multiple readers
weak_ptrNoneControl-block onlyBreak cycles; observe
💡 When you do not need sharing, choose unique_ptr. This reduces contention and improves locality.

make functions and custom deleters

std::make_unique, std::make_shared, and std::allocate_shared help with exception safety and speed. They construct the object and its manager in one expression; this avoids temporary raw pointers between steps and in many cases reduces allocations.

Why to prefer make_unique and make_shared

Allocation and construction happen together; if construction throws, nothing leaks. For shared_ptr, make_shared creates the object and control block together; this typically improves cache locality.

auto a = std::make_unique<Widget>("safe");
auto b = std::make_shared<Widget>("compact");

Custom deleters

Use custom deleters for resources that need special cleanup; store them in the smart pointer so they run automatically. For unique_ptr, the deleter type is part of the pointer type. For shared_ptr, the deleter is kept in the control block.

// unique_ptr with lambda deleter
auto close_fd = [](int* fd){ if (fd) { /* close(*fd); */ } };
std::unique_ptr<int, decltype(close_fd)> fd_holder(new int(42), close_fd);

// shared_ptr with custom deleter
auto deleter = [](Widget* p){ /* custom release */ delete p; };
std::shared_ptr<Widget> sp(new Widget("x"), deleter);
⚠️ make_shared does not accept a deleter; if you need a custom deleter, build the shared_ptr with new or convert from unique_ptr, or use allocate_shared with a custom allocator for the control block when that is the real goal.

allocate_shared and memory resources

std::allocate_shared uses an allocator to obtain both the control block and the object storage. This is useful when you want all related allocations to come from a pool or a monotonic arena.

#include <memory_resource>

std::byte arena[4096];
std::pmr::monotonic_buffer_resource mbr(arena, sizeof(arena));
auto sp = std::allocate_shared<Widget>(std::pmr::polymorphic_allocator<Widget>{&mbr}, "pmr");

Object pools and custom allocators

Pooling trades generality for speed and predictability. A pool reserves a large region once, then hands out fixed-size or variable-size blocks quickly. Standard containers can use pools through the allocator interface, and the <memory_resource> toolbox offers ready-to-use arenas for pmr containers.

A tiny fixed-size pool example

This sketch shows a free-list pool for same-sized nodes. It uses placement new for construction in preallocated storage; destruction returns the block to the free list.

struct Node { int x; Node* next; /* … */ };

class NodePool {
  std::vector<unsigned char> storage;
  Node* free_list = nullptr;

public:
  explicit NodePool(std::size_t n) : storage(n * sizeof(Node)) {
    for (std::size_t i = 0; i < n; ++i) {
      auto p = reinterpret_cast<Node*>(&storage[i * sizeof(Node)]);
      p->next = free_list;
      free_list = p;
    }
  }

  template<class... Args>
  Node* create(Args&&... args) {
    if (!free_list) throw std::bad_alloc{};
    Node* p = free_list;
    free_list = free_list->next;
    return new (p) Node{std::forward<Args>(args)...};
  }

  void destroy(Node* p) {
    if (!p) return;
    p->~Node();
    p->next = free_list;
    free_list = p;
  }
};
💡 Pooling helps when allocations are many, small, and frequent; it also reduces fragmentation and improves cache locality.

pmr containers with arena resources

std::pmr lets you provide a memory resource to containers at runtime. A monotonic_buffer_resource grows by carving from upstream only when needed; release happens in one shot when the resource is destroyed or release() is called.

#include <memory_resource>
#include <string>
#include <vector>

std::byte buf[8192];
std::pmr::monotonic_buffer_resource arena(buf, sizeof buf);
std::pmr::vector<std::pmr::string> names{&arena};
names.emplace_back("alpha");
names.emplace_back("beta");

Writing a standard-conforming allocator

Allocators supply and free raw storage for objects of a given type. In modern C++, prefer pmr to custom allocator classes unless you need compile-time allocator types for legacy APIs. If you do write one, implement the required nested aliases and allocate/deallocate with correct alignment.

template<class T>
struct BumpAlloc {
  using value_type = T;
  std::byte* cur;
  std::byte* end;

  BumpAlloc(std::byte* b, std::size_t n) : cur(b), end(b + n) {}
  template<class U> BumpAlloc(const BumpAlloc<U>& other) : cur(other.cur), end(other.end) {}

  T* allocate(std::size_t n) {
    std::size_t bytes = n * sizeof(T);
    if (cur + bytes > end) throw std::bad_alloc{};
    T* p = reinterpret_cast<T*>(cur);
    cur += bytes;
    return p;
  }

  void deallocate(T*, std::size_t) { /* bump alloc frees in bulk */ }
};
⚠️ A bump allocator does not free individual objects; it frees everything at once. Use it for lifetimes that end together, such as parsing phases.

Choosing the right tool

If ownership is single, use unique_ptr. If ownership is shared, use shared_ptr with weak_ptr to avoid cycles. If allocation hot paths show up in profiles, consider make_shared or arena-backed pmr containers. If you need deterministic reuse for fixed-size objects, write or use a pool. Measure before and after each change to confirm the benefit.