The program at the center of this guide contains eight memory errors, and it compiles. With -Wall -Wextra, GCC 13 warned about four of them (one only when optimizing) and Clang 18 about two. Run without any tools, three of the eight finished with exit status 0 and no message; one read freed memory and printed the value that used to be there, as if nothing had happened. AddressSanitizer reported all eight, and the Clang build named each one correctly. That gap between “compiles” and “correct” is what C++ memory management is about.
This guide covers where C++ objects live and how long they last, new and delete and what happens when allocation fails, the memory errors that survive compilation and the tools that find them, RAII and ownership, copying objects that own memory, and what smart pointers and containers do and do not fix. Every program was compiled and run for this article on Ubuntu 24.04 (x86-64) with GCC 13.3 and Clang 18.1.3 under -std=c++17 and -std=c++20 with -Wall -Wextra -pedantic. The correct examples produce no warnings and run clean under AddressSanitizer and UndefinedBehaviorSanitizer; the broken ones are shown with the diagnostics they produced, or with none where the compilers stayed silent. All output is captured verbatim, and the code is in a GitHub repository whose build repeats these checks on each commit.
The Short Answer
Does C++ have garbage collection? No. Memory is released when the object that owns it is destroyed, at a point the program determines. C++11 added a minimal interface for optional garbage collectors; no major implementation used it, and C++23 removed it (P2186R2).
Should I use new and delete? Rarely in application code. Use local variables, standard containers such as std::vector and std::string, and std::make_unique when an object must live on the heap. The C++ Core Guidelines put it as rule R.11: avoid calling new and delete explicitly.
How do I find memory bugs? Build your tests with -fsanitize=address (GCC and Clang) or run them under Valgrind. In the tests below, both reported all eight errors; the compilers’ warnings caught at most half.
What Is Memory Management in C++?
Memory management in C++ is the work of deciding where each object’s storage comes from, how long the object lives, and who releases the storage when it ends. Local and global objects are managed by the language. Dynamically allocated objects are managed by the program, either directly with new and delete or, in modern C++, through owning types such as std::vector, std::string and std::unique_ptr that release memory in their destructors.
Every object has a storage duration, which fixes when its storage is obtained and released. cppreference’s page on storage duration gives the formal rules; in practice there are four:
| Storage duration | How you get it | Lifetime ends | Who releases it |
|---|---|---|---|
| Automatic | A local variable: int n;, std::string s; | At the closing brace of its scope | The compiler, by running the destructor |
| Static | A global, a static local, a static class member | When the program exits normally | The runtime, in reverse order of construction |
| Thread | A variable declared thread_local | When its thread exits | The runtime |
| Dynamic | new, new[], std::make_unique, a container’s allocator | When delete runs, or the owning object does it | The program |
The first three cannot leak: the language ends their lifetimes for you. Every memory error in this guide involves dynamic storage, or a pointer or reference that outlived the object it referred to. If pointers themselves are new to you, start with pointers in C++.
Stack and Heap
“Stack” and “heap” are not words the C++ standard uses, but they describe how mainstream implementations provide automatic and dynamic storage, and they explain most of the trade-offs.
| Aspect | Stack (automatic storage) | Heap or free store (dynamic storage) |
|---|---|---|
| Allocation | Moving the stack pointer, set up on function entry | A call into the allocator (operator new, then usually malloc) |
| Lifetime | Tied to a scope | Until released explicitly or by an owner |
| Size limit | Small and fixed per thread; ulimit -s reported 8,192 KiB on the test machine | Bounded by address space and available memory |
| Failure mode | Stack overflow crashes the program | new throws std::bad_alloc |
| Typical errors | Returning a pointer or reference to a local | Leaks, use after free, double free, overflow |
A std::vector<int> declared as a local variable uses both: the vector object itself (three pointers on libstdc++) is on the stack, and the elements it manages are on the heap. When the vector goes out of scope, its destructor frees the elements. That pattern, an object with automatic lifetime that owns dynamic memory, is the basis of modern C++ memory management, and it has a name: RAII, covered below.
new and delete
A new expression does two things: it obtains raw storage by calling an allocation function (operator new), then constructs an object in it. delete reverses both: it runs the destructor, then returns the storage. Arrays have their own pair, new[] and delete[], and the two pairs must not be mixed.
// new_delete.cpp - the four forms of new and delete, and what happens when
// an allocation cannot be satisfied.
#include <cstddef>
#include <iostream>
#include <new>
#include <string>
#include <utility>
// Storing the pointer in a volatile variable stops the optimizer from removing
// an allocation whose result is otherwise unused (C++ allows that removal).
char* volatile g_seen = nullptr;
void touch(char* p) { g_seen = p; }
struct Sensor {
explicit Sensor(std::string n) : name(std::move(n)) { std::cout << " construct " << name << '\n'; }
~Sensor() { std::cout << " destroy " << name << '\n'; }
std::string name;
};
int main()
{
std::cout << "single object:\n";
Sensor* s = new Sensor("thermo"); // allocate, then construct
delete s; // destroy, then deallocate
std::cout << "array of objects:\n";
Sensor* row = new Sensor[2]{Sensor("left"), Sensor("right")};
delete[] row; // destroys both, last element first
std::cout << "scalars:\n";
int* uninitialized = new int[3]; // values are indeterminate: do not read them
int* zeroed = new int[3](); // () value-initializes: all zero
std::cout << " zeroed: " << zeroed[0] << ' ' << zeroed[1] << ' ' << zeroed[2] << '\n';
delete[] uninitialized;
delete[] zeroed;
std::cout << "allocation failure:\n";
std::size_t huge = std::size_t{1} << 46; // 64 TiB
try {
char* p = new char[huge];
touch(p);
delete[] p;
std::cout << " allocated 64 TiB (unexpected)\n";
} catch (const std::bad_alloc&) {
std::cout << " new threw std::bad_alloc\n";
}
char* q = new (std::nothrow) char[huge]; // returns nullptr instead of throwing
touch(q);
std::cout << " new (std::nothrow) returned " << (q ? "a pointer" : "nullptr") << '\n';
delete[] q; // deleting nullptr is a no-op
}
Output:
single object:
construct thermo
destroy thermo
array of objects:
construct left
construct right
destroy right
destroy left
scalars:
zeroed: 0 0 0
allocation failure:
new threw std::bad_alloc
new (std::nothrow) returned nullptr
What the output shows:
- Construction follows allocation; destruction precedes deallocation.
delete sprinteddestroy thermobefore the memory was returned. delete[]destroys array elements in reverse order:rightbeforeleft.new int[3]leaves the values indeterminate;new int[3]()zeroes them. Reading an element of the first array before writing it is undefined behavior, which is why the program does not print them.- A failed allocation throws
std::bad_alloc. The 64 TiB request could not be satisfied, sonewthrew.new (std::nothrow)reports the same failure by returningnullptr, which you then have to check. - Deleting a null pointer does nothing, so
delete[] qis safe even thoughqis null.
Two things nearly made this example lie. Without the touch() call, Clang 18 at -O2 removed both 64 TiB allocations entirely and the program printed allocated 64 TiB (unexpected) and new (std::nothrow) returned a pointer; GCC 13 at -O2 removed the throwing one. C++ allows a compiler to omit an allocation whose result is not used, so a test of allocation failure has to use the pointer somehow. And under AddressSanitizer the same request does not throw at all: ASan’s allocator rejects anything over its 1 TiB maximum with allocation-size-too-big and stops the program. Test allocation-failure paths in a build without ASan.
malloc and free in C++
malloc and free still exist in C++, but they allocate bytes, not objects: malloc runs no constructor and free runs no destructor. They also report failure by returning null rather than throwing.
| Aspect | new / delete | malloc / free |
|---|---|---|
| Returns | A typed pointer to a constructed object | void* to uninitialized bytes |
| Constructors and destructors | Runs them | Does not |
| Failure | Throws std::bad_alloc (or returns nullptr with std::nothrow) | Returns NULL |
| Size | Computed from the type | Passed in bytes |
| Mixing | free on memory from new is undefined behavior | delete on memory from malloc is undefined behavior |
Use malloc in C++ only to interoperate with a C API that will call free on the result, or the reverse. For the C side of the story, see malloc vs calloc in C.
The Memory Errors That Compile
Here is the test program. Each function contains one error; a command-line argument picks which one runs, so each can be tested on its own.
// memory_errors.cpp - eight memory errors that compile, one per command-line mode.
// Run it plainly to see what the program does, then build it with
// -fsanitize=address, or run it under Valgrind, to see what the tools report.
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
namespace {
[[gnu::noinline]] int* make_scores(std::size_t n)
{
int* p = new int[n];
for (std::size_t i = 0; i < n; ++i)
p[i] = static_cast<int>(i);
return p;
}
void leak()
{
int* scores = make_scores(100);
std::printf("first score %d\n", scores[0]);
} // no delete[]: 400 bytes lost
void use_after_free()
{
int* scores = make_scores(100);
delete[] scores;
std::printf("read after delete[]: %d\n", scores[10]);
}
void double_free()
{
int* scores = make_scores(100);
delete[] scores;
delete[] scores; // second release of the same block
std::printf("deleted twice\n");
}
void mismatched_delete()
{
std::string* names = new std::string[3]{"ada", "grace", "linus"};
std::printf("first name %s\n", names[0].c_str());
delete names; // allocated with new[], needs delete[]
}
void heap_overflow()
{
std::size_t n = 10;
int* scores = make_scores(n);
for (std::size_t i = 0; i <= n; ++i) // <= writes one element past the end
scores[i] = 0;
std::printf("cleared %zu scores\n", n);
delete[] scores;
}
const std::string& longest(const std::vector<std::string>& words)
{
std::string best; // local: destroyed when the function returns
for (const auto& w : words)
if (w.size() > best.size())
best = w;
return best; // returns a reference to a dead object
}
void return_local()
{
const std::vector<std::string> words{"heap", "stack", "allocator"};
const std::string& best = longest(words);
std::printf("longest word: %s\n", best.c_str());
}
void invalidated_pointer()
{
std::vector<int> values{1, 2, 3};
int* first = &values[0]; // points into the vector's buffer
values.push_back(4); // may reallocate and free that buffer
std::printf("first value: %d\n", *first);
}
void delete_non_heap()
{
int local = 42;
int* p = &local;
std::printf("deleting a stack address\n");
delete p; // p was never returned by new
}
struct Mode { const char* name; void (*run)(); };
constexpr Mode modes[] = {
{"leak", leak},
{"use-after-free", use_after_free},
{"double-free", double_free},
{"mismatched-delete", mismatched_delete},
{"heap-overflow", heap_overflow},
{"return-local", return_local},
{"invalidated-pointer", invalidated_pointer},
{"delete-non-heap", delete_non_heap},
};
} // namespace
int main(int argc, char** argv)
{
if (argc == 2)
for (const Mode& m : modes)
if (std::strcmp(argv[1], m.name) == 0) {
m.run();
return 0;
}
std::fprintf(stderr, "usage: memory_errors <mode>\nmodes:");
for (const Mode& m : modes)
std::fprintf(stderr, " %s", m.name);
std::fprintf(stderr, "\n");
return 2;
}
Each mode was compiled with both compilers at -O0 and -O2 with -Wall -Wextra, run plainly in each of those four builds, run under AddressSanitizer (-g -O0, both compilers), and run under Valgrind 3.22 (the GCC -O0 build). The figure summarizes what happened.
The same results as a table:
| Error | GCC 13 -Wall -Wextra | Clang 18 -Wall -Wextra | Plain run, four builds | AddressSanitizer | Valgrind |
|---|---|---|---|---|---|
| Memory leak | No warning | No warning | Exit 0, no message | detected memory leaks, 400 bytes | definitely lost: 400 bytes |
| Use after free | No warning | No warning | Exit 0; printed the old value, 10 | heap-use-after-free | Invalid read of size 4 |
| Double free | -Wuse-after-free, at -O2 only | No warning | Aborted: double free detected in tcache 2 | attempting double-free | Invalid free() |
new[] with delete | -Wmismatched-new-delete | -Wmismatched-new-delete | Aborted: munmap_chunk(): invalid pointer | bad-free | Invalid free() and 104 bytes lost |
| Heap overflow | No warning | No warning | Three builds aborted inside malloc later; Clang -O2 exited 0 | heap-buffer-overflow | Invalid write of size 4 |
| Reference to a local | -Wreturn-local-addr | -Wreturn-stack-address | GCC builds crashed; Clang -O2 printed the right word, Clang -O0 printed (null) | Clang: stack-use-after-return; GCC: SEGV on address 0 | Invalid read of size 8 |
Pointer into a vector after push_back | No warning | No warning | Exit 0; printed a different garbage value each run | heap-use-after-free | Invalid read of size 4 |
delete of a stack address | -Wfree-nonheap-object | No warning | Crashed in every run, with a glibc message or a segmentation fault | Clang: bad-free; GCC: see below | Invalid free() |
Plain-run results were repeated three times per build and were stable except where the table says otherwise. They describe undefined behavior on one machine with one C library; another system can do something different, including nothing visible.
Four results deserve a closer look.
A use after free can print the right answer. After delete[] scores, reading scores[10] printed 10, the value the array held before it was freed, in all four builds and all twelve runs. The allocator had not reused the block yet. A test that checks the printed value passes. AddressSanitizer stops at the read and names the line (excerpt):
==3661==ERROR: AddressSanitizer: heap-use-after-free on address 0x514000000068 at pc 0x55d4dab5a935 bp 0x7fffbf3fd0a0 sp 0x7fffbf3fd090
READ of size 4 at 0x514000000068 thread T0
#0 0x55d4dab5a934 in use_after_free src/memory_errors.cpp:29
#1 0x55d4dab5ba20 in main src/memory_errors.cpp:109
new[] with delete is an address error, not just a missing loop. For an array of std::string, the compiler stores the element count just before the first element so that delete[] knows how many destructors to run. new[] returns the address after that count, so a plain delete passes the allocator an address it never handed out. AddressSanitizer’s report shows the offset exactly (excerpt):
==3663==ERROR: AddressSanitizer: attempting free on address which was not malloc()-ed: 0x50b000000048 in thread T0
#1 0x556350bd1b77 in mismatched_delete src/memory_errors.cpp:44
0x50b000000048 is located 8 bytes inside of 104-byte region [0x50b000000040,0x50b0000000a8)
allocated by thread T0 here:
#1 0x556350bd1a3c in mismatched_delete src/memory_errors.cpp:42
Three 32-byte strings plus an 8-byte count make the 104-byte block, and the pointer given to delete is 8 bytes into it. For a type with no destructor, such as int, there is no count: the same mistake on a new int[3] ran to exit 0 in a plain build, and ASan reported it as alloc-dealloc-mismatch (operator new [] vs operator delete).
Containers do not prevent dangling pointers. invalidated_pointer() uses only a std::vector, with no new or delete in sight, and it reads freed memory. push_back needed more capacity, allocated a new buffer, moved the elements and freed the old one; first still pointed at the old one. ASan shows all three steps (excerpt):
==3665==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010 at pc 0x555b8358b6cb bp 0x7ffd993d50c0 sp 0x7ffd993d50b0
#0 0x555b8358b6ca in invalidated_pointer src/memory_errors.cpp:78
freed by thread T0 here:
#7 0x555b8358b67c in invalidated_pointer src/memory_errors.cpp:77
previously allocated by thread T0 here:
#6 0x555b8358b5c7 in invalidated_pointer src/memory_errors.cpp:75
Neither compiler warned. The same applies to iterators, references to elements, and std::string_views into a std::string that is later modified. The std::vector guide lists which operations invalidate what.
The two compilers handled the returned local differently. GCC warned and then compiled return best; to return a null reference, so the GCC builds crashed on first use; its ASan report is a SEGV on address 0, and with UndefinedBehaviorSanitizer added it says reference binding to null pointer. Clang returned the dead object’s address, so Clang -O2 printed longest word: allocator, the correct answer, from a destroyed string. For delete on a stack address, Clang’s ASan reported bad-free with the variable’s location, while GCC’s ASan build printed attempting double-free with an address of 0 or 0x1f and then failed an internal check in each of three runs. The cause was not investigated further; the Clang report is the one to trust for that error.
RAII: Tie Every Resource to an Object
RAII (Resource Acquisition Is Initialization) means acquiring a resource in an object’s constructor and releasing it in the object’s destructor, so the resource is freed whenever the object’s lifetime ends: at the end of a scope, on an early return, or while an exception unwinds the stack. std::vector, std::string, std::unique_ptr, std::lock_guard and std::fstream are all RAII types.
The difference shows most clearly when a constructor fails half-way. If a constructor throws, the object never existed, so its destructor does not run. Only the members that were already fully constructed are destroyed, by their own destructors:
// exception_safety.cpp - a constructor that throws after its first allocation.
// The destructor of a partly constructed object never runs, so only members
// that release themselves are cleaned up.
#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>
#include <utility>
struct Part {
explicit Part(std::string n, bool fail = false) : name(std::move(n))
{
if (fail)
throw std::runtime_error("cannot create " + name);
std::cout << " create " << name << '\n';
}
~Part() { std::cout << " destroy " << name << '\n'; }
std::string name;
};
class RawEngine { // owns its parts through raw pointers
public:
RawEngine() : pump_(new Part("pump")), valve_(new Part("valve", true)) {}
~RawEngine() { delete valve_; delete pump_; } // never reached here
RawEngine(const RawEngine&) = delete;
RawEngine& operator=(const RawEngine&) = delete;
private:
Part* pump_;
Part* valve_;
};
class OwnedEngine { // owns its parts through unique_ptr members
public:
OwnedEngine()
: pump_(std::make_unique<Part>("pump")),
valve_(std::make_unique<Part>("valve", true)) {}
private:
std::unique_ptr<Part> pump_; // fully constructed members are destroyed
std::unique_ptr<Part> valve_; // even when the constructor throws
};
int main()
{
std::cout << "RawEngine:\n";
try { RawEngine e; } catch (const std::exception& ex) { std::cout << " caught: " << ex.what() << '\n'; }
std::cout << "OwnedEngine:\n";
try { OwnedEngine e; } catch (const std::exception& ex) { std::cout << " caught: " << ex.what() << '\n'; }
}
Output:
RawEngine:
create pump
caught: cannot create valve
OwnedEngine:
create pump
destroy pump
caught: cannot create valve
Both engines created a pump and then failed to create a valve. OwnedEngine destroyed its pump, because pump_ was a fully constructed std::unique_ptr and its destructor ran during unwinding. RawEngine did not: pump_ was a raw pointer, which has no destructor, and ~RawEngine() never ran because construction never finished. The memory for the valve itself was not leaked in either case, because a new expression whose constructor throws frees its own storage. LeakSanitizer reports the pump (excerpt):
==3674==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 32 byte(s) in 1 object(s) allocated from:
#0 0x7fa57f4fe548 in operator new(unsigned long) ../../../../src/libsanitizer/asan/asan_new_delete.cpp:95
#1 0x55ba7eefbf52 in RawEngine::RawEngine() src/exception_safety.cpp:23
#2 0x55ba7eefb78b in main src/exception_safety.cpp:45
SUMMARY: AddressSanitizer: 32 byte(s) leaked in 1 allocation(s).
Thirty-two bytes is the size of Part, which holds one std::string (32 bytes on libstdc++). The fix needed no try block and no cleanup code: changing the member types was enough. That is the practical meaning of RAII. If every resource is owned by an object, the language’s ordinary rules about when objects are destroyed do the cleanup on every path, including the ones nobody tested.
Copying Objects That Own Memory
A class that releases memory in its destructor has to decide what copying it means. If it does not decide, the compiler does, and the compiler-generated copy constructor copies each member, including the pointer:
// shallow_copy.cpp - a class that frees its buffer in the destructor but lets
// the compiler generate the copy constructor. Both copies free the same block.
#include <cstring>
#include <iostream>
class Name {
public:
explicit Name(const char* text) : data_(new char[std::strlen(text) + 1])
{
std::strcpy(data_, text);
}
~Name() { delete[] data_; } // releases what the constructor allocated
// No copy constructor or copy assignment: the generated ones copy the pointer.
const char* c_str() const { return data_; }
private:
char* data_;
};
int main()
{
Name original("Ada Lovelace");
{
Name copy = original; // shallow copy: same data_ pointer
std::cout << "copy: " << copy.c_str() << '\n';
} // copy's destructor frees the shared block
std::cout << "original: " << original.c_str() << '\n';
} // original's destructor frees it again
Output:
free(): double free detected in tcache 2
exit status 134
copy.data_ and original.data_ held the same address. When copy went out of scope its destructor freed the block; printing original read freed memory, and original‘s destructor freed the block a second time, which glibc detected. (The program uses std::strlen and std::strcpy because text is a NUL-terminated string literal, which is what they are for.) Under AddressSanitizer the first error is the read, not the second free (excerpt):
==3812==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010 at pc 0x7f3c6507d96f bp 0x7fff51e4a280 sp 0x7fff51e49a28
READ of size 2 at 0x502000000010 thread T0
#2 0x558ade3264b7 in main src/shallow_copy.cpp:28
freed by thread T0 here:
#1 0x558ade3266b3 in Name::~Name() src/shallow_copy.cpp:12
#2 0x558ade326473 in main src/shallow_copy.cpp:27
Neither compiler warns about this class under -Wall -Wextra. Both can: GCC’s -Wdeprecated-copy-dtor reports implicitly-declared 'constexpr Name::Name(const Name&)' is deprecated, and Clang’s -Wdeprecated reports definition of implicit copy constructor for 'Name' is deprecated because it has a user-provided destructor. Generating a copy constructor for a class with a user-declared destructor has been deprecated since C++11, and the warning points straight at this bug.
There are two correct designs, and both are in this program:
// ownership.cpp - two correct versions of a class that owns a character buffer:
// one that manages the memory itself (rule of five) and one that lets a
// standard type do it (rule of zero).
#include <cstddef>
#include <cstring>
#include <iostream>
#include <string>
#include <utility>
// Rule of five: every special member function is written by hand.
class Buffer {
public:
explicit Buffer(const char* text)
: size_(std::strlen(text)), data_(new char[size_ + 1])
{
std::memcpy(data_, text, size_ + 1);
}
~Buffer() { delete[] data_; }
Buffer(const Buffer& other) // deep copy
: size_(other.size_), data_(new char[other.size_ + 1])
{
std::memcpy(data_, other.data_, size_ + 1);
}
Buffer(Buffer&& other) noexcept // steal, leave the source empty
: size_(std::exchange(other.size_, 0)), data_(std::exchange(other.data_, nullptr)) {}
Buffer& operator=(Buffer other) noexcept // copy-and-swap: handles both
{ // copy and move assignment,
std::swap(size_, other.size_); // and self-assignment
std::swap(data_, other.data_);
return *this;
}
void set_first(char c) { if (size_ > 0) data_[0] = c; }
const char* c_str() const { return data_ ? data_ : ""; }
private:
std::size_t size_; // declared before data_, which is initialized from it
char* data_;
};
// Rule of zero: std::string already copies, moves and frees correctly.
struct Name {
std::string text;
};
int main()
{
Buffer a("Ada Lovelace");
Buffer b = a; // copy constructor
b.set_first('I');
Buffer c = std::move(b); // move constructor
Buffer& same = a;
a = same; // self-assignment is safe
std::cout << "Buffer: a=\"" << a.c_str() << "\" b=\"" << b.c_str()
<< "\" c=\"" << c.c_str() << "\"\n";
Name x{"Grace Hopper"};
Name y = x; // generated copy: a separate string
y.text[0] = 'T';
Name z = std::move(y); // generated move
std::cout << "Name: x=\"" << x.text << "\" z=\"" << z.text << "\"\n";
}
Output:
Buffer: a="Ada Lovelace" b="" c="Ida Lovelace"
Name: x="Grace Hopper" z="Trace Hopper"
Buffer follows the rule of five: because it manages memory by hand, it defines the destructor, the copy constructor, the move constructor and an assignment operator. The copy constructor allocates a new buffer (a deep copy), so changing b did not change a. The move constructor takes the pointer and leaves the source empty, which is why b printed as "" after c was built from it. The assignment operator takes its argument by value and swaps with it (copy-and-swap), so one function handles copy assignment, move assignment and self-assignment.
Name follows the rule of zero: it declares none of the five, because std::string already copies, moves and frees itself correctly. It is a three-line struct instead of a thirty-line class, and it has nothing to get wrong. Prefer it; write the five by hand only for a class whose whole job is to own one resource. Which special members the compiler generates, and the silent cost of declaring just a destructor, are covered in constructors and destructors in C++.
Smart Pointers: Ownership in the Type
When an object must be allocated dynamically, for example because its type is chosen at run time or it must outlive the scope that created it, a smart pointer from <memory> owns it:
| Type | Ownership | Releases the object when | Size on x86-64 libstdc++ |
|---|---|---|---|
std::unique_ptr<T> | One owner; can be moved, not copied | The unique_ptr is destroyed or reset | 8 bytes, the same as T* |
std::shared_ptr<T> | Shared, reference-counted | The last shared_ptr to it is destroyed | 16 bytes, plus a control block |
std::weak_ptr<T> | None; observes a shared_ptr-owned object | Never; lock() reports whether it still exists | 16 bytes |
Create them with std::make_unique and std::make_shared rather than new, so no raw owning pointer exists even briefly. Use std::unique_ptr unless ownership is genuinely shared. Smart pointers solve the leak and double-free rows of the table above; they do not stop a raw pointer or reference taken from one from outliving it, and a std::shared_ptr cycle leaks. The full treatment, with custom deleters, the make_shared allocation trade-off, cycles and thread safety, is in smart pointers in C++.
Dangling References Without new or delete
Some lifetime errors need no heap allocation at all. A function that returns one of its const reference parameters is a common one:
// dangling_temporary.cpp - returning a const reference parameter is safe while
// the caller's full expression lasts, and dangles if the caller keeps it.
#include <iostream>
#include <string>
const std::string& longer(const std::string& a, const std::string& b)
{
return a.size() >= b.size() ? a : b;
}
int main()
{
std::string name = "Lovelace";
// Safe: the temporary std::string lives until the end of this statement.
std::cout << "in one expression: " << longer(name, std::string("Ada")) << '\n';
// Dangles: the temporary is destroyed at the semicolon, `kept` still refers to it.
const std::string& kept = longer(std::string("Babbage, Charles"), name);
std::cout << "kept reference: " << kept << '\n';
}
The first line printed in one expression: Lovelace in every build. The second printed 16 bytes of unprintable garbage, different on each run, in all four builds across three runs each. A temporary bound to a const& parameter lives until the end of the full expression that created it, the statement in the caller, so using the result inside that statement is safe. Binding the result to a reference that outlives the statement is not: kept refers to a std::string that was destroyed at the semicolon.
GCC 13 warned, through a check added in that release:
src/dangling_temporary.cpp:19:24: warning: possibly dangling reference to a temporary [-Wdangling-reference]
Clang 18 compiled it without a warning. Under AddressSanitizer, GCC’s build reported heap-use-after-free (the string’s characters had been freed) and Clang’s reported stack-use-after-scope (the string object itself had gone). If you need to keep the result, store it by value: std::string kept = longer(...).
Destructors and Exceptions
Since C++11, a destructor is noexcept unless it says otherwise. An exception that escapes it does not propagate to a catch; it calls std::terminate. The same source behaves differently depending on the language version:
// throwing_destructor.cpp - since C++11 a destructor is noexcept unless it says
// otherwise, so an exception that leaves it calls std::terminate even when no
// other exception is in flight.
#include <cstring>
#include <iostream>
#include <stdexcept>
struct Flusher { // implicitly noexcept(true)
~Flusher() { throw std::runtime_error("flush failed"); }
};
struct LooseFlusher { // explicitly allows exceptions out
~LooseFlusher() noexcept(false) { throw std::runtime_error("flush failed"); }
};
int main(int argc, char** argv)
{
const bool loose = argc > 1 && std::strcmp(argv[1], "loose") == 0;
try {
if (loose) {
LooseFlusher f;
} else {
Flusher f;
}
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << '\n';
}
std::cout << "end of main\n";
}
Output (default mode):
terminate called after throwing an instance of 'std::runtime_error'
what(): flush failed
exit status 134
Output (throwing_destructor loose):
caught: flush failed
end of main
exit status 0
The try block around Flusher did not help: the program ended with std::terminate and exit status 134. LooseFlusher‘s exception reached the catch. A version of Flusher compiled as C++98 printed caught: flush failed; the same file compiled as C++11 or C++17 terminated. Both compilers saw it coming:
src/throwing_destructor.cpp: In destructor 'Flusher::~Flusher()':
src/throwing_destructor.cpp:9:18: warning: 'throw' will always call 'terminate' [-Wterminate]
9 | ~Flusher() { throw std::runtime_error("flush failed"); }
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
src/throwing_destructor.cpp:9:18: note: in C++11 destructors default to 'noexcept'
The note on the last line is GCC’s; Clang reported '~Flusher' has a non-throwing exception specification but can still throw [-Wexceptions]. noexcept(false) makes the exception catchable, but it is rarely the answer: if a destructor runs because another exception is already unwinding the stack and it throws again, the program still terminates. Report the failure some other way, such as an explicit flush() or close() member the caller can check, and keep destructors from throwing. The exception handling guide covers what happens to objects during stack unwinding.
How to Find Memory Bugs
The table of eight errors suggests an order of defence: let the compiler catch what it can, then run the tests under a tool that watches memory at run time.
| Tool | How to use it | What it found here | Cost |
|---|---|---|---|
| Compiler warnings | -Wall -Wextra; add -Wdeprecated-copy-dtor (GCC) or -Wdeprecated (Clang) | GCC 4 of 8, one only at -O2; Clang 2 of 8 | None at run time |
| AddressSanitizer | Compile and link with -fsanitize=address -g | 8 of 8 (GCC’s report for one was not usable) | Rebuild; added run time and memory (not measured here) |
| LeakSanitizer | Included in ASan on Linux; reports at exit | The leaks, including the constructor leak | As ASan |
| UndefinedBehaviorSanitizer | -fsanitize=undefined, combinable with ASan | Identified GCC’s null reference | Small; rebuild |
| Valgrind (Memcheck) | valgrind --leak-check=full ./program on an unmodified build | 8 of 8 | No rebuild; added run time (not measured here) |
| Static analysis | Clang Static Analyzer, clang-tidy, cppcheck and others | Not run for this table | Analysis time; some false positives |
A minimal setup that would have caught everything in this guide:
# Development and CI builds of the tests
g++ -std=c++17 -g -O1 -fno-omit-frame-pointer -fsanitize=address,undefined \
-Wall -Wextra -Wdeprecated-copy-dtor -o tests tests.cpp
./tests
# A separate run of an ordinary build, when rebuilding is not an option
valgrind --leak-check=full --error-exitcode=1 ./program
Sanitizers find errors on the paths your tests execute and nowhere else; a leak on an error path that no test triggers stays hidden. That is a reason to combine them with the design rules in this guide, not to skip them. A comparison of static analyzers, run against a file of planted defects, is in the best static code analysis tools.
C++ Memory Management Rules of Thumb
- Prefer automatic storage. The storage of a local variable or a member is released for you.
- Use containers for collections:
std::vectorinstead ofnew[],std::stringinstead ofchar*buffers. - Give every heap object an owner:
std::make_uniqueby default,std::make_sharedwhen ownership is genuinely shared. - Follow the rule of zero. Classes whose members manage themselves need no destructor, copy or move operations.
- Do not keep pointers, references or iterators into a container across operations that can reallocate it.
- Return by value rather than returning references to locals or to parameters that might be temporaries. Since C++17 a returned temporary is built in the caller’s variable, so this costs no copy; functions in C++ measures it.
- Keep destructors from throwing.
- Run the tests under AddressSanitizer (or Valgrind) in CI.
Key Takeaways
- Compiling cleanly says little about memory safety. With
-Wall -Wextra, GCC warned about four of eight memory errors and Clang about two. - A memory bug can look like success. A read after
delete[]printed the correct old value in all twelve runs; three of the eight errors exited 0 with no message. - AddressSanitizer and Valgrind each reported all eight errors in this test, so run your tests under one of them.
new[]must be paired withdelete[]. For an array ofstd::string, plaindeletehanded the allocator an address 8 bytes inside the block.- RAII is what makes cleanup reliable. Changing two raw-pointer members to
std::unique_ptrfixed a 32-byte leak from a throwing constructor without atryblock. - A class with a destructor needs a decision about copying. The compiler-generated copy caused a double free, and neither compiler warned under
-Wall -Wextra; the rule of zero avoids the question. - Containers and smart pointers do not stop dangling pointers. A pointer kept across
push_backread freed memory with no warning from either compiler. - Destructors are
noexceptby default since C++11. A throwing destructor that C++98 let you catch now callsstd::terminate.
Frequently Asked Questions
Conclusion
Most C++ memory bugs are not caused by forgetting how delete works. They come from a pointer or reference that outlives what it points to, or a resource that has no single owner, and the compiler lets both through. The habits that prevent them are mostly about ownership: give each resource one owning object, let standard types be those owners, and treat any pointer that does not own its target as something that can go stale.
The other habit is to stop trusting a clean run. Three of the eight errors here produced no symptom at all; one produced the right answer. A Clang AddressSanitizer build turned each of them into a report pointing at the code responsible. More C++ tutorials are collected in the C++ programming section.
Source Code and Tests
All programs on this page are in the memory/memory-management directory of the MYCPLUS C++ examples repository.
Build and test:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
bash tests/run_tests.sh g++ c++17
bash tests/sanitizers.sh g++
What the build checks. On Linux, with GCC and Clang under C++17 and C++20, it builds the correct examples with -Wall -Wextra -pedantic -Werror, runs them and compares their output with this page. It checks that the compiler warnings quoted here still appear, that shallow_copy still compiles without a warning under -Wall -Wextra, that the throwing destructor calls std::terminate, and that the shallow copy fails at run time. A sanitizer job on both compilers confirms that the correct examples run clean under AddressSanitizer and UndefinedBehaviorSanitizer and that each of the eight deliberate errors, the shallow copy, the dangling reference and the constructor leak is reported. macOS (Apple Clang) and Windows (MSVC, /W4 /WX) jobs build the four portable examples and compare their output. The plain-run results for the broken programs, the Valgrind results and the stack size are not checked by any job; they were measured on one machine.


