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