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 static in function or file scope
  • unique_ptr is used for factory functions if allocation is needed
  • shared_ptr is 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