Free Handbook · Every example compiled & verified

Modern C++

The C++17 and C++20 features modern codebases rely on: auto and structured bindings, enum class, optional, variant, span, constexpr, std::format and lambdas.

0 / 145 lessons🔥 0 day streak
ShareXLinkedIn

Module 10 · what you'll be able to do

  • Use auto, structured bindings and if-with-initializer to write shorter, safer code
  • Model "maybe a value" with std::optional and "one of several types" with std::variant and std::visit
  • Pass contiguous data safely with std::span, and scoped constants with enum class
  • Move work to compile time with constexpr and consteval
  • Format output with std::format, and write lambdas with the right captures
01

auto and structured bindings

auto lets the compiler infer a variable's type from its initialiser. It shines for long iterator and lambda types and keeps code correct when a function's return type changes. Structured bindings (C++17) unpack a pair, tuple, array or struct into named variables in one line: auto [name, qty] = entry;. Combined with range-for over a map, they replace it->first and it->second with names that say what they are.

C++main.cpp
#include <iostream>
#include <map>
#include <string>
#include <tuple>

struct Stats { int min; int max; double mean; };

Stats summarize(int a, int b, int c) {
    int lo = std::min({a, b, c});
    int hi = std::max({a, b, c});
    return {lo, hi, (a + b + c) / 3.0};
}

std::tuple<std::string, int, bool> lookup() { return {"widget", 12, true}; }

int main() {
    auto [lo, hi, mean] = summarize(4, 9, 2);
    std::cout << lo << ' ' << hi << ' ' << mean << '\n';

    auto [name, qty, inStock] = lookup();
    std::cout << name << " x" << qty << (inStock ? " in stock" : " sold out") << '\n';

    std::map<std::string, int> stock{{"apple", 3}, {"fig", 0}};
    for (const auto& [item, count] : stock) std::cout << item << ':' << count << ' ';
    std::cout << '\n';

    if (auto [it, inserted] = stock.insert({"kiwi", 7}); inserted)   // if with initializer
        std::cout << "added " << it->first << '\n';
}
Outputcompiled & run with real C++
2 9 5
widget x12 in stock
apple:3 fig:0 
added kiwi

const auto& in the loop binds to each map entry without copying it. map::insert returns a pair of an iterator and a bool, which unpacks naturally.

Error you will hit

Structured binding count does not match

C++
#include <tuple>

int main() {
    std::tuple<int, double, char> t{1, 2.5, 'x'};
    auto [a, b] = t;
    return a;
}
main.cpp:5:10: error: type 'std::tuple<int, double, char>' decomposes into 3 elements, but only 2 names were provided
    5 |     auto [a, b] = t;
      |          ^
1 error generated.
Why the compiler said that

A structured binding must name every element. There is no way to skip one by leaving it out.

The fix

Name all three, and mark the ones you do not use with [[maybe_unused]] (or a name like _unused); or use std::get<0>(t) when you only need one element.

C++
#include <tuple>

int main() {
    std::tuple<int, double, char> t{1, 2.5, 'x'};
    [[maybe_unused]] auto [a, b, c] = t;
    return a;
}
02

enum class: scoped, strongly typed enums

A plain C-style enum leaks its names into the surrounding scope and converts silently to int, so Color::Red == Size::Small can compile. An enum class keeps its names scoped (Status::Paid), never converts implicitly, and can pick its underlying type (enum class Status : std::uint8_t). A switch over it gets a compiler warning when a case is missing.

C++main.cpp
#include <cstdint>
#include <iostream>
#include <string_view>

enum class Status : std::uint8_t { Pending, Paid, Failed };

std::string_view label(Status s) {
    switch (s) {
        case Status::Pending: return "pending";
        case Status::Paid:    return "paid";
        case Status::Failed:  return "failed";
    }
    return "unknown";
}

int main() {
    Status s = Status::Paid;
    std::cout << label(s) << '\n';
    std::cout << static_cast<int>(s) << '\n';         // explicit conversion only
    std::cout << sizeof(Status) << " byte\n";
    std::cout << std::boolalpha << (s == Status::Paid) << '\n';
}
Outputcompiled & run with real C++
paid
1
1 byte
true
Error you will hit

Cannot initialize an int with an enum class value

C++
#include <iostream>

enum class Status { Pending, Paid, Failed };

