The difference between the stack and the heap, the pitfalls of new/delete, RAII, and safe memory management with unique_ptr and shared_ptr.
What makes C++ powerful is that you can manage memory by hand; what makes it dangerous is exactly the same thing. In this lesson we will look at the two main regions of memory, the pitfalls of manual management and how modern C++ makes this job safe.
The stack and the heap
- Stack: The region where local variables live. It is fast, and variables are deleted automatically when the function ends. Its size is limited.
- Heap: The region where you allocate as much memory as you want with
new. It is large, but you must give back what you allocate.
new and delete
int* p = new int(42); // create an int on the heap
std::cout << *p << '\n';
delete p; // give it back
p = nullptr; // clear the address that is now invalid
double* arr = new double[100]; // array of 100 elements
delete[] arr; // delete[] is a must for arrays
Here is the problem: if you forget to write delete, the memory is never given back (a memory leak). If you delete twice, the program crashes. Using the pointer after delete is undefined behavior. If you leave branching code early (for example with return or an exception), the delete line is never reached. That is why manual management is so error-prone.
RAII: tie the resource to an object
C++'s solution is the idea of RAII (Resource Acquisition Is Initialization): acquire the resource in an object's constructor and release it in its destructor. When the object goes out of scope, the destructor runs automatically. This makes forgetting impossible. The idea applies not only to memory but also to files, locks and serial port connections.
The standard library provides smart pointers for this. Header: #include <memory>.
#include <iostream>
#include <memory>
class Sensor {
public:
explicit Sensor(int id) : id_(id) {
std::cout << "Sensor " << id_ << " opened\n";
}
~Sensor() {
std::cout << "Sensor " << id_ << " closed\n";
}
void read() const {
std::cout << "Sensor " << id_ << " reading\n";
}
private:
int id_;
};
int main() {
Sensor* raw = new Sensor(1);
raw->read();
delete raw; // manual: forget it and you leak
{
auto single = std::make_unique<Sensor>(2);
single->read();
} // block ended: deleted automatically
auto a = std::make_shared<Sensor>(3);
{
auto b = a; // ownership is shared
std::cout << "count: " << a.use_count() << '\n';
}
std::cout << "count: " << a.use_count() << '\n';
std::cout << "main is ending\n";
return 0;
}
Sensor 1 opened
Sensor 1 reading
Sensor 1 closed
Sensor 2 opened
Sensor 2 reading
Sensor 2 closed
Sensor 3 opened
count: 2
count: 1
main is ending
Sensor 3 closed
unique_ptr and shared_ptr
std::unique_ptr
The object has a single owner. It cannot be copied; it can only be handed over with std::move. When the owner goes out of scope, the object is deleted. This is the default choice in almost every case, and its extra cost compared to a raw pointer is next to nothing. To create one, use std::make_unique<Type>(arguments).
std::shared_ptr
It can have several owners. A counter keeps track of how many owners there are; when the counter drops to zero, the object is deleted. In the example, the line b = a raised the counter to 2, it dropped to 1 when the inner block ended, and Sensor 3 was closed only when main ended. Since managing the counter has a small cost, unique_ptr is preferred when sharing is not needed.
The rule
When writing modern C++, aim not to use naked new and delete. If you need an array, use std::vector; if you need a single object, use make_unique. Raw new should appear only in low-level library code.
Embedded systems note: In many microcontroller projects dynamic memory is banned completely (new, malloc, even std::vector growth). The reason: on devices that run for a long time the heap gets fragmented and one day an allocation fails, which means the device freezes in the field. In such systems memory is allocated at compile time: static/global objects or std::array. Learning the rules matters, but follow the rules of your target platform.
There are tools for finding memory bugs: if you add the -fsanitize=address flag with g++, leaks and out-of-bounds accesses are reported at run time. Valgrind (Linux) does a similar job. This is the first thing to try on a suspicious program.
Try it yourself
- Using the
Sensorclass from the example, create astd::vector<std::unique_ptr<Sensor>>; add three sensors withpush_back(std::make_unique<Sensor>(i))and watch the destructors run at the end. - Comment out the
delete raw;line and catch the leak with-fsanitize=address. - Try to copy a
unique_ptrinto another variable; read the compiler error and fix it withstd::move.
// Mini quiz
Click an option; you will see right away, with the reason, whether it is correct or not.
Mark it when you finish: your progress is saved and you earn +20 XP.
Tip: ← → keys let you move between lessons.