Skip to main content
C++ advanced Lesson 22 of 23

Modern C++: C++17, C++20, and C++23 Features

Explore the most impactful modern C++ features: modules, coroutines, ranges, concepts, std::format, and more.

Modern C++: C++17, C++20, and C++23 Features

Each C++ standard release has brought features that reshape how idiomatic code is written. C++17 focused on reducing boilerplate and making common patterns safer. C++20 was the most transformative release since C++11, adding Concepts, Ranges, Coroutines, and std::format. C++23 continued refining the library. This guide covers the most impactful additions from each with concrete examples you can use today.


C++17 Features

Structured Bindings

Before structured bindings, iterating over a map meant writing it->first and it->second everywhere. Structured bindings let you unpack pairs, tuples, structs, and arrays into named variables, making code dramatically more readable with no runtime cost.

#include <map>
#include <tuple>
#include <string>

// Pair decomposition — no more .first/.second
std::map<std::string, int> scores{{"Alice", 95}, {"Bob", 87}};
for (const auto& [name, score] : scores) {
    // name and score are named — intent is clear
}

// Tuple decomposition
auto getStats() -> std::tuple<int, double, std::string> {
    return {42, 3.14, "ok"};
}
auto [count, ratio, status] = getStats();

// Struct decomposition (aggregate types)
struct Point { int x, y; };
Point p{10, 20};
auto [px, py] = p;

if constexpr

if constexpr evaluates its condition at compile time and removes the discarded branch entirely. This is essential in template programming because the discarded branch is not compiled for that instantiation — it can contain code that would be ill-formed for other types, which regular if cannot do.

#include <type_traits>
#include <string>

template <typename T>
std::string describe(T val) {
    if constexpr (std::is_integral_v<T>) {
        // Only compiled for integer types — std::to_string(val) for string would fail
        return "integer: " + std::to_string(val);
    } else if constexpr (std::is_floating_point_v<T>) {
        return "float: " + std::to_string(val);
    } else {
        return "other";
    }
}
// Each branch only compiled for matching types — no spurious errors

std::optional

std::optional<T> represents a value that may or may not be present. It replaces error-prone sentinel values like -1, nullptr, or empty strings with an explicit, type-safe mechanism. The type itself communicates that absence is a valid outcome.

#include <optional>
#include <string>

std::optional<std::string> findUser(int id) {
    if (id == 1) return "Alice";
    return std::nullopt;  // explicit absence — not -1, not nullptr
}

auto user = findUser(1);
if (user) {
    std::cout << *user << "\n";          // dereference
    std::cout << user.value() << "\n";   // throws std::bad_optional_access if empty
}
std::string name = findUser(99).value_or("Unknown");  // provide a default

std::variant

std::variant is a type-safe union that holds exactly one of a fixed set of types at a time. It eliminates raw unions and tagged-union boilerplate, and std::visit provides exhaustive pattern matching over all possible types.

#include <variant>
#include <string>

using Result = std::variant<std::string, int, double>;

Result parse(const std::string& s) {
    try { return std::stoi(s); }
    catch (...) {}
    try { return std::stod(s); }
    catch (...) {}
    return s;
}

Result r = parse("3.14");
std::visit([](auto&& val) {
    using T = std::decay_t<decltype(val)>;
    if constexpr (std::is_same_v<T, int>)
        std::cout << "int: " << val << "\n";
    else if constexpr (std::is_same_v<T, double>)
        std::cout << "double: " << val << "\n";
    else
        std::cout << "string: " << val << "\n";
}, r);

Fold Expressions

Fold expressions apply a binary operator across a variadic template parameter pack. Before C++17, this required recursive template specializations. Fold expressions collapse the pack in one line.

template <typename... Args>
auto sum(Args... args) {
    return (args + ...);       // unary right fold: a + (b + (c + ...))
}

template <typename... Args>
void print(Args... args) {
    ((std::cout << args << " "), ...);  // comma-fold: applies to each argument
    std::cout << "\n";
}