int main() {
    Status s = Status::Paid;
    int code = s;
    std::cout << code << '\n';
}
main.cpp:7:9: error: cannot initialize a variable of type 'int' with an lvalue of type 'Status'
    7 |     int code = s;
      |         ^      ~
1 error generated.
Why the compiler said that

That is the point of enum class: it does not convert to int behind your back, so a status cannot be mixed up with a count or an ID.

The fix

Convert explicitly when you really need the number, for example for storage or a wire format. C++23 adds std::to_underlying(s) for the same thing.

C++
#include <iostream>

enum class Status { Pending, Paid, Failed };

int main() {
    Status s = Status::Paid;
    int code = static_cast<int>(s);
    std::cout << code << '\n';
}
03

std::optional: a value that may be missing

std::optional<T> holds either a T or nothing (std::nullopt). It replaces the old tricks of returning -1, an empty string or a null pointer to mean "not found", and it puts the possibility in the function's type so the caller cannot forget it. Check with if (opt) or has_value(), read with *opt, and supply a fallback with value_or.

C++main.cpp
#include <iostream>
#include <optional>
#include <string>

std::optional<int> parsePort(const std::string& s) {
    if (s.empty() || s.size() > 5) return std::nullopt;
    for (char c : s) if (c < '0' || c > '9') return std::nullopt;
    int n = std::stoi(s);
    if (n < 1 || n > 65535) return std::nullopt;
    return n;
}

int main() {
    for (std::string input : {"8080", "http", "70000", ""}) {
        if (auto port = parsePort(input))
            std::cout << "'" << input << "' -> port " << *port << '\n';
        else
            std::cout << "'" << input << "' -> invalid\n";
    }
    std::cout << "default: " << parsePort("oops").value_or(443) << '\n';
}
Outputcompiled & run with real C++
'8080' -> port 8080
'http' -> invalid
'70000' -> invalid
'' -> invalid
default: 443
Your turn

Write std::optional<std::string> domainOf(const std::string& email) that returns the part after @, or std::nullopt if there is no @.

Error you will hit

bad_optional_access: calling value() on an empty optional

C++
#include <iostream>
#include <optional>
#include <string>

std::optional<std::string> findEmail(int id) {
    if (id == 1) return "[email protected]";
    return std::nullopt;
}

int main() {
    std::cout << findEmail(1).value() << '\n';
    std::cout << findEmail(2).value() << '\n';
}
[email protected]
libc++abi: terminating due to uncaught exception of type std::bad_optional_access: bad_optional_access
Why the compiler said that

value() throws std::bad_optional_access when the optional is empty. Nothing catches it, so the program terminates (exit code 134, SIGABRT). Dereferencing an empty optional with * is worse: it is undefined behaviour with no check at all.

The fix

Check before reading, or give a fallback. Reserve value() for places where an empty optional really is a bug you want to surface as an exception.

C++
#include <iostream>
#include <optional>
#include <string>

std::optional<std::string> findEmail(int id) {
    if (id == 1) return "[email protected]";
    return std::nullopt;
}

int main() {
    std::cout << findEmail(1).value_or("(no email)") << '\n';
    std::cout << findEmail(2).value_or("(no email)") << '\n';
}
04

std::variant and std::visit

std::variant<A, B, C> holds exactly one of several types, and knows which. It is a type-safe union, and a lightweight alternative to a class hierarchy when the set of cases is closed: no heap allocation, no virtual functions. std::visit calls a function with whatever the variant currently holds; the "overloaded lambdas" idiom below gives each type its own handler, and the code fails to compile if a type is not handled.

C++main.cpp
#include <iostream>
#include <string>
#include <variant>
#include <vector>

struct Card   { std::string last4; };
struct Upi    { std::string handle; };
struct Cash   {};
using Payment = std::variant<Card, Upi, Cash>;

template <class... Fs> struct overloaded : Fs... { using Fs::operator()...; };

std::string describe(const Payment& p) {
    return std::visit(overloaded{
        [](const Card& c) { return "card ending " + c.last4; },
        [](const Upi& u)  { return "UPI " + u.handle; },
        [](const Cash&)   { return std::string("cash on delivery"); },
    }, p);
}

