C++ Course — 05 · Modern C++ (smart pointers, move, lambdas, auto, constexpr)
C++11 and later releases (C++14, C++17, C++20, C++23) transformed the language. The pre-2011 "C with classes" idioms are still legal — but writing them today is like writing PHP from 2003 in 2026. This post is the final base post and covers the modern C++ feature set every working engineer should know: smart pointers, move semantics in practice, auto, range-based for, lambdas in depth, constexpr, and a glimpse of C++20 concepts and modules.
Series map: 00 Setup → 01 Syntax & Types → 02 Pointers & Memory → 03 Classes & OOP → 04 Templates & STL → 05 Modern C++ (this post) → (future: concurrency · ranges · coroutines · modules deep dive · advanced templates).
Learning Outcome
- Use
std::unique_ptrandstd::shared_ptrinstead of rawnew/delete. - Apply move semantics deliberately (when and why to call
std::move). - Use
auto,decltype, anddecltype(auto). - Write expressive lambdas with capture by value / reference /
[=]/[&]. - Use
constexprfor compile-time computation. - Use C++17 features:
std::optional,std::variant, structured bindings,if constexpr. - Glimpse C++20 concepts + modules.
Chapter 1 — Smart pointers
std::unique_ptr — sole ownership
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <memory>
auto p = std::make_unique<int>(42);
std::cout << *p << "\n"; // 42
// no delete needed — freed when p goes out of scope
// Custom class
struct Widget {
int x;
Widget(int v) : x(v) { std::cout << "construct\n"; }
~Widget() { std::cout << "destroy\n"; }
};
void use() {
auto w = std::make_unique<Widget>(7);
std::cout << w->x << "\n";
} // "destroy" printed here automatically
unique_ptr is move-only — there's exactly one owner at a time:
1
2
3
4
auto a = std::make_unique<int>(5);
auto b = a; // ERROR — can't copy
auto c = std::move(a); // OK — ownership transferred; a is now nullptr
Use unique_ptr for almost all heap-allocated objects. Costs nothing at runtime vs a raw pointer.
std::shared_ptr — shared ownership (reference count)
1
2
3
4
auto p1 = std::make_shared<int>(100);
auto p2 = p1; // both own; count = 2
// when both go out of scope, freed
Use when you genuinely have multiple owners — usually in observer-style or graph structures. Has reference-counting overhead, so prefer unique_ptr when one owner is enough.
std::weak_ptr — non-owning observer
1
2
3
4
5
6
7
auto sp = std::make_shared<int>(5);
std::weak_ptr<int> wp = sp; // doesn't extend lifetime
if (auto locked = wp.lock()) { // try to upgrade to shared_ptr
std::cout << *locked;
}
Use to break reference cycles in shared_ptr graphs, or to observe without prolonging life.
Rule: never write
newordeletein new code. If you find yourself reaching for them, your design needs a smart pointer or a container.
Chapter 2 — Move semantics in practice
A move transfers a resource's ownership instead of copying it.
1
2
3
4
5
6
7
std::vector<int> make_big() {
std::vector<int> v(1'000'000, 42);
return v; // moves (not copies) the vector out
}
auto v = make_big(); // no million-element copy
The compiler optimises returns automatically (NRVO + implicit move). You don't have to do anything for the common case.
When to call std::move explicitly
1
2
3
4
5
std::string a = "hello world this is long enough to allocate";
std::vector<std::string> v;
v.push_back(std::move(a)); // moves a into v; a is now empty
// a is in a "moved-from" state — valid but unspecified value
Use std::move when:
- You're passing a local variable to a sink (container, constructor) and won't use it afterward.
- You're transferring ownership in an assignment or parameter.
Don't move const objects — moving requires modifying the source. The compiler silently falls back to copy.
Pass-by-value + move idiom
1
2
3
4
5
6
class Person {
std::string name_;
public:
Person(std::string name) : name_(std::move(name)) {}
};
The parameter name is constructed (one copy or move depending on the caller). Then std::move transfers into name_ with zero extra allocation. Modern idiomatic style.
Chapter 3 — auto and decltype
1
2
3
4
5
6
7
8
9
10
11
12
13
auto x = 5; // int
auto y = 3.14; // double
auto& r = x; // int&
const auto& cr = x; // const int&
// for long types:
auto it = v.begin(); // far better than std::vector<int>::iterator
// auto in lambdas (C++14+)
auto f = [](auto a, auto b){ return a + b; };
f(1, 2); // int
f(1.5, 2.5); // double
decltype(expr) gives you the type of an expression without evaluating it:
1
2
3
4
int x = 5;
decltype(x) y = 10; // int y = 10;
decltype(x + 0.5) z; // double z;
decltype(auto) (C++14) preserves references / cv-qualifiers in return type deduction.
Chapter 4 — Lambdas in depth
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// minimal
auto greet = []{ std::cout << "hi\n"; };
greet();
// with parameters
auto add = [](int a, int b){ return a + b; };
add(3, 4);
// explicit return type
auto safe_div = [](double a, double b) -> double {
if (b == 0) return 0;
return a / b;
};
Captures
| Capture | Meaning |
|---|---|
[ ] |
no capture |
[x] |
capture x by value (copy) |
[&x] |
capture x by reference |
[=] |
capture all used variables by value |
[&] |
capture all used variables by reference |
[=, &x] |
all by value, but x by reference |
[this] |
capture this (so this->member works) |
[x = expr] (C++14) |
init-capture |
1
2
3
4
5
6
7
8
9
int n = 5;
auto by_val = [n]{ return n; }; // captured 5; later changes to n don't affect
auto by_ref = [&n]{ return n; }; // sees current n
auto init = [m = n * 2]{ return m; }; // captures m initialised to 10
n = 99;
by_val(); // 5
by_ref(); // 99
init(); // 10
mutable
1
2
3
4
5
auto counter = [n = 0]() mutable { return ++n; };
counter(); // 1
counter(); // 2
counter(); // 3
By default, value-captured variables are const inside the lambda. mutable removes that.
Trick: prefer
[&]only inside short scopes — capturing by reference outside the captured variable's lifetime = dangling. For lambdas that escape (stored, returned, passed to async), capture by value.
Chapter 5 — constexpr (compile-time computation)
1
2
3
4
5
6
7
constexpr int square(int n) { return n * n; }
constexpr int x = square(5); // computed at compile time → 25
int y = 10;
int z = square(y); // OK too — runs at runtime
If all arguments are compile-time constants, the call is evaluated by the compiler. Otherwise it runs at runtime.
consteval (C++20) — only compile-time
1
2
3
4
consteval int strict_square(int n) { return n * n; }
constexpr int a = strict_square(5); // OK
int b = strict_square(y); // ERROR — y not constant
constexpr containers (C++20)
std::vector, std::string are now constexpr-friendly inside constexpr functions. Compile-time lookup tables become trivial.
Chapter 6 — C++17 small but mighty
std::optional
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <optional>
std::optional<int> divide(int a, int b) {
if (b == 0) return std::nullopt;
return a / b;
}
if (auto r = divide(10, 3); r.has_value()) {
std::cout << *r; // 3
}
int safe = divide(10, 0).value_or(-1); // -1 on failure
Replaces "return -1 on failure" sentinels.
std::variant
A type-safe tagged union — holds one of several types.
1
2
3
4
5
6
7
8
9
10
11
#include <variant>
std::variant<int, std::string, double> v;
v = 42;
v = "hello";
v = 3.14;
std::visit([](auto&& arg){
std::cout << arg << "\n";
}, v);
Structured bindings
1
2
3
4
5
6
7
8
std::pair<int, std::string> p{1, "Ada"};
auto [id, name] = p; // id = 1, name = "Ada"
std::map<int, std::string> m{{1, "Ada"}, {2, "Bob"}};
for (const auto& [k, v] : m) {
std::cout << k << ": " << v << "\n";
}
if constexpr
1
2
3
4
5
6
7
8
9
10
11
template <typename T>
void describe(T x) {
if constexpr (std::is_integral_v<T>) {
std::cout << "integer\n";
} else if constexpr (std::is_floating_point_v<T>) {
std::cout << "float\n";
} else {
std::cout << "other\n";
}
}
Branches not taken are not even compiled. Replaces lots of old SFINAE template tricks.
Chapter 7 — C++20 concepts (glimpse)
Concepts let you constrain template parameters:
1
2
3
4
5
6
7
8
#include <concepts>
template <std::integral T> // T must be an integer type
T add_one(T x) { return x + 1; }
add_one(5); // OK
add_one(3.14); // ERROR — 3.14 isn't integral
Custom concept:
1
2
3
4
5
6
7
8
template <typename T>
concept Addable = requires(T a, T b) {
{ a + b } -> std::same_as<T>;
};
template <Addable T>
T sum(T a, T b) { return a + b; }
Replaces verbose enable_if patterns with readable constraints. Much friendlier compile errors.
Chapter 8 — C++20 modules (glimpse)
Headers are an artefact of 1970s linker design. Modules replace them with proper compile units:
1
2
3
4
5
// math.cppm (module interface)
export module math;
export int square(int n) { return n * n; }
1
2
3
4
5
6
7
// main.cpp
import math;
int main() {
return square(5);
}
Benefits: ~10× faster compile times, no preprocessor leakage, clean dependency graph.
Tooling still maturing in 2026; most projects still use headers + #include. Worth knowing exists; adopt when your build chain is ready.
Chapter 9 — Putting it together
A short modern C++ example combining many features:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>
#include <memory>
#include <vector>
#include <algorithm>
#include <optional>
class Animal {
public:
virtual ~Animal() = default;
virtual std::string speak() const = 0;
};
class Dog : public Animal {
std::string name_;
public:
Dog(std::string name) : name_(std::move(name)) {}
std::string speak() const override { return name_ + ": woof!"; }
};
class Cat : public Animal {
std::string name_;
public:
Cat(std::string name) : name_(std::move(name)) {}
std::string speak() const override { return name_ + ": meow"; }
};
std::optional<std::string> first_speaker(const std::vector<std::unique_ptr<Animal>>& zoo) {
if (zoo.empty()) return std::nullopt;
return zoo.front()->speak();
}
int main() {
std::vector<std::unique_ptr<Animal>> zoo;
zoo.push_back(std::make_unique<Dog>("Rex"));
zoo.push_back(std::make_unique<Cat>("Mochi"));
for (const auto& a : zoo) {
std::cout << a->speak() << "\n";
}
auto sorted = first_speaker(zoo);
std::cout << sorted.value_or("(empty zoo)") << "\n";
}
Features used: unique_ptr + make_unique, virtual functions, override, std::move, range-for with const auto&, optional, value_or, structured initialisation, the Rule of Zero (no explicit destructors anywhere).
Practice Exercises
Exercise 1 — Replace raw with smart
Take this leaky code and rewrite using std::unique_ptr:
1
2
3
4
5
6
7
class Node {
int value;
Node* next;
};
Node* head = new Node{1, new Node{2, new Node{3, nullptr}}};
// ... eventually you must walk and delete each
Exercise 2 — Counting lambda
Write a lambda that, each time it's called, returns the next prime number. Use mutable and an init-capture.
Exercise 3 — Optional parser
Write std::optional<int> parse_int(const std::string& s) that returns the value if parseable, else std::nullopt.
Solution
1
2
3
4
5
6
7
8
9
10
11
#include <optional>
#include <string>
#include <charconv>
std::optional<int> parse_int(const std::string& s) {
int value;
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
if (ec == std::errc{} && ptr == s.data() + s.size()) return value;
return std::nullopt;
}
Exercise 4 — Constexpr factorial
Write constexpr int factorial(int n). Test by storing factorial(10) in a constexpr int.
Solution
1
2
3
4
5
6
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
constexpr int x = factorial(10); // 3628800 — computed at compile time
Exercise 5 — Variant message handler
Define using Message = std::variant<Int, Str, Quit> where each is a simple struct. Write a handle(Message) function using std::visit that prints each.
Cheat-sheet
| What | How |
|---|---|
| Owning ptr (one) | std::make_unique<T>(args) |
| Owning ptr (shared) | std::make_shared<T>(args) |
| Non-owning observer | std::weak_ptr<T> |
| Move | std::move(x) — transfers ownership |
| Deduce type | auto, decltype(expr) |
| Lambda | [capture](args) -> ret { body } |
| Mutable lambda | []() mutable { ... } |
| Compile-time | constexpr (or consteval for strict) |
| Optional | std::optional<T>; value_or(default) |
| Variant | std::variant<A, B, C>; std::visit(f, v) |
| Structured binding | auto [a, b] = pair; |
| Compile-time if | if constexpr (cond) { ... } |
| Concept (C++20) | template <std::integral T> |
| Module (C++20) | export module name; / import name; |
| Rule of Zero | use smart pointers + containers; declare nothing |
Course base complete
You now have a full base in modern C++: setup, syntax, memory, classes, templates, STL, and the modern feature set. This is enough to read most professional C++ codebases and contribute to the high-level parts confidently.
Planned future posts (extensions)
- C++ 06: Concurrency (
std::thread,std::mutex,std::atomic,std::async). - C++ 07: Ranges deep-dive (C++20).
- C++ 08: Coroutines (C++20).
- C++ 09: Modules deep-dive (C++20 / 23).
- C++ 10: Build systems (CMake, package managers — Conan, vcpkg).
- C++ 11: Performance + profiling (sanitizers, Valgrind, perf).
- C++ 12: Embedded / no-std (when you can't use the STL).
References
- cppreference.com — the indispensable reference.
- Effective Modern C++ by Scott Meyers — Items 5–42 cover almost everything in this post.
- A Tour of C++ by Bjarne Stroustrup — Ch. 4–8 for OOP + modern features.
- C++ Core Guidelines — isocpp.github.io/CppCoreGuidelines — the canonical "what's idiomatic" doc.
- Jason Turner's C++ Weekly — best YouTube channel for modern C++.
Note: this post — and the base course — is in progress. Planned additions: coroutine "hello generator" example; concepts deep-dive; sanitiser-driven debugging walkthrough.