sum(1, 2, 3, 4);   // 10
print("hello", 42, 3.14);

C++20 Features

Concepts

Concepts are named constraints on template parameters. They solve the main weakness of SFINAE: when a template constraint is violated with SFINAE you get an impenetrable substitution failure error; with Concepts you get a clear message like “T does not satisfy Numeric.” They also serve as documentation — a template <Numeric T> parameter tells readers immediately what types are accepted.

#include <concepts>
#include <string>

template <typename T>
concept Printable = requires(T t) {
    { std::cout << t } -> std::same_as<std::ostream&>;
};

template <typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;

template <Numeric T>
T clamp(T val, T lo, T hi) {
    return val < lo ? lo : val > hi ? hi : val;
}

// Abbreviated function template syntax with concepts
void printAll(Printable auto const& val) {
    std::cout << val << "\n";
}

clamp(15, 0, 10);      // compiles — int satisfies Numeric
// clamp("hi", 0, 10); // clear error: string doesn't satisfy Numeric

Ranges

The Ranges library replaces the verbose begin()/end() iterator-pair interface with a cleaner model. Algorithms take ranges directly, and views compose lazily with | — no intermediate containers, no allocation.

#include <ranges>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

    // Filter evens, square them, take first 3 — all lazy, zero intermediate allocations
    auto result = v
        | std::views::filter([](int x) { return x % 2 == 0; })
        | std::views::transform([](int x) { return x * x; })
        | std::views::take(3);

    for (int x : result)
        std::cout << x << " ";  // 4 16 36
}

std::format

std::format provides type-safe printf-style formatting. It replaces printf (unsafe, no type checking) and std::ostringstream (verbose, slow). The format string is validated at compile time when using a literal.

#include <format>
#include <string>

std::string s = std::format("Hello, {}! You scored {:.1f}%", "Alice", 98.5);
// "Hello, Alice! You scored 98.5%"

// Width and alignment — useful for tabular output
std::string table = std::format("{:<10} {:>6}", "Name", "Score");
// "Name            Score"

std::string msg = std::format("{0} + {1} = {2}", 1, 2, 3);

std::span

std::span is a non-owning view over a contiguous sequence. It is the correct parameter type for functions that just need to read or write a range without taking ownership — it accepts std::vector, std::array, C arrays, and raw pointer+size pairs all with the same function signature.

#include <span>
#include <vector>
#include <array>

void printAll(std::span<const int> data) {
    for (int x : data)
        std::cout << x << " ";
}

std::vector<int> v{1, 2, 3, 4, 5};
std::array<int, 3> a{6, 7, 8};
int raw[4] = {9, 10, 11, 12};

printAll(v);        // works — no overloads needed
printAll(a);        // works
printAll(raw);      // works
printAll({v.data() + 1, 3});  // subspan: [2, 3, 4]

Three-Way Comparison (Spaceship Operator)

Before C++20, supporting full ordering required writing six comparison operators and keeping them consistent. The spaceship operator collapses all six into one definition. With = default, the compiler generates all of them by comparing members in declaration order.

#include <compare>

struct Version {
    int major, minor, patch;

    // One line replaces six comparison operators
    auto operator<=>(const Version&) const = default;
};

Version v1{1, 2, 3};
Version v2{1, 3, 0};
bool older = v1 < v2;   // true — generated automatically
bool same  = v1 == v2;  // false — generated automatically

std::jthread

std::jthread improves on std::thread by joining automatically in its destructor (preventing accidental std::terminate) and supporting cooperative cancellation via std::stop_token.

#include <thread>
#include <stop_token>
#include <chrono>

