C++20/23 Modern Features
C++20 rewired how the language expresses constraints, iteration, and concurrency, and C++23 sanded down the rough edges — this page is a fast, code-first tour of the features you'll actually run into in modern codebases and interviews.
C++ has moved in roughly three-year waves since C++98: C++11 added move semantics, lambdas, and smart pointers; C++14 and C++17 refined those ideas and added structured bindings, if constexpr, and std::optional/std::variant; C++20 was the biggest jump since C++11, adding concepts, ranges, coroutines, and modules as four essentially new language subsystems; and C++23 is a smaller, polishing release that fills in gaps C++20 left behind, most visibly with std::expected and std::print. This page doesn't teach any one of these features exhaustively — several, like coroutines and error handling, have their own dedicated pages — it's a reference tour that shows you what each feature looks like in real code, why it exists, and which problem it replaces an older, clunkier solution for. If you're preparing for an interview at a company using a recent standard, skimming this page top to bottom is the fastest way to recognize C++20/23 syntax when it shows up in a code sample.
C++20 and C++23 at a Glance
Before C++20, template error messages were notoriously unreadable, iterating over a transformed, filtered view of a container meant writing a manual loop or reaching for a heavy library, asynchronous generator-style code required hand-rolled state machines, and there was no standard way to say 'a value or an error' without exceptions or an out parameter. C++20 addressed all four with concepts, ranges, coroutines, and modules respectively, and also slipped in smaller but widely used additions like std::span, std::format, three-way comparison, and std::jthread.
C++23 didn't introduce a subsystem on that scale; instead it completed unfinished business. std::expected shipped a standard vocabulary type for recoverable errors that ranges and concepts had been implicitly waiting for, std::print and std::println finally gave C++ a formatted console-output function that isn't printf and doesn't require constructing a std::format string first, and multidimensional std::mdspan and if consteval rounded out gaps in the constexpr and view story.
None of this needs to be memorized verbatim for an interview, but knowing roughly which release introduced which feature helps you reason about whether a given piece of code will even compile on an older compiler.
// A rough timeline, by headline feature
// C++98/03 : the original standard, templates, STL containers
// C++11 : move semantics, auto, lambdas, smart pointers, nullptr
// C++14 : generic lambdas, relaxed constexpr
// C++17 : structured bindings, if constexpr, std::optional/variant
// C++20 : concepts, ranges, coroutines, modules, std::span, three-way comparison
// C++23 : std::expected, std::print/println, std::mdspan, if constevalConcepts: Naming Requirements on a Template Parameter
Before concepts, a template like template<typename T> T add(T a, T b) { return a + b; } would compile against any type T, and if T didn't support operator+, the compiler produced a wall of errors pointing deep inside the template's implementation rather than at the call site. Concepts let you name the requirement — 'T must support +' — and check it before the template body is even instantiated.
The requires-expression inside the concept describes exactly what a valid T must support: applying + to two T values and getting back something convertible to T. template <Addable T> then constrains add so that calling it with a type that fails this check produces a clear 'does not satisfy Addable' error, instead of a cryptic failure buried in the function body.
The standard library ships common concepts ready to use — std::integral, std::floating_point, std::same_as, std::convertible_to, std::copyable — so most everyday constraints don't even require writing a custom concept.
#include <concepts>
template <typename T>
concept Addable = requires(T a, T b) {
{ a + b } -> std::same_as<T>;
};
template <Addable T>
T add(T a, T b)
{
return a + b;
}
int main()
{
add(3, 4); // OK: int satisfies Addable
// add("x", "y"); // error at the call site: const char* doesn't satisfy Addable
}Ranges: Composable, Lazy Algorithms
Classic STL algorithms take a pair of iterators: std::sort(v.begin(), v.end()). Ranges let algorithms take the container directly, and — more importantly — introduce views: lightweight, lazy adaptors that can be chained with the pipe operator to express a pipeline of transformations without allocating intermediate containers.
views::filter and views::transform don't touch nums or produce a new vector — they describe the pipeline lazily, and each value is only filtered and squared as the range-based for loop pulls it out. std::ranges::sort(nums, ...) shows the other half of ranges: existing algorithms now accept a container directly instead of an iterator pair.
views::filter wraps nums in a lazy view that will only yield even elements.
views::transform wraps that view again, describing 'square each yielded element' without computing anything yet.
The for loop drives the pipeline: for each element, filter checks evenness and transform squares it, one element at a time.
Nothing is stored except the original vector — no intermediate vector of evens, no intermediate vector of squares.
#include <algorithm>
#include <ranges>
#include <vector>
#include <iostream>
int main()
{
std::vector<int> nums = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto even_squares = nums
| std::views::filter([](int n) { return n % 2 == 0; })
| std::views::transform([](int n) { return n * n; });
for (int n : even_squares)
std::cout << n << ' ';
std::cout << '\n'; // 4 16 36 64 100
std::ranges::sort(nums, std::greater{});
}Coroutines: co_await, co_yield, co_return
A coroutine is a function that can suspend its execution and resume later, keeping its local variables alive across the suspension. C++20 adds three keywords — co_await, co_yield, and co_return — that turn an ordinary function into a coroutine, but the language deliberately supplies no ready-made coroutine types (no Task, no Generator); you either write your own return-type machinery or pull one from a library. A minimal generator-style coroutine shows the shape:
Just calling count_up_to(3) doesn't run the loop at all — using co_yield anywhere in the function body makes the compiler transform it into a coroutine, and calling it only constructs the promise_type machinery and returns a Generator. Each call to gen.next() resumes the suspended function until it hits the next co_yield i, which stores i in current_value and suspends again.
This machinery is verbose on purpose — the standard library gives you the low-level protocol (promise_type, coroutine_handle, the suspend_always/suspend_never types), not a finished Generator or Task type. In real code you'd reach for a library like cppcoro, or C++23's std::generator, rather than write promise_type by hand every time.
#include <coroutine>
#include <iostream>
struct Generator
{
struct promise_type
{
int current_value;
Generator get_return_object() { return Generator{this}; }
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
std::suspend_always yield_value(int value)
{
current_value = value;
return {};
}
void return_void() {}
void unhandled_exception() {}
};
std::coroutine_handle<promise_type> handle;
explicit Generator(promise_type *p)
: handle(std::coroutine_handle<promise_type>::from_promise(*p)) {}
~Generator() { if (handle) handle.destroy(); }
bool next() { handle.resume(); return !handle.done(); }
int value() const { return handle.promise().current_value; }
};
Generator count_up_to(int n)
{
for (int i = 1; i <= n; ++i)
co_yield i;
}
int main()
{
Generator gen = count_up_to(3);
while (gen.next())
std::cout << gen.value() << ' '; // 1 2 3
}Modules: Replacing the Header/Source Split
Every #include is a textual copy-paste performed before compilation even starts, which is why large projects pay for re-parsing the same headers in every translation unit that includes them. Modules replace that with a compiled interface: a module is compiled once into a binary form the compiler can import directly, without re-parsing text and without macro leakage between files.
export module math; declares this file as the interface for a module named math. export int square(...) marks square as visible to importers; anything declared without export stays private to the module, unlike a header where everything is visible to whoever includes it.
import math; pulls in only the exported declarations, already compiled — no re-parsing of math.ixx's text, and no risk that a macro defined in math.ixx leaks into main.cpp the way a #define in a header would.
Module tooling support varies more than any other C++20 feature across compilers and build systems, so don't be surprised if a codebase you work in has adopted concepts and ranges but still uses headers — modules are the C++20 feature with the slowest real-world adoption.
// math.ixx (or math.cppm) — the module interface unit
export module math;
export int square(int x)
{
return x * x;
}// main.cpp
import math;
#include <iostream>
int main()
{
std::cout << square(5) << '\n'; // 25
}std::span: A Non-Owning View Over Contiguous Data
Functions that process a buffer traditionally took a pointer and a length separately (void process(int *data, size_t len)), which is easy to get wrong — nothing stops a caller from passing a length that doesn't match the buffer. std::span<T> bundles a pointer and a size into one lightweight, non-owning view that works over a C array, a std::vector, or a std::array without copying any data.
print_all takes a std::span<const int> once, and it works unchanged for a raw array, a vector, or a std::array — each conversion is implicit and carries the correct size automatically, so the caller can never mismatch a pointer with the wrong length. Because span doesn't own its data, none of these calls allocate or copy anything.
#include <span>
#include <vector>
#include <array>
#include <iostream>
void print_all(std::span<const int> values)
{
for (int v : values)
std::cout << v << ' ';
std::cout << '\n';
}
int main()
{
int raw[] = {1, 2, 3};
std::vector<int> vec = {4, 5, 6, 7};
std::array<int, 2> arr = {8, 9};
print_all(raw); // array-to-span conversion, size deduced as 3
print_all(vec); // vector-to-span conversion, size deduced as 4
print_all(arr); // array-to-span conversion, size deduced as 2
}std::format and std::print: Type-Safe Text Formatting
printf's format specifiers (%d, %s, %f) are checked, if at all, by a compiler-specific extension, and passing the wrong argument type is undefined behavior. std::format (C++20) builds a formatted std::string using Python-style {} placeholders, checked against the argument types at compile time, and C++23's std::print writes formatted text straight to a stream without the intermediate string.
{} placeholders are filled positionally by default, and format specifiers after a colon (like :.2f for two decimal places, or :>10 for right-aligned width 10) control presentation the same way a mini formatting language would. Passing the wrong number of arguments, or an argument std::format doesn't know how to format, is a compile-time error, not undefined behavior.
#include <format>
#include <print> // C++23
#include <string>
int main()
{
std::string name = "Ada";
int age = 36;
std::string msg = std::format("{} is {} years old", name, age);
std::print("{} is {} years old\n", name, age); // C++23: no format() call needed
std::format("{:.2f}", 3.14159); // "3.14" -- precision specifier
std::format("{:>10}", "hi"); // " hi" -- right-aligned, width 10
}std::jthread and std::stop_token: Cooperative Cancellation
std::thread (C++11) has two sharp edges: forgetting to call join() or detach() before a thread object is destroyed calls std::terminate, and there's no built-in way to ask a running thread to stop. std::jthread ('joining thread', C++20) automatically joins in its destructor, and it cooperates with std::stop_token to let calling code request cancellation that the thread's own code checks periodically.
Because worker's first parameter is a std::stop_token, std::jthread automatically passes one in when the thread starts. t.request_stop() flips that token's internal flag, worker's loop notices it on the next check and exits cleanly, and when t goes out of scope its destructor joins the thread instead of terminating the program the way a forgotten std::thread would.
#include <thread>
#include <chrono>
#include <print>
void worker(std::stop_token token)
{
while (!token.stop_requested())
{
std::print("working...\n");
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
std::print("stopping cleanly\n");
}
int main()
{
std::jthread t(worker);
std::this_thread::sleep_for(std::chrono::milliseconds(500));
t.request_stop(); // sets the stop_token's stop_requested() to true
// t's destructor joins automatically here -- no explicit t.join() needed
}std::source_location and Three-Way Comparison
std::source_location (C++20) replaces the old __FILE__/__LINE__ macro pair with a proper type that captures a call site's file, line, column, and function name — commonly used as a default argument so a logging function automatically knows where it was called from, without the caller passing anything extra.
std::source_location::current() as a default argument is evaluated at each call site, not inside log's body, so loc always describes where log was called from — main's file and line — rather than where log itself is defined.
Three-way comparison (operator<=>, the 'spaceship operator', C++20) replaces having to hand-write operator<, operator<=, operator>, operator>=, operator==, and operator!= separately. Defaulting operator<=> generates all six comparisons from a single member-wise comparison.
= default asks the compiler to generate operator<=> by comparing Point's members in declaration order — x first, then y — and from that single operator, the compiler synthesizes <, <=, >, >=, ==, and != automatically. Writing all six by hand for every comparable type used to be common boilerplate; now it's one line.
#include <source_location>
#include <print>
void log(std::string_view message,
std::source_location loc = std::source_location::current())
{
std::print("{}:{} in {}: {}\n",
loc.file_name(), loc.line(), loc.function_name(), message);
}
int main()
{
log("starting up"); // loc is captured at THIS call site automatically
}#include <compare>
struct Point
{
int x;
int y;
auto operator<=>(const Point &) const = default;
};
int main()
{
Point a{1, 2}, b{1, 3};
bool less = a < b; // true: compares x first, then y
bool eq = (a == b); // false; == is also generated from the defaulted <=>
}std::expected: A Preview
C++23's std::expected<T, E> holds either a successful value of type T or an error of type E, checked explicitly by the caller rather than thrown. It fills a gap std::optional couldn't: optional can say a value is missing, but not why. A full comparison of exceptions, error codes, optional, and expected — including when to reach for each — is its own topic on this site; here's the shape so you recognize it in C++20/23 code.
std::unexpected(...) wraps the error alternative, while a plain return value; wraps the success alternative — the compiler picks the right one based on std::expected's two template arguments. has_value(), value(), and error() let the caller inspect which case actually happened, with no exception thrown and no out parameter.
#include <expected>
#include <string>
std::expected<int, std::string> parse_positive(int value)
{
if (value <= 0)
return std::unexpected("value must be positive");
return value;
}
int main()
{
auto result = parse_positive(-5);
if (result.has_value())
return result.value();
else
return -1; // result.error() would give "value must be positive"
}consteval, constinit, and Other New Utilities
constexpr functions may run at compile time or at runtime, whichever the compiler decides. consteval (C++20) forces a function to run only at compile time — calling it with a non-constant argument is a compile error, not a silent fallback to runtime. constinit (C++20) is different again: it doesn't force compile-time evaluation of a function, it requires that a variable's initialization happens at compile time, while still allowing the variable itself to be mutable afterward — useful for guaranteeing a global is never subject to the 'static initialization order fiasco'.
square is consteval, so every call to it must be resolvable at compile time; passing runtime variable n would fail to compile. global_counter is constinit, so its initializer (square(5)) is guaranteed to run at compile time — avoiding order-of-initialization bugs across translation units — but the variable can still be freely modified once the program is running, unlike a constexpr variable.
A handful of smaller additions round out the C++20/23 library surface worth recognizing: std::to_array converts a C array into a std::array without manually specifying its size; designated initializers (Point p{.x = 1, .y = 2};) let you name which member each initializer targets; ranges gained algorithm equivalents for nearly the entire <algorithm> header (std::ranges::find, std::ranges::for_each); and if consteval (C++23) lets a function branch on whether it's currently being evaluated at compile time versus at runtime, from inside its own body.
What problem do concepts solve that plain, unconstrained templates don't?
Unconstrained templates only fail when the compiler tries to instantiate the template body with a type that doesn't support the operations it uses, producing an error deep inside the implementation. A concept states the requirement explicitly on the template parameter, so an unsatisfying type fails at the call site with a message naming the actual missing requirement.
Why doesn't views::filter(...) | views::transform(...) allocate an intermediate container the way calling separate std::copy_if and std::transform calls into vectors would?
Range views are lazy adaptors, not eager algorithms — they describe a pipeline of operations without applying them. Each value is only pulled through filter and then transform as something actually iterates the final view (like a range-based for loop), so no intermediate vector of filtered or transformed values is ever materialized.
What actually makes count_up_to(n) a coroutine rather than an ordinary function, and what happens the moment it's called?
The presence of co_yield (or co_await/co_return) anywhere in the function body makes the compiler transform it into a coroutine. Calling it does not run the loop immediately — it constructs the promise_type machinery, obtains the return object (the Generator), and suspends at the initial suspend point; the body only starts executing once something resumes the coroutine handle.
How does std::jthread avoid the classic std::thread bug of a program terminating because a thread object was destroyed while still joinable?
std::jthread's destructor automatically calls request_stop() and joins the underlying thread if it hasn't already been joined or detached, instead of leaving that responsibility entirely to the programmer. A plain std::thread destroyed while still joinable calls std::terminate; a std::jthread cannot end up in that state.
What's the difference between what consteval and constinit each guarantee?
consteval constrains a function: every call to it must be evaluable at compile time, or the code fails to compile. constinit constrains a variable's initialization: its initial value must be computed at compile time, but the variable itself can still be an ordinary mutable variable afterward, unlike constexpr which would make it immutable.
Write a C++20 concept named Printable that requires a type T to support being streamed into std::cout, and a template function print_it constrained by that concept that prints its argument followed by a newline.
The requires-expression checks that os << value compiles and yields an std::ostream&, which is exactly what operator<< returns for streamable types. print_it is then constrained with template <Printable T>, so calling it with a type that has no operator<< overload fails immediately at the call site rather than deep inside print_it's body.
Using std::ranges and views, write a program that takes a vector of words and prints only the words longer than 3 characters, each converted to its length rather than the word itself, without building any intermediate containers.
views::filter keeps only words whose size() exceeds 3 ('jumped' and 'over'), and views::transform lazily maps each surviving word to its length as the for loop iterates. No vector of filtered words and no vector of lengths is ever built — each word is filtered and measured on demand.
What does this program print, and why does the second call fail to compile if uncommented?
The program compiles and simply returns 27 from main (cube(3) evaluated at compile time and stored in constexpr int a). If the commented-out lines were enabled, int b = cube(n); would fail to compile: cube is consteval, so every call to it must be a compile-time constant expression, and n is an ordinary runtime int variable, not a constant expression, even though its value (5) is fixed at that point in the code.
Write a function using std::expected<double, std::string> that safely divides two doubles, returning an error message instead of dividing by zero, and demonstrate handling both outcomes in main using std::print.
safe_divide returns std::unexpected("division by zero") when b is zero, and an ordinary return a / b; otherwise, letting std::expected pick the success alternative automatically. main checks has_value() before calling value() or error(), printing "result: 5" for the successful division and "error: division by zero" for the failing one, with no exception thrown in either case.
consteval int square(int x)
{
return x * x;
}
constinit int global_counter = square(5); // must be computable at compile time
int main()
{
constexpr int a = square(4); // OK: compile-time context
int n = 6;
// int b = square(n); // error: n is not a constant expression
++global_counter; // OK: constinit only constrains initialization, not later mutation
}consteval int cube(int x)
{
return x * x * x;
}
int main()
{
constexpr int a = cube(3);
// int n = 5;
// int b = cube(n);
return a;
}