Ownership is the concept; smart pointers are the tool
Before unique_ptr and shared_ptr, ownership was implicit — a convention written
in comments and enforced only by discipline. “Whoever calls create() must call
destroy().” This worked until it didn’t: exceptions, early returns, and multiple
exit paths caused leaks.
Smart pointers make ownership explicit and enforced by the type system. A
unique_ptr<T> doesn’t just hold a pointer — it says “I am the sole owner of
this object. When I go out of scope, the object is destroyed.”
Understanding smart pointers means understanding the three ownership models they encode:
| Type | Ownership model |
|---|---|
unique_ptr<T> |
Single owner — one pointer, one lifetime |
shared_ptr<T> |
Shared ownership — reference counted, destroyed when last owner goes away |
weak_ptr<T> |
Non-owning observer — doesn’t keep the object alive |
unique_ptr — the default choice
unique_ptr is a zero-overhead RAII wrapper. On most compilers, it compiles to
exactly the same machine code as a raw pointer with a manual delete.
1// Creation
2auto sensor = std::make_unique<Bmp280Sensor>(hi2c1);
3
4// Automatic destruction when sensor goes out of scope
5// No delete needed — even if an exception is thrown
make_unique is preferred over new — it’s exception-safe and prevents the
pointer from ever being in a state where it’s allocated but not yet owned.
Moving ownership
unique_ptr is non-copyable. Ownership transfers via std::move:
1std::unique_ptr<ISensor> createSensor(SensorType type) {
2 switch (type) {
3 case SensorType::BMP280: return std::make_unique<Bmp280Sensor>(hi2c1);
4 case SensorType::NTC: return std::make_unique<NtcSensor>(ADC_CHANNEL_3);
5 }
6}
7
8// Transfer to caller — the factory no longer owns it
9auto sensor = createSensor(SensorType::BMP280);
10
11// Transfer to another unique_ptr
12auto other = std::move(sensor);
13// sensor is now nullptr; other owns the object
Factory functions returning unique_ptr are the idiomatic way to transfer
dynamically-created objects without exposing new.
Passing to functions
1// Passes ownership — callee becomes the owner
2void take(std::unique_ptr<ISensor> sensor);
3
4// Borrows — callee uses the object but doesn't own it
5void use(ISensor& sensor); // preferred
6void use(ISensor* sensor); // only if nullptr is a valid argument
7void use(const ISensor& sensor); // read-only borrow
Pass by raw reference or pointer when you’re just using the object, not owning it.
Passing unique_ptr by value transfers ownership to the function — the caller
loses the object. This is rarely what you want in a pipeline that holds the sensor.
shared_ptr — shared ownership with a cost
shared_ptr maintains a reference count. Every copy increments the count; every
destruction decrements it. When the count reaches zero, the object is destroyed.
1auto sensor = std::make_shared<Bmp280Sensor>(hi2c1);
2auto copy = sensor; // count = 2
3
4// Both sensor and copy can use the object
5// Destroyed when both go out of scope
The cost
A shared_ptr is not zero-overhead:
- Two heap allocations by default (object + control block), reduced to one with
make_shared - Two atomic operations (increment + decrement) on copy and destruction — expensive on multi-core
- Larger than
unique_ptr— typically 16 bytes (pointer + control block pointer)
On an embedded Cortex-M without an OS, the atomic operations still add instruction overhead and require disabling interrupts or using LDREX/STREX.
When shared_ptr is the right call
Use shared_ptr when multiple unrelated parts of a system need to keep an object
alive and you can’t determine a single owner:
1// Configuration loaded once, shared across pipeline stages
2auto config = std::make_shared<SensorConfig>(loadConfig());
3
4SensorPipeline pipeline(config);
5DiagnosticsUnit diag(config);
6Logger logger(config);
7
8// Config lives until all three are destroyed — order doesn't matter
If you can determine a single owner — use unique_ptr. shared_ptr is not
“the safe default” — it’s an explicit choice to share ownership at a runtime cost.
weak_ptr — observing without owning
weak_ptr holds a non-owning reference to a shared_ptr-managed object. It
doesn’t prevent destruction. To use the object, you must lock() it — which
returns a shared_ptr if the object still exists, or an empty shared_ptr if
it was already destroyed.
1std::shared_ptr<Display> display = std::make_shared<TftDisplay>(hspi1);
2std::weak_ptr<Display> observer = display;
3
4// Later, maybe from a callback:
5if (auto d = observer.lock()) {
6 d->refresh(); // safe — object is still alive
7} else {
8 // display was destroyed — skip
9}
The main use case: breaking cycles
shared_ptr cycles cause memory leaks — A holds a shared_ptr to B, B holds
one to A, neither reaches zero count.
1// Cycle — both leak
2struct Node {
3 std::shared_ptr<Node> next; // BAD when circular
4};
5
6// Fix: one direction is weak
7struct Node {
8 std::shared_ptr<Node> next;
9 std::weak_ptr<Node> prev; // observer, not owner
10};
Custom deleters
unique_ptr and shared_ptr accept custom deleters — useful for resources that
aren’t heap-allocated objects:
1// HAL handle that must be released, not deleted
2auto timer = std::unique_ptr<TIM_HandleTypeDef, decltype(&HAL_TIM_Base_DeInit)>(
3 &htim1, HAL_TIM_Base_DeInit
4);
5// HAL_TIM_Base_DeInit called automatically when timer goes out of scope
6
7// File descriptor
8auto fd = std::unique_ptr<FILE, decltype(&fclose)>(
9 fopen("log.bin", "wb"), fclose
10);
For embedded targets, this pattern manages HAL handles, SPI chip select pins, and other resources that have paired init/deinit functions.
Raw pointers — when they’re correct
Raw pointers are not wrong. They are wrong as owners. As non-owning observers, they are perfectly idiomatic:
1class SensorPipeline {
2 ISensor* sensor_; // raw pointer — pipeline does NOT own this
3public:
4 // Owned elsewhere (stack, unique_ptr at composition root)
5 explicit SensorPipeline(ISensor* sensor) : sensor_(sensor) {
6 assert(sensor != nullptr);
7 }
8};
The ownership is clear from the call site:
1// main.cpp
2Bmp280Sensor sensor(hi2c1); // stack-owned
3SensorPipeline pipeline(&sensor); // pipeline borrows a pointer
4// Both destroyed when main exits — no leak possible
This is the dominant pattern in embedded firmware where dynamic allocation is minimised or forbidden. All objects live on the stack or in static storage; pointers pass around non-owning references.
Ownership in embedded firmware
Most embedded codebases avoid dynamic allocation entirely:
Rule: no new/delete outside of startup
In this model:
- Objects live on the stack or as
staticin function or file scope unique_ptris used for factory functions if allocation is neededshared_ptris avoided — atomic reference counting costs interrupt-disable time- Raw pointers pass non-owning references
- References (
&) are preferred over raw pointers when nullptr is not valid
1// Typical embedded composition root
2static Bmp280Sensor sensor(hi2c1); // static — lives forever
3static MovingAvgFilter filter(8);
4static TftDisplay display(hspi1);
5
6static SensorPipeline pipeline(&sensor, &filter, &display);
Static objects at file scope are zero-initialised before main() and destroyed
after it — guaranteed by the C++ standard.
Quick reference
Need sole ownership, dynamically allocated? unique_ptr
Need shared ownership (multiple owners)? shared_ptr
Need to observe a shared_ptr without owning? weak_ptr
Object lives on stack / static storage? raw reference or raw pointer
Passing to a function that uses (not owns)? T& or const T&
Passing to a function that takes ownership? unique_ptr<T> by value
Returning a newly created object? unique_ptr<T>
What’s next
- std::variant and std::optional — type-safe ownership of uncertain or multiple types
- Memory allocators: pool and arena — avoiding heap fragmentation when unique_ptr isn’t enough
- Dependency injection without a framework — how ownership interacts with injection patterns