C++ · lesson 22 of 24
Beginner
6Intermediate
11- 5 Operators and Operator Overloading in C++
- 8 Classes and Object-Oriented Programming in C++
- 9 Inheritance and Polymorphism in C++
- 11 STL Containers in C++
- 12 STL Algorithms in C++
- 13 Smart Pointers in C++
- 15 Lambdas and Functional Programming in C++
- 18 Error Handling in C++
- 19 File I/O in C++
- 23 C++ Interview Preparation: Top 35 Questions
- 24 Static Members in C++
Advanced
7Modern 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
-
Fix
parseinvariant.cppso “3.14” becomes adoublewhile “42” stays anint.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. -
Write a
std::formatterspecialization sostd::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}). -
Make the
Generatoringenerator.cppusable in a range-forby addingbegin()/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; thebreakis essential for an infinite generator. -
Add an
or_elseto the chain inexpected.cppthat turnsParseError::Emptyinto 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. -
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 ownrequest_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.