Programming · 55 pages
- C language — 15 lessonstutorial
- Lesson 1 — Hello, C
- Lesson 2 — Types & literals
- Lesson 3 — Expressions & operators
- Lesson 4 — Control flow
- Lesson 5 — Functions
- Lesson 6 — Arrays
- Lesson 7 — Pointers
- Lesson 8 — Strings
- Lesson 9 — Structs & unions
- Lesson 10 — Dynamic memory
- Lesson 11 — Preprocessor
- Lesson 12 — Multi-file projects
- Lesson 13 — Standard library
- Lesson 14 — Undefined behavior
- Lesson 15 — Modern C (C11/17/23)
- C++ programming — 15 lessonstutorial
- Lesson 1 — Hello, C++
- Lesson 2 — References & namespaces
- Lesson 3 — Classes & RAII
- Lesson 4 — Inheritance & polymorphism
- Lesson 5 — Templates
- Lesson 6 — STL containers
- Lesson 7 — Iterators & algorithms
- Lesson 8 — Smart pointers
- Lesson 9 — Move semantics
- Lesson 10 — Lambdas
- Lesson 11 — Error handling
- Lesson 12 — Concurrency
- Lesson 13 — Modules (C++20)
- Lesson 14 — Modern C++23
- Lesson 15 — C++ on Cortex-M
- Bare-metal C tutorial — 10 lessonsembedded
- Lesson 1 — Toolchain setup
- Lesson 2 — Bare-metal blink
- Lesson 3 — Registers in C
- Lesson 4 — Memory map
- Lesson 5 — Linker script
- Lesson 6 — Interrupts & ISRs
- Lesson 7 — Bit patterns
- Lesson 8 — Fixed-point math
- Lesson 9 — State machines
- Lesson 10 — Ring buffers + DMA
- Embedded C patterns
- volatile & memory-mapped IO
- CMSIS-Core
- CMSIS-DSP
- Linker scripts
- Startup & vector table
- C++ on Cortex-M
- Rust on Cortex-Mmodern
- Inline assembly
- Bare-metal libc & newlib-nano
- Bootloaders & DFU
- OTA update flow
Lesson 8 — Smart pointers
std::unique_ptr, std::shared_ptr, std::weak_ptr, std::make_unique, std::make_shared, custom deleters, the owning-vs-non-owning distinction, and why raw new and delete are mostly wrong in modern C++.
- What you'll learn
- std::unique_ptr (exclusive ownership, zero overhead), std::shared_ptr (reference-counted), std::weak_ptr (for cycles), std::make_unique and std::make_shared, custom deleters, the owning vs non-owning pointer distinction, and a tree built from unique_ptr children.
- Code provided
- About 180 lines covering each smart pointer, custom deleters for FILE*, and a tree-of-unique-ptr example
- Time
- 120-150 minutes
- Pre-reqs
- Lessons 1-7
new and delete are the C++ versions of malloc and free. They suffer from the same problem: pairing the allocation with the deallocation requires programmer discipline, and forgetting either leaks or double-frees. C++ has had a solution since 2011: smart pointers, which wrap raw pointers in RAII classes that handle the deallocation automatically. In modern C++ you essentially never write raw new; you use std::make_unique or std::make_shared and let the smart pointer's destructor handle cleanup. This lesson covers the three smart pointers and the design pattern they support.
Why raw new is mostly wrong
void process() {
Widget* w = new Widget{};
w->configure(42);
if (failed_check()) {
return; // LEAK: w never deleted
}
w->run(); // if run() throws, also leaks
delete w;
}Three failure modes baked into this pattern:
- Early return. If
failed_check()returns true, the function exits without freeingw. - Exceptions. If
w->configureorw->runthrows, the function unwinds without reachingdelete w. - Refactoring. A future maintainer adds an early
returnsomewhere and silently introduces a leak.
The "fix" with raw new is to wrap everything in try/catch, manually free on every error path, and hope nobody adds a return statement. The fix with std::unique_ptr is one line:
void process() {
auto w = std::make_unique<Widget>();
w->configure(42);
if (failed_check()) return;
w->run();
// unique_ptr destructor deletes Widget automatically, on every path
}Every exit path — return, exception, fall-through — runs ~unique_ptr, which calls delete. No code on your end. This is the only safe way to manage dynamic allocation in C++, and it has been since 2011. If you see new and delete in a modern C++ codebase, it's a smell.
std::unique_ptr
std::unique_ptr<T> owns a single dynamically allocated T. When the unique_ptr goes out of scope, the object is deleted. It is move-only — you cannot copy a unique_ptr (which would imply two owners). You can transfer ownership with std::move.
#include <memory>
auto p = std::make_unique<int>(42);
std::cout << *p << "\n"; // 42
// std::unique_ptr<int> q = p; // compile error: cannot copy
std::unique_ptr<int> q = std::move(p); // OK: ownership transferred
// p is now empty (nullptr); q owns the int
if (!p) std::cout << "p is empty\n";
if (q) std::cout << "q owns " << *q << "\n";std::make_unique<T>(args...) is the right way to construct a unique_ptr. It calls new T(args...) internally and gives you a unique_ptr<T> wrapping the result. Three reasons to prefer it over std::unique_ptr<T>(new T{...}):
- Less repetition. Write the type once.
- Exception safety.
f(std::unique_ptr<T>(new T), std::unique_ptr<U>(new U))is unsafe — the compiler is allowed to evaluate asnew T; new U; unique_ptr<T>; unique_ptr<U>and ifnew Uthrows,Tleaks. Withf(std::make_unique<T>(), std::make_unique<U>()), each construction is atomic. - No
newin your code. Searches forneware then leak-hunters; absence means safety.
For arrays, use std::make_unique<T[]>(n):
auto buffer = std::make_unique<int[]>(1024);
buffer[0] = 42;
// destructor calls delete[] automaticallyBut honestly, std::vector<int> is almost always a better choice than std::unique_ptr<int[]>. The vector gives you size(), bounds checking via at(), range-based for, and growth — at the same memory cost. Use unique_ptr<T[]> only when you specifically need exactly-n-elements with no header.
Member methods worth knowing
p.get()— returns the raw pointer (still owned byp).p.reset()— deletes the current object and setspto empty.p.reset(new T{...})— deletes the current and takes ownership of a new T (rarely used; preferp = std::make_unique<T>(...);).p.release()— returns the raw pointer and gives up ownership. The caller is now responsible fordelete. Dangerous; needed only when interfacing with C APIs that take ownership.*p,p->member— pointer-like dereference.static_cast<bool>(p)orif (p)— true if owns something, false if empty.
unique_ptr has zero runtime overhead compared to a raw pointer. It is the same size (one pointer), the same speed at every operation. The destructor compiles to one comparison + one delete. If you're worried about overhead, you're worried about nothing.
std::shared_ptr — reference counting
Sometimes ownership is genuinely shared. Multiple parts of the program hold pointers to the same object; nobody knows who will be the last to release it. std::shared_ptr<T> handles this with reference counting.
auto a = std::make_shared<int>(42);
std::cout << a.use_count() << "\n"; // 1
auto b = a; // copy: refcount becomes 2
std::cout << a.use_count() << "\n"; // 2
{
auto c = a; // 3
std::cout << a.use_count() << "\n"; // 3
} // c destroyed, refcount 2
// when a and b both go out of scope, refcount reaches 0, int is deletedEach shared_ptr increments the refcount on construction and decrements on destruction. When the count reaches zero, the managed object is deleted.
This has costs:
- Two heap allocations in the default construction: one for the T, one for the control block (which holds the refcount and the weak refcount).
std::make_shared<T>(args...)consolidates these into one allocation, which is one reason to prefer it. - Atomic increment/decrement on every copy and destruction. This is safe across threads but not free — atomic operations are 5-10x slower than non-atomic on modern CPUs.
- Two pointers of size (the shared_ptr holds the object pointer and the control block pointer). Twice the size of a raw or unique pointer.
The cost is bearable when ownership is genuinely shared. When it isn't — when one clear owner exists — unique_ptr is the right tool. Reach for shared_ptr only when the design genuinely demands shared ownership.
std::make_shared<T>(args...), like make_unique, is preferred over raw construction. Bonus: it puts the T and the control block in one allocation, halving the allocation cost.
When you actually need shared_ptr
The honest answer: not often. Real cases include:
- Caches that hand out objects to many consumers, where the cache cannot know when the last consumer is done.
- Graphs where multiple nodes refer to a shared resource (and you cannot turn the graph into a tree).
- Plugin systems where the lifetime is genuinely cooperative.
Most "the object has multiple users" cases turn out to have a single owner with non-owning observers. Use unique_ptr for the owner and raw pointers or references for the observers.
std::weak_ptr — breaking cycles
Reference counting cannot handle cycles. If A holds a shared_ptr<B> and B holds a shared_ptr<A>, neither refcount ever reaches zero, even after every external reference is gone. Memory leaks silently.
std::weak_ptr<T> solves this. A weak_ptr observes a shared_ptr-owned object without contributing to the refcount. To access the object, you ask the weak_ptr for a temporary shared_ptr via lock():
auto sp = std::make_shared<int>(42);
std::weak_ptr<int> wp = sp; // weak observer; refcount still 1
if (auto locked = wp.lock()) { // try to get a strong ref
std::cout << *locked << "\n"; // 42
}
sp.reset(); // refcount → 0; object deleted
if (auto locked = wp.lock()) {
std::cout << *locked << "\n"; // not reached
} else {
std::cout << "object gone\n";
}wp.lock() returns either a valid shared_ptr (if the object still exists) or an empty one (if it has been deleted). The weak_ptr itself does not extend the lifetime.
Use weak_ptr for the "back-pointer" in a parent-child graph where the children should not keep the parent alive, or for observer lists where observers should not extend the subject's lifetime.
Custom deleters
Smart pointers can manage non-delete-able resources via custom deleters.
#include <cstdio>
#include <memory>
void close_file(FILE* f) {
if (f) std::fclose(f);
}
auto open_file(const char* path) {
return std::unique_ptr<FILE, decltype(&close_file)>{
std::fopen(path, "r"),
&close_file
};
}
int main() {
auto f = open_file("/etc/hosts");
if (f) {
int c = std::fgetc(f.get());
std::cout << static_cast<char>(c);
}
// fclose runs automatically
}The unique_ptr template takes a second template argument: the deleter type. We use decltype(&close_file) (the type of the function pointer) and pass the function pointer as a constructor argument.
A cleaner pattern uses a stateless function object so the unique_ptr stays one pointer in size:
struct FileCloser {
void operator()(FILE* f) const { if (f) std::fclose(f); }
};
using UniqueFile = std::unique_ptr<FILE, FileCloser>;
UniqueFile f{std::fopen("/etc/hosts", "r")};FileCloser is empty (no members), so the unique_ptr can apply empty-base-optimisation and stay the size of a single pointer. With a function-pointer deleter the unique_ptr is two pointers in size.
Use cases for custom deleters:
- C library handles:
FILE*,sqlite3*,OpenSSL_BIO*, OpenGL textures, audio handles. Anything withxxx_closeorxxx_freeinstead ofdelete. - Memory from a custom allocator (pool, arena, OS region).
- Objects allocated by a specific factory that has its own destruction protocol.
On embedded targets, custom deleters let you use unique_ptr to manage a fixed-storage resource without involving the heap:
static MyDevice device_storage;
auto dev = std::unique_ptr<MyDevice, void(*)(MyDevice*)>{
&device_storage,
[](MyDevice*) { /* no-op: storage is static */ }
};Now dev has the same usage syntax as a heap-allocated device but lives in static memory.
Owning vs non-owning pointers
The most important conceptual distinction in modern C++ pointer usage:
| Concept | Type | Manages lifetime? |
|---|---|---|
| Single owner | std::unique_ptr<T> | yes |
| Shared owner | std::shared_ptr<T> | yes (jointly) |
| Weak observer of shared | std::weak_ptr<T> | no |
| Non-owning view | T* or T& | no |
| Non-owning view (semantic) | std::observer_ptr<T> (proposed) | no |
When you see T* in modern C++ code, it should mean non-owning — a temporary view of an object owned elsewhere. Never new T into a raw pointer and expect the caller to delete; pass unique_ptr<T> if you mean "ownership transfer" or T* / T& if you mean "lookat-but-don't-touch".
// Owns: returns ownership to caller
std::unique_ptr<Widget> create_widget();
// Borrows: caller retains ownership; widget must outlive the call
void process(Widget* w);
void process(const Widget& w); // even better — non-null guaranteed
// Owns: takes ownership from caller
void destroy(std::unique_ptr<Widget> w);Reading the signature should tell you the ownership story.
std::observer_ptr<T> was proposed for the standard as a "this raw pointer is semantically non-owning" marker. It never made it in. The convention "raw pointers are non-owning" is the working compromise.
Working example: a tree with unique_ptr children
#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>
struct Node {
std::string name;
std::vector<std::unique_ptr<Node>> children;
explicit Node(std::string n) : name(std::move(n)) {}
Node* add_child(std::string n) {
children.push_back(std::make_unique<Node>(std::move(n)));
return children.back().get(); // non-owning observer
}
void print(int depth = 0) const {
for (int i = 0; i < depth; ++i) std::cout << " ";
std::cout << name << "\n";
for (const auto& child : children) {
child->print(depth + 1);
}
}
};
int main() {
Node root{"root"};
auto a = root.add_child("a");
auto b = root.add_child("b");
a->add_child("a1");
a->add_child("a2");
b->add_child("b1");
root.print();
// root destructor runs; vector destructor runs; each unique_ptr's destructor
// runs; children are deleted; recursively. No leaks possible.
}Output:
root
a
a1
a2
b
b1Things to note:
std::unique_ptr<Node>children: each node owns its children. When the parent is destroyed, the vector destroys each unique_ptr, which destroys the Node, which destroys its own children. Recursive cleanup falls out of the destructor.add_childreturns aNode*— a non-owning observer. The tree still owns the node; the caller can manipulate it but cannot delete it. This is the "owning vs non-owning" distinction in action.- No
new, nodelete, no leak possibility. - The
Nodeclass doesn't define a destructor, copy constructor, copy assignment, or any special function. Rule of zero. The compiler-generated destructor runs the vector's destructor which runs the unique_ptrs' destructors. Everything works.
Compare this to the C equivalent, which would need a manual free_node(Node*) function calling free_node on every child and then free on the node itself, paired with manual malloc at every add_child. Twenty more lines of code, ten more places for bugs.
Exercises
Exercise 8.1. Write a function read_lines(const std::string& path) that returns a std::unique_ptr<std::vector<std::string>> containing every line in the file. Use a custom-deleter unique_ptr<FILE> for the file handle.
Exercise 8.2. Why does this code leak? Fix it.
void take_two(std::shared_ptr<int> a, std::shared_ptr<int> b);
void caller() {
take_two(std::shared_ptr<int>(new int{1}),
std::shared_ptr<int>(new int{2}));
}Exercise 8.3. Modify the tree example to add a parent pointer to each Node. Should it be Node*, std::shared_ptr<Node>, or std::weak_ptr<Node>? Why?
What you learned
- Raw
newanddeleteare mostly wrong in modern C++ because of leak risks on every error path. std::unique_ptr<T>is the default owning pointer: zero-overhead, move-only, single owner. Usestd::make_unique<T>(args)to construct.std::shared_ptr<T>has reference counting overhead (atomic ops, control block). Use only when ownership is genuinely shared.std::weak_ptr<T>observes a shared_ptr without extending its lifetime. Used to break reference cycles.- Custom deleters extend unique_ptr to manage
FILE*, OpenGL handles, custom-allocated memory, or static storage with a no-op deleter. - The owning-vs-non-owning distinction: smart pointers for ownership, raw
T*orT&for non-owning views. - A tree built from
std::unique_ptrchildren gets fully recursive cleanup for free via the destructor chain.
That concludes part 1 of the C++ tutorial. In part 2 (lessons 9-12) we cover move semantics and rvalue references, lambdas, error handling without exceptions (std::optional, std::expected), and the concurrency primitives. Part 3 (lessons 13-15) covers modules, C++23 highlights, and finally putting it all together on a Cortex-M target.
Next
| Previous | Next |
|---|---|
| Lesson 7 — Iterators and algorithms | Lesson 9 — Move semantics and rvalue references (part 2 — coming soon) |