Skip to main content
C++ · lesson 22 of 24
C++ advanced Lesson 22 of 24

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

C++17, C++20 and C++23 features run on GCC 13 and Clang 18: variant, concepts, std::format, span, jthread, coroutines, std::expected, deducing this.

By SkillByExample · Updated

Each standard reshapes idiomatic C++. C++17 cut boilerplate, C++20 was the biggest release since C++11 (concepts, ranges, coroutines, std::format), and C++23 continues the library work. Everything below was compiled on this machine — and where GCC 13 or Clang 18 doesn’t support a feature yet, you’ll see the real error.

C++17 Features

Structured bindings, if with an initializer, std::optional and fold expressions in one program:

#include <iostream>
#include <map>
#include <optional>
#include <string>
#include <tuple>

std::tuple<int, double, std::string> stats() { return {42, 3.14, "ok"}; }

std::optional<std::string> find_user(int id) {
    if (id == 1) return "Alice";
    return std::nullopt;
}

template <typename... Args>
auto sum(Args... args) { return (args + ...); }           // fold expression

int main() {
    auto [count, ratio, status] = stats();                 // structured bindings
    std::cout << count << " " << ratio << " " << status << "\n";

    std::map<std::string, int> scores{{"Bob", 87}, {"Alice", 95}};
    if (auto [it, added] = scores.try_emplace("Alice", 0); !added)   // if with initializer
        std::cout << "Alice already has " << it->second << "\n";

    std::cout << find_user(1).value_or("Unknown") << ", "
              << find_user(99).value_or("Unknown") << "\n";
    std::cout << sum(1, 2, 3, 4) << " " << sum(1, 2.5) << "\n";
}
$ g++ -std=c++17 -Wall -Wextra -o cpp17 cpp17.cpp
$ ./cpp17
42 3.14 ok
Alice already has 95
Alice, Unknown
10 3.5

auto [count, ratio, status] unpacks the tuple into three named variables. try_emplace inserts only if the key is missing and returns (iterator, inserted), so the if initializer can check both in one line — Alice kept her 95. value_or gives an optional a default. (args + ...) expands to 1 + (2 + (3 + 4)), and sum(1, 2.5) promotes to double. if constexpr is also C++17; it’s covered in the templates lesson.

std::variant

A std::variant holds exactly one of a fixed set of types. std::visit dispatches on whichever it holds, and the overloaded helper lets you give one lambda per type:

#include <iostream>
#include <string>
#include <variant>
#include <vector>

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

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

template <typename... Fs> struct overloaded : Fs... { using Fs::operator()...; };
template <typename... Fs> overloaded(Fs...) -> overloaded<Fs...>;   // needed in C++17 only

int main() {
    for (const char* input : {"42", "3.14", "hello"}) {
        Value v = parse(input);
        std::cout << input << " -> index " << v.index() << ": ";
        std::visit(overloaded{
            [](int i)                { std::cout << "int " << i << "\n"; },
            [](double d)             { std::cout << "double " << d << "\n"; },
            [](const std::string& s) { std::cout << "string " << s << "\n"; },
        }, v);
    }
    Value v = 7;
    std::cout << std::boolalpha << "holds int: " << std::holds_alternative<int>(v) << "\n";
    try { std::get<double>(v); } catch (const std::bad_variant_access& e) { std::cout << e.what() << "\n"; }
}
$ g++ -std=c++17 -Wall -Wextra -o variant variant.cpp
$ ./variant
42 -> index 0: int 42
3.14 -> index 0: int 3
hello -> index 2: string hello
holds int: true
std::get: wrong index for variant

“3.14” came back as the int 3: std::stoi happily parses the leading digits and ignores the rest, so the double branch was never reached. Order matters when parsing into a variant — check how much of the string was consumed (std::stoi(s, &used), then used == s.size()), or try the stricter type first. The deduction guide under overloaded is needed only in C++17; C++20 deduces aggregate template arguments by itself. std::get<double> on a variant holding an int throws std::bad_variant_access; holds_alternative checks first.

C++20 Features

Concepts

Concepts name requirements on template arguments.

#include <concepts>
#include <iostream>
#include <string>

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

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

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

void show(const Printable auto& v) { std::cout << "[" << v << "]\n"; }

struct Opaque {};