void worker(std::stop_token stop) {
    while (!stop.stop_requested()) {
        // do work
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
}

{
    std::jthread t(worker);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    // t.request_stop() called automatically, then t.join() on destruction
}

Coroutines (Basic Generator)

Coroutines are functions that can suspend their execution and be resumed later. They are the foundation for generators (producing a sequence lazily), async I/O (suspending while waiting for a result), and cooperative multitasking. The co_yield keyword suspends the coroutine and produces a value; co_await suspends until an async operation completes.

#include <coroutine>
#include <optional>

template <typename T>
struct Generator {
    struct promise_type {
        T current_value;
        auto get_return_object() { return Generator{this}; }
        auto initial_suspend() { return std::suspend_always{}; }
        auto final_suspend() noexcept { return std::suspend_always{}; }
        auto yield_value(T val) {
            current_value = val;
            return std::suspend_always{};  // suspend after each yield
        }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };

    bool next() {
        handle.resume();
        return !handle.done();
    }
    T value() { return handle.promise().current_value; }

    ~Generator() { if (handle) handle.destroy(); }
private:
    using Handle = std::coroutine_handle<promise_type>;
    explicit Generator(promise_type* p) : handle(Handle::from_promise(*p)) {}
    Handle handle;
};

Generator<int> fibonacci() {
    int a = 0, b = 1;
    while (true) {
        co_yield a;        // suspend and produce a value
        auto c = a + b;
        a = b; b = c;
    }
}

int main() {
    auto fib = fibonacci();
    for (int i = 0; i < 10; ++i) {
        fib.next();
        std::cout << fib.value() << " ";
    }
    // 0 1 1 2 3 5 8 13 21 34
}

C++23 Features

std::expected

std::expected<T, E> holds either a value of type T or an error of type E. It is a cleaner alternative to exceptions for expected failure paths — the error type is explicit in the function signature, and the monadic interface lets you chain fallible operations cleanly.

#include <expected>
#include <string>

enum class ParseError { EmptyInput, InvalidFormat };

std::expected<int, ParseError> parseAge(const std::string& s) {
    if (s.empty()) return std::unexpected(ParseError::EmptyInput);
    try {
        int age = std::stoi(s);
        if (age < 0 || age > 150) return std::unexpected(ParseError::InvalidFormat);
        return age;
    } catch (...) {
        return std::unexpected(ParseError::InvalidFormat);
    }
}

auto result = parseAge("25");
if (result) {
    std::cout << "Age: " << *result << "\n";
} else {
    std::cout << "Error parsing age\n";
}

// Chain with and_then — short-circuits on first error
auto doubled = parseAge("21")
    .and_then([](int age) -> std::expected<int, ParseError> { return age * 2; });

std::print and std::println

Direct formatted output without the verbosity of std::cout << and without the unsafety of printf. std::println adds a newline automatically.

#include <print>

std::print("Hello, {}!\n", "world");
std::println("Value: {:.2f}", 3.14159);  // println adds newline automatically
std::println(stderr, "Error: {}", 42);   // can target any stream

Deducing this (Explicit Object Parameter)

The explicit object parameter lets you write one template function instead of four const/non-const × lvalue/rvalue overloads. It also enables recursive lambdas without std::function.

struct Widget {
    std::string name;

    // One template instead of four overloads (const/non-const * lvalue/rvalue)
    template <typename Self>
    auto& getName(this Self&& self) {
        return std::forward<Self>(self).name;
    }
};

// Recursive lambda — previously required std::function
auto factorial = [](this auto self, int n) -> int {
    return n <= 1 ? 1 : n * self(n - 1);
};

std::flat_map

A sorted-vector-backed map with better cache performance than std::map for read-heavy workloads. Because the keys are stored contiguously, iteration and binary search both benefit from cache locality — unlike std::map’s tree nodes which are scattered across the heap.

#include <flat_map>
#include <string>

std::flat_map<std::string, int> config{
    {"timeout", 30},
    {"retries", 3},
    {"port", 8080}
};

config["host_port"] = 443;
auto it = config.find("timeout");
// Contiguous storage — iteration and lookup are cache-friendly

Frequently Asked Questions

Should I use C++20 modules instead of headers?
Modules offer faster compilation and better encapsulation, but toolchain support is still maturing. Use them in new projects if your toolchain supports them well.
What are C++20 coroutines?
Coroutines are functions that can be suspended and resumed. They're the foundation for async/await patterns, generators, and cooperative multitasking in C++.