int main() {
    std::vector<Payment> payments{Card{"4242"}, Upi{"asha@okbank"}, Cash{}};
    for (const auto& p : payments) std::cout << describe(p) << '\n';

    Payment p = Upi{"li@okbank"};
    std::cout << "index " << p.index() << ", holds Upi? " << std::boolalpha
              << std::holds_alternative<Upi>(p) << '\n';
    if (auto* card = std::get_if<Card>(&p)) std::cout << card->last4 << '\n';
    else std::cout << "not a card\n";
}
Outputcompiled & run with real C++
card ending 4242
UPI asha@okbank
cash on delivery
index 1, holds Upi? true
not a card

overloaded inherits the call operator of every lambda passed to it, so std::visit picks the matching one by type. std::get_if returns a null pointer instead of throwing when the variant holds something else.

05

std::span: a view of contiguous data

std::span<T> (C++20) is a non-owning view of a contiguous sequence: a pointer plus a length. One function taking std::span<const int> accepts a std::vector, a std::array, a C array or part of any of them, without copying and without templates. It replaces the old (const int* data, size_t n) pair, which lost the length at every call. Like std::string_view, a span must not outlive the data it points at.

C++main.cpp
#include <array>
#include <iostream>
#include <numeric>
#include <span>
#include <vector>

double average(std::span<const int> values) {
    if (values.empty()) return 0.0;
    return std::accumulate(values.begin(), values.end(), 0.0) / values.size();
}

void doubleAll(std::span<int> values) {
    for (int& v : values) v *= 2;
}

int main() {
    std::vector<int> v{10, 20, 30, 40};
    std::array<int, 3> a{1, 2, 3};
    int raw[] = {5, 7};

    std::cout << average(v) << ' ' << average(a) << ' ' << average(raw) << '\n';
    std::cout << average(std::span(v).subspan(1, 2)) << '\n';   // just 20 and 30

    doubleAll(std::span(v).first(2));
    for (int x : v) std::cout << x << ' ';
    std::cout << '\n';
}
Outputcompiled & run with real C++
25 2 6
25
20 40 30 40 

std::span<const int> is read-only; std::span<int> can modify the caller's data in place. first, last and subspan make sub-views without copying.

06

constexpr and consteval: work at compile time

A constexpr function can run at compile time when its arguments are constants, and still works normally at run time otherwise. A constexpr variable must be computed at compile time. consteval (C++20) goes further: the function must run at compile time. static_assert checks a condition during compilation. Together they move lookup tables, checks and small calculations out of the running program entirely.

C++main.cpp
#include <array>
#include <iostream>

constexpr long long factorial(int n) {
    long long result = 1;
    for (int i = 2; i <= n; ++i) result *= i;
    return result;
}

consteval int kib(int n) { return n * 1024; }

constexpr std::array<int, 6> squares = [] {
    std::array<int, 6> out{};
    for (int i = 0; i < 6; ++i) out[i] = i * i;
    return out;
}();

static_assert(factorial(5) == 120, "factorial is wrong");
static_assert(squares[4] == 16);

int main() {
    constexpr auto f20 = factorial(20);      // computed by the compiler
    constexpr int bufferSize = kib(4);
    int n = 10;
    std::cout << f20 << '\n';
    std::cout << bufferSize << '\n';
    std::cout << factorial(n) << '\n';       // same function, run time
    for (int s : squares) std::cout << s << ' ';
    std::cout << '\n';
}
Outputcompiled & run with real C++
2432902008176640000
4096
3628800
0 1 4 9 16 25 

If factorial(5) ever returned the wrong value, the program would not compile. kib(n) with a runtime n would be a compile error, because consteval forbids running at run time.

07

std::format: type-safe formatting

std::format (C++20) builds strings with Python-style placeholders: std::format("{} has {} items", name, n). It is type-safe (unlike printf, a wrong argument type is a compile error), and it replaces most std::ostringstream and std::setw juggling. Format specs control width, alignment, precision and base.

C++main.cpp
#include <format>
#include <iostream>
#include <string>
#include <vector>

struct Line { std::string item; int qty; double price; };