int main() {
    std::cout << clamp(15, 0, 10) << " " << clamp(-0.5, 0.0, 1.0) << "\n";
    show(42);
    show(std::string("text"));
    std::cout << std::boolalpha << "Printable<Opaque>: " << Printable<Opaque>
              << ", Numeric<char>: " << Numeric<char> << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o concepts concepts.cpp
$ ./concepts
10 0
[42]
[text]
Printable<Opaque>: false, Numeric<char>: true

Numeric combines two standard concepts with ||. Printable is a requires expression: os << t must compile and return std::ostream&. Anything can be tested as a boolean — Opaque has no <<, and char counts as integral. The templates lesson compares concept errors with SFINAE errors.

std::format

Type-safe, Python-style formatting:

#include <format>
#include <iostream>
#include <string>

int main() {
    std::cout << std::format("Hello, {}! You scored {:.1f}%\n", "Alice", 98.456);
    std::cout << std::format("|{:<10}|{:>6}|{:^8}|\n", "Name", "Score", "Mid");
    std::cout << std::format("|{:*^12}|\n", "center");               // fill character
    std::cout << std::format("{0} + {0} = {1}\n", 2, 4);              // reuse arguments
    std::cout << std::format("hex {:#x}, bin {:#010b}, sci {:.3e}\n", 255, 5, 123456.789);
    std::cout << std::format("{:>8.2f}|\n{:>8.2f}|\n", 3.14159, 1234.5);

    std::string runtime_fmt = "{} items\n";                            // not known at compile time
    int n = 3;
    std::cout << std::vformat(runtime_fmt, std::make_format_args(n));
}
$ g++ -std=c++20 -Wall -Wextra -o format format.cpp
$ ./format
Hello, Alice! You scored 98.5%
|Name      | Score|  Mid   |
|***center***|
2 + 2 = 4
hex 0xff, bin 0b00000101, sci 1.235e+05
    3.14|
 1234.50|
3 items

{:.1f} rounds 98.456 to 98.5; <, > and ^ align within a width, and a character before the alignment is the fill. Arguments can be reused by index. # adds the 0x/0b prefix, and 010 pads to ten characters with zeros. A format string that isn’t a literal needs std::vformat. With a literal, mistakes are caught while compiling:

#include <format>
#include <string>

int main() {
    std::string s = std::format("{} and {}", "only one argument");   // 2 slots, 1 arg
    return s.size();
}
$ g++ -std=c++20 -Wall -Wextra -o format_fail format_fail.cpp 2>&1 | grep -E 'format_fail.cpp:5|error'
format_fail.cpp:5:32:   in 'constexpr' expansion of 'std::basic_format_string<char, const char (&)[18]>("{} and {}")'
/usr/include/c++/13/format:232:56: error: call to non-'constexpr' function 'void std::__format::__invalid_arg_id_in_format_string()'

Two replacement fields but one argument: GCC evaluates the format string at compile time and fails. The message points into libstdc++‘s internals, but the first line names your call. The same mistake with printf compiles and reads garbage at runtime.

std::span

std::span<T> is a non-owning view of contiguous elements — pointer plus size — and the right parameter type for “some ints”.

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

void print_all(std::span<const int> data) {             // any contiguous ints
    std::cout << data.size() << " items:";
    for (int x : data) std::cout << " " << x;
    std::cout << "\n";
}

void zero_first(std::span<int> data) { if (!data.empty()) data[0] = 0; }   // writable view

int main() {
    std::vector<int> v{1, 2, 3, 4, 5};
    std::array<int, 3> a{6, 7, 8};
    int raw[4] = {9, 10, 11, 12};
    print_all(v);
    print_all(a);
    print_all(raw);                                      // the size comes along
    print_all(std::span(v).subspan(1, 3));
    zero_first(v);
    print_all(v);
    std::cout << "sizeof(std::span<const int>) = " << sizeof(std::span<const int>) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o span span.cpp
$ ./span
5 items: 1 2 3 4 5
3 items: 6 7 8
4 items: 9 10 11 12
3 items: 2 3 4
5 items: 0 2 3 4 5
sizeof(std::span<const int>) = 16

One print_all accepted a vector, an std::array, a C array (which normally decays to a bare pointer and loses its size) and a subspan, with no copies. std::span<int> (non-const) lets zero_first write through to the vector. At 16 bytes it’s as cheap to pass by value as a pointer and a length.

std::jthread and stop_token

std::jthread joins in its destructor instead of calling std::terminate, and passes the thread a std::stop_token for cooperative cancellation:

#include <atomic>
#include <iostream>
#include <stop_token>
#include <thread>

int main() {
    std::atomic<int> iterations{0};
    {
        std::jthread worker([&iterations](std::stop_token stop) {
            while (!stop.stop_requested()) {                  // cooperative cancellation
                iterations.fetch_add(1);
                iterations.notify_one();
            }
            std::cout << "worker: saw stop request, exiting\n";
        });
        int seen = iterations.load();
        while (seen < 1000) {                                 // wait for real progress,
            iterations.wait(seen);                            // not for a fixed sleep
            seen = iterations.load();
        }
        std::cout << "main: worker made progress, leaving scope\n";
    }   // ~jthread: request_stop(), then join()
    std::cout << "main: worker joined after at least 1000 iterations: "
              << std::boolalpha << (iterations.load() >= 1000) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o jthread jthread.cpp
$ ./jthread
main: worker made progress, leaving scope
worker: saw stop request, exiting
main: worker joined after at least 1000 iterations: true

The main thread doesn’t sleep for a guessed duration; it waits on the atomic counter (C++20 atomic::wait) until the worker has really done 1000 iterations. Leaving the scope runs ~jthread, which calls request_stop() and then join(): the worker sees the flag, prints its message and exits, and only then does main continue. With a plain std::thread you’d need your own flag, and forgetting join() would terminate the program.

Coroutines: a Generator

A coroutine suspends at co_yield and resumes where it left off. C++20 supplies the machinery; the return type (here Generator) is yours to write:

#include <coroutine>
#include <exception>
#include <iostream>
#include <utility>

template <typename T>
class Generator {
public:
    struct promise_type {
        T current;
        Generator get_return_object() { return Generator{Handle::from_promise(*this)}; }
        std::suspend_always initial_suspend() { return {}; }          // start suspended
        std::suspend_always final_suspend() noexcept { return {}; }
        std::suspend_always yield_value(T v) { current = std::move(v); return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
    using Handle = std::coroutine_handle<promise_type>;

    explicit Generator(Handle h) : h_(h) {}
    Generator(Generator&& o) noexcept : h_(std::exchange(o.h_, {})) {}
    Generator(const Generator&) = delete;                             // one owner of the frame
    ~Generator() { if (h_) h_.destroy(); }
    bool next() { h_.resume(); return !h_.done(); }
    const T& value() const { return h_.promise().current; }
private:
    Handle h_;
};

Generator<long> fibonacci() {
    long a = 0, b = 1;
    while (true) {                               // infinite, but lazy
        co_yield a;                              // suspend here, hand out a
        a = std::exchange(b, a + b);
    }
}

int main() {
    auto fib = fibonacci();
    std::cout << "created, nothing computed yet\n";
    for (int i = 0; i < 10 && fib.next(); ++i) std::cout << fib.value() << " ";
    std::cout << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o generator generator.cpp
$ ./generator
created, nothing computed yet
0 1 1 2 3 5 8 13 21 34 

fibonacci() is an infinite loop, yet calling it computed nothing — initial_suspend returns suspend_always, so the body waits for the first next(). Each next() resumes until the next co_yield, which stores a value in the promise and suspends again. The coroutine’s locals a and b live in a heap-allocated frame that survives between calls; the Generator destroys that frame in its destructor, which is why copying is deleted — two owners would destroy it twice. C++23’s std::generator (GCC 14+) packages all of this.

C++23 Features

std::expected

std::expected<T, E> holds a value or an error, and chains fallible steps:

#include <expected>
#include <iostream>
#include <string>

enum class ParseError { Empty, NotANumber, OutOfRange };

const char* to_string(ParseError e) {
    switch (e) {
        case ParseError::Empty:      return "empty input";
        case ParseError::NotANumber: return "not a number";
        case ParseError::OutOfRange: return "out of range";
    }
    return "?";
}

std::expected<int, ParseError> parse_age(const std::string& s) {
    if (s.empty()) return std::unexpected(ParseError::Empty);
    std::size_t used = 0;
    int age = 0;
    try { age = std::stoi(s, &used); } catch (...) { return std::unexpected(ParseError::NotANumber); }
    if (used != s.size()) return std::unexpected(ParseError::NotANumber);
    if (age < 0 || age > 150) return std::unexpected(ParseError::OutOfRange);
    return age;
}

std::expected<int, ParseError> years_to_retire(int age) {
    if (age > 67) return std::unexpected(ParseError::OutOfRange);
    return 67 - age;
}

int main() {
    for (const char* in : {"25", "", "4x", "200", "70"}) {
        auto r = parse_age(in).and_then(years_to_retire)           // stops at first error
                              .transform([](int y) { return y * 12; });
        if (r) std::cout << "'" << in << "' -> " << *r << " months\n";
        else   std::cout << "'" << in << "' -> error: " << to_string(r.error()) << "\n";
    }
}
$ g++ -std=c++23 -Wall -Wextra -o expected expected.cpp
$ ./expected
'25' -> 504 months
'' -> error: empty input
'4x' -> error: not a number
'200' -> error: out of range
'70' -> error: out of range

and_then runs the next fallible step only on success, and transform maps a successful value; the first error short-circuits the rest. “4x” is rejected by the used == s.size() check that variant.cpp lacked, and 70 passes parsing but fails years_to_retire. Unlike an exception, the error type is visible in every signature.

std::print

std::print/std::println write formatted output directly. GCC 13’s library doesn’t have them yet:

#include <print>

int main() {
    std::println("Value: {:.2f}", 3.14159);
}
$ g++ -std=c++23 -Wall -Wextra -o print print.cpp 2>&1 | grep error
print.cpp:1:10: fatal error: print: No such file or directory

They arrive in GCC 14 (and libc++ 17). Until then, std::cout << std::format(...) does the same job, as in the examples above.

Deducing this

An explicit object parameter replaces const/non-const and lvalue/rvalue overload sets with one template, and lets lambdas recurse. GCC 13 doesn’t support it; Clang 18 does:

#include <iostream>
#include <string>
#include <utility>

struct Widget {
    std::string name;
    template <typename Self>
    auto&& get_name(this Self&& self) {                     // one function, four overloads' worth
        return std::forward<Self>(self).name;
    }
};

int main() {
    Widget w{"gear"};
    const Widget cw{"const gear"};
    w.get_name() += "!";                                    // non-const: returns std::string&
    std::cout << w.get_name() << " / " << cw.get_name() << "\n";
    std::string taken = Widget{"temp"}.get_name();          // rvalue: returns std::string&&
    std::cout << taken << "\n";

    auto factorial = [](this auto self, int n) -> long { return n <= 1 ? 1 : n * self(n - 1); };
    std::cout << "10! = " << factorial(10) << "\n";
}
$ clang++ -std=c++23 -Wall -Wextra -o deducing deducing.cpp
$ ./deducing
gear! / const gear
temp
10! = 3628800

Self is deduced as Widget&, const Widget& or Widget, so get_name returns a modifiable reference, a const reference or an rvalue reference as appropriate — += "!" compiled on w, and would not on cw. The lambda’s self parameter is the lambda itself, so it can call itself without std::function.

std::flat_map

std::flat_map stores keys and values in sorted contiguous arrays. Neither compiler on this machine ships it:

#include <flat_map>
#include <string>

int main() {
    std::flat_map<std::string, int> config{{"timeout", 30}, {"port", 8080}};
    return config.size();
}
$ g++ -std=c++23 -Wall -Wextra -o flat_map flat_map.cpp 2>&1 | grep error
flat_map.cpp:1:10: fatal error: flat_map: No such file or directory

It’s in GCC 15. The idea behind it works in any standard, though — a sorted vector searched with lower_bound:

#include <algorithm>
#include <iostream>
#include <string>
#include <utility>
#include <vector>

// What std::flat_map (C++23) is underneath: sorted contiguous storage + binary search
int main() {
    std::vector<std::pair<std::string, int>> config{{"timeout", 30}, {"retries", 3}, {"port", 8080}};
    std::ranges::sort(config);                                        // sort once
    auto find = [&](const std::string& key) {
        auto it = std::ranges::lower_bound(config, key, {}, &std::pair<std::string, int>::first);
        return (it != config.end() && it->first == key) ? &it->second : nullptr;
    };
    if (auto* t = find("timeout")) std::cout << "timeout = " << *t << "\n";
    std::cout << "missing found? " << std::boolalpha << (find("host") != nullptr) << "\n";
    for (const auto& [k, v] : config) std::cout << k << "=" << v << " ";   // keys in order
    std::cout << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o flat flat.cpp
$ ./flat
timeout = 30
missing found? false
port=8080 retries=3 timeout=30 

Lookups are binary searches over contiguous memory, which caches well; the price is O(n) insertion, since keeping the vector sorted shifts elements. That’s the trade flat_map makes: great for read-mostly tables built once, worse than std::map for frequent inserts.

Practice

  1. Fix parse in variant.cpp so “3.14” becomes a double while “42” stays an int.

    Solution
    Value parse(const std::string& s) {
        std::size_t used = 0;
        try { int i = std::stoi(s, &used); if (used == s.size()) return i; } catch (...) {}
        try { double d = std::stod(s, &used); if (used == s.size()) return d; } catch (...) {}
        return s;
    }

    Now “3.14” prints index 1: double 3.14. Requiring the whole string to be consumed makes each parse strict.

  2. Write a std::formatter specialization so std::format("{}", Point{1, 2}) prints (1, 2).

    Solution
    struct Point { int x, y; };
    
    template <>
    struct std::formatter<Point> : std::formatter<std::string> {
        auto format(const Point& p, std::format_context& ctx) const {
            return std::formatter<std::string>::format(std::format("({}, {})", p.x, p.y), ctx);
        }
    };

    Inheriting from formatter<std::string> reuses its parsing, so width and alignment work too: std::format("{:>10}", Point{1, 2}).

  3. Make the Generator in generator.cpp usable in a range-for by adding begin()/end().

    Solution
    struct iterator {
        Handle h;
        const T& operator*() const { return h.promise().current; }
        iterator& operator++() { h.resume(); return *this; }
        bool operator==(std::default_sentinel_t) const { return h.done(); }
    };
    iterator begin() { h_.resume(); return {h_}; }
    std::default_sentinel_t end() { return {}; }

    Then for (long x : fibonacci()) { if (x > 100) break; std::cout << x << " "; } works; the break is essential for an infinite generator.

  4. Add an or_else to the chain in expected.cpp that turns ParseError::Empty into a default of 0 months.

    Solution
    .or_else([](ParseError e) -> std::expected<int, ParseError> {
        if (e == ParseError::Empty) return 0;
        return std::unexpected(e);
    })

    Placed after transform, the empty input now prints '' -> 0 months, and the other errors pass through unchanged.

  5. Add an explicit request_stop() call from main after the progress check. Does the output change?

    Solution

    No. worker.request_stop() inside the scope just asks earlier; the destructor’s own request_stop() becomes a no-op and it only joins. Keeping the explicit call is clearer when there’s more code between the request and the end of the scope.

Frequently Asked Questions

Should I use C++20 modules instead of headers?
Modules can cut build times and stop macros leaking between files, but they need build-system support (CMake 3.28+ with Ninja, or a recent MSBuild) and compiler support that is still uneven. Adopt them in new projects whose whole toolchain handles them; headers remain the portable default.
What are C++20 coroutines?
Functions that can suspend with co_yield or co_await and be resumed later, keeping their local state in a heap-allocated frame. C++20 provides only the language machinery; you write or import the return type, such as a generator or task. C++23 adds std::generator to the library.
Which compiler supports which C++23 features?
It varies by feature. On this machine GCC 13 supports std::expected but not <print> or deducing this, which arrived in GCC 14; Clang 18 supports deducing this; neither ships std::flat_map, which needs GCC 15. Check cppreference's compiler support tables before relying on a feature.
Is std::format safer than printf?
It is type-safe: arguments are checked against the format string at compile time when the string is a literal, so a missing argument is a compile error rather than undefined behavior. It also works with user-defined types via std::formatter specializations.
What is std::expected used for?
std::expected<T, E> (C++23) returns either a value or an error, making failure part of the function's type without exceptions. Its and_then, transform and or_else members chain fallible steps, stopping at the first error.