Lesson 6 of 6
You now have every option. This lesson is the procedure for choosing between them, and it is short because the answer is usually the first thing you try.
The procedure
Does this need to be on the heap at all?
│
├── No ───────────────────────────────> a plain value (most cases)
│
└── Yes: it must outlive its scope, be polymorphic,
or is genuinely too large for the stack
│
├── Can you name the single owner?
│ │
│ ├── Yes ──────────────────────> std::unique_ptr<T> (nearly all the rest)
│ │
│ └── No: several independent owners,
│ last one not knowable ─> std::shared_ptr<T> (rare)
│
└── Not owning it at all,
just looking ─────────────────> const T& / T* / weak_ptr
Work top to bottom and stop at the first line that fits. The proportions in real code look roughly
like the indentation suggests: mostly values, then unique_ptr, and shared_ptr occasionally.
Start with a value
The most common mistake arriving from other languages is putting everything on the heap. In Java or Python every object is a reference; in C++ it does not have to be, and usually should not be.
#include <iostream>
#include <memory>
#include <string>
#include <vector>
struct Point {
double x;
double y;
};
int main() {
// Idiomatic: values in a container. One allocation for the whole vector.
std::vector<Point> good = {{1, 2}, {3, 4}, {5, 6}};
// Unnecessary: a separate allocation per point, an extra indirection to
// read each one, and 8 bytes of pointer stored per element.
std::vector<std::unique_ptr<Point>> wasteful;
wasteful.push_back(std::make_unique<Point>(Point{1, 2}));
std::cout << "value vector: " << good.size() << " points, contiguous\n";
std::cout << "pointer vector: " << wasteful.size() << " point, scattered\n";
std::cout << "sizeof(Point) = " << sizeof(Point)
<< ", sizeof(unique_ptr) = " << sizeof(std::unique_ptr<Point>) << "\n";
return 0;
}
value vector: 3 points, contiguous
pointer vector: 1 point, scattered
sizeof(Point) = 16, sizeof(unique_ptr) = 8
The value version stores the points next to each other, which the CPU cache rewards heavily (Module 22). The pointer version scatters them across the heap and adds an indirection to every read.
The three real reasons to go to the heap
1. It must outlive the scope that created it. A factory returning an object the caller keeps.
2. It is polymorphic. You need a Shape* that might be a Circle or a Square. Storing a
Circle in a Shape-sized slot slices it (Module 16). This is the most common legitimate reason.
3. It is genuinely large. A multi-megabyte buffer will overflow the stack (lesson 8.1). Note
that std::vector already puts its elements on the heap, so a large vector is not a reason to add
a pointer.
Polymorphism is worth its own example, since it is the case you will meet most:
#include <iostream>
#include <memory>
#include <string>
#include <vector>
struct Shape {
virtual ~Shape() = default; // needed for delete via base
virtual double area() const = 0;
virtual std::string name() const = 0;
};
struct Circle : Shape {
double radius;
explicit Circle(double r) : radius(r) {}
double area() const override { return 3.14159265 * radius * radius; }
std::string name() const override { return "circle"; }
};
struct Square : Shape {
double side;
explicit Square(double s) : side(s) {}
double area() const override { return side * side; }
std::string name() const override { return "square"; }
};
int main() {
// A vector of Shape would not compile — Shape is abstract, and storing a
// Circle in a Shape-sized slot would slice it. Pointers are required.
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(1.0));
shapes.push_back(std::make_unique<Square>(2.0));
double total = 0.0;
for (const auto& s : shapes) {
std::cout << s->name() << ": " << s->area() << "\n";
total += s->area();
}
std::cout << "total: " << total << "\n";
return 0;
}
circle: 3.14159
square: 4
total: 7.14159
unique_ptr, not shared_ptr — the vector is the single owner. Module 16 covers virtual
properly; note for now that a polymorphic base class needs a virtual destructor, or deleting
through a Shape* is undefined behaviour.
Signatures: say what you mean
The type you write in a parameter list is a contract:
| Parameter | Means |
|---|---|
| const T& | I read it. It must exist. |
| T& | I modify it. It must exist. |
| const T* | I read it. It may be null. |
| std::unique_ptr<T> | I take ownership. It is mine now. |
| const std::unique_ptr<T>& | almost always wrong — take const T& |
| std::shared_ptr<T> | I am becoming a co-owner. |
| const std::shared_ptr<T>& | I might become a co-owner. |
| std::weak_ptr<T> | I observe; you control the lifetime. |
The fifth row is worth calling out. Taking const std::unique_ptr<T>& says "I need to know this
is stored in a unique_ptr," which no function actually needs. Take const T& and the function
works with values, unique_ptrs, shared_ptrs and stack objects alike.
#include <iostream>
#include <memory>
#include <string>
struct Config {
std::string name;
};
// Works with anything: a value, a unique_ptr's target, a shared_ptr's target.
void describe(const Config& c) {
std::cout << "config: " << c.name << "\n";
}
int main() {
Config on_stack{"stack.ini"};
auto owned = std::make_unique<Config>(Config{"unique.ini"});
auto shared = std::make_shared<Config>(Config{"shared.ini"});
describe(on_stack);
describe(*owned);
describe(*shared);
return 0;
}
config: stack.ini
config: unique.ini
config: shared.ini
One function, three ownership models, no coupling to any of them.
Part II in one page
This closes the hardest part of the curriculum. What it amounts to:
- Memory has three homes: static, stack, heap (8.1).
- Objects clean up after themselves through destructors — RAII (8.2, 9.4).
- Manual
new/deletefails structurally, not through carelessness (8.3). - Leaks, dangling pointers, and overruns are silent, and undefined behaviour proves nothing about correctness (8.4–8.7).
- A class owning a raw resource owes you five functions; a class owning nothing raw owes you none (9.8, 9.11).
- Smart pointers make ownership a thing the compiler checks (10.1–10.5).
If you retain one sentence from Part II, make it this: decide who owns each thing, write that decision in the type, and let destructors do the work.
Try it
Design the types for a small drawing program. Say which ownership model each needs and why:
- The 2D coordinates of a point.
- The list of shapes in a document.
- A shape's reference to the document it belongs to.
- An undo stack holding previous versions of a shape.
- A currently-selected shape, which may be nothing.
- A brush texture used by many shapes, freed when unused.
Checkpoint
- Try a plain value first; most objects never need the heap.
- Go to the heap only to outlive a scope, to be polymorphic, or for genuinely large objects.
- With a nameable owner, use
unique_ptr— this covers nearly everything remaining. - Use
shared_ptronly when the last owner cannot be known in advance. - Use
const T&,T*, orweak_ptrwhen you are looking rather than owning. - Prefer
const T&overconst unique_ptr<T>&— it works with every ownership model. - Values in a container are contiguous and cache-friendly; pointers scatter them.
Questions about this lesson
Log in to ask a question.