int main() {
    std::vector<Line> order{{"Keyboard", 1, 49.5}, {"Cable", 3, 4.25}, {"Monitor", 2, 189.0}};
    double total = 0;
    std::cout << std::format("{:<10}{:>5}{:>10}\n", "Item", "Qty", "Amount");
    for (const auto& [item, qty, price] : order) {
        double amount = qty * price;
        total += amount;
        std::cout << std::format("{:<10}{:>5}{:>10.2f}\n", item, qty, amount);
    }
    std::cout << std::format("{:-<25}\n", "");
    std::cout << std::format("{:<15}{:>10.2f}\n", "Total", total);
    std::cout << std::format("hex {:#x}, binary {:#b}, padded {:05}\n", 255, 5, 42);
}
Outputcompiled & run with real C++
Item        Qty    Amount
Keyboard      1     49.50
Cable         3     12.75
Monitor       2    378.00
-------------------------
Total              440.25
hex 0xff, binary 0b101, padded 00042

{:<10} left-aligns in 10 columns, {:>10.2f} right-aligns a float with 2 decimals, {:-<25} fills 25 columns with dashes. C++23's std::print writes the formatted text directly.

08

Lambdas in depth: captures, init-captures and generic lambdas

A lambda's capture list decides what it can see from the surrounding scope: [x] copies x when the lambda is created, [&x] refers to it, [=] and [&] capture everything used, by value or by reference. An init-capture [count = 0] creates a new member, and with mutable the lambda can change it, giving you a tiny stateful function object. A generic lambda takes auto parameters and works for any type.

C++main.cpp
#include <iostream>
#include <memory>
#include <string>

int main() {
    int rate = 2;
    auto byValue = [rate](int x) { return x * rate; };
    auto byRef = [&rate](int x) { return x * rate; };
    rate = 10;
    std::cout << byValue(3) << ' ' << byRef(3) << '\n';

    auto nextId = [id = 100]() mutable { return id++; };  // init-capture with state
    std::cout << nextId() << ' ' << nextId() << ' ' << nextId() << '\n';

    auto p = std::make_unique<std::string>("owned");
    auto consume = [s = std::move(p)] { return *s + " by the lambda"; };  // move into a lambda
    std::cout << consume() << ", p is " << (p ? "set" : "empty") << '\n';

    auto twice = [](const auto& x) { return x + x; };      // generic lambda
    std::cout << twice(21) << ' ' << twice(std::string("ab")) << ' ' << twice(1.5) << '\n';
}
Outputcompiled & run with real C++
6 30
100 101 102
owned by the lambda, p is empty
42 abab 3

byValue copied 2 at creation; byRef sees the later 10. A unique_ptr cannot be copied, so the only way into a lambda is an init-capture with std::move.

Dangling captures
A lambda that captures a local by reference must not outlive that local. Returning [&] { return total; } from a function, or storing it in a callback that runs later, reads a destroyed variable: undefined behaviour. Capture by value (or move) anything the lambda needs after the current scope ends.
auto
Lets the compiler infer a variable's type from its initialiser.
Structured binding
auto [a, b] = x; unpacks a pair, tuple, array or struct into named variables.
enum class
A scoped enumeration that does not convert implicitly to int.
std::optional
Holds a value or nothing (std::nullopt); replaces sentinel values like -1.
std::variant
A type-safe union holding exactly one of several types; handled with std::visit.
std::span
A non-owning view of contiguous data: pointer plus length.
constexpr / consteval
Functions that can (constexpr) or must (consteval) run at compile time.
Init-capture
A lambda capture that creates a new member, e.g. [count = 0] or [p = std::move(ptr)].
Quick check

What does int rate = 2; auto f = [rate](int x) { return x * rate; }; rate = 10; std::cout << f(3); print?

Frequently asked questions

What is "modern C++"?
C++ written with the features of C++11 and later, especially C++17 and C++20: auto, smart pointers, move semantics, lambdas, structured bindings, std::optional and std::variant, constexpr, ranges, concepts and std::format. Modern C++ avoids raw new and delete, C-style arrays and casts, and manual memory management in application code.
When should I use std::optional instead of a pointer or a sentinel value?
Use std::optional when a function may legitimately have no result, such as a lookup that finds nothing or a parse that fails. It makes the possibility part of the type, so callers must handle it. Use a pointer or reference when you are referring to an existing object rather than returning a value.
Which C++ standard should I learn?
Learn C++20, with C++17 as the baseline most production code uses today. Every example in this handbook is compiled as C++20. Newer standards (C++23 and later) add conveniences such as std::print and std::expected, which you can pick up once the C++20 foundation is solid.

Finish the C++ handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.