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_ptr and std::shared_ptr instead of raw new/delete.
  • Apply move semantics deliberately (when and why to call std::move).
  • Use auto, decltype, and decltype(auto).
  • Write expressive lambdas with capture by value / reference / [=] / [&].
  • Use constexpr for 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 new or delete in new code. If you find yourself reaching for them, your design needs a smart pointer or a container.

Who owns the memory: unique_ptr, shared_ptr with a reference count, weak_ptr, and RAII ending an object's life with its scope


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.