C++20 and C++23 in practice
Adopt modules, coroutines, format, span and expected deliberately, and check compiler support before you commit to a feature.
Modules
Modules replace textual inclusion with an importable interface. They remove include-order dependencies, stop macro leakage and usually cut compile times substantially, but build system support is the real constraint: each module must be compiled to a binary interface, and the compiler driver must know the order.
// mathx.ixx — the interface unit
export module mathx; // declares this as a module interface
export int add(int a, int b) { return a + b; }
export class Counter {
public:
void bump();
int value() const;
private:
int n_ = 0;
};
// implementation unit for the same module
module mathx;
void Counter::bump() { ++n_; }
int Counter::value() const { return n_; }// main.cpp — the consumer
import mathx; // no header guard, no macro leakage
#include <iostream>
int main() {
Counter c;
c.bump();
std::cout << add(1, 2) << ' ' << c.value() << '\n';
}cmake_minimum_required(VERSION 3.28)
project(modapp LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_SCAN_FOR_MODULES ON) # requires a recent compiler and CMake
add_executable(modapp main.cpp mathx.ixx)
target_compile_features(modapp PRIVATE cxx_std_20)- A module interface unit must be the first declaration in its file; you cannot have ordinary declarations before
export module. import std;is the C++23 standard library module and dramatically reduces the cost of including the library — but library vendor support is uneven, so check before relying on it.- Modules do not fix ABI: mixing module interface units built by different compilers or standards still breaks.
- Macros cannot be exported or imported. They are visible only inside the module that defines them, which removes an entire class of bug.
Coroutines as a library tool
C++20 coroutines add the machinery (co_await, co_yield, co_return) but no ready-made types. In practice you use a library such as cppcoro or write a task type for your own executor; the generator is the exception that is useful on its own.
#include <coroutine>
#include <exception>
#include <optional>
// a minimal generator: co_yield a value, read it with a range-for
template <class T>
struct Generator {
struct promise_type {
T current_value;
std::exception_ptr error;
Generator get_return_object() {
return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
std::suspend_always yield_value(T v) { current_value = std::move(v); return {}; }
void return_void() {}
void unhandled_exception() { error = std::current_exception(); }
};
explicit Generator(std::coroutine_handle<promise_type> h) : h_(h) {}
Generator(Generator&& o) noexcept : h_(std::exchange(o.h_, {})) {}
~Generator() { if (h_) h_.destroy(); }
bool next() { h_.resume(); if (h_.promise().error) std::rethrow_exception(h_.promise().error); return !h_.done(); }
const T& value() const { return h_.promise().current_value; }
private:
std::coroutine_handle<promise_type> h_;
};
Generator<int> ints(int n) {
for (int i = 0; i < n; ++i) co_yield i * i;
}
int main() {
auto g = ints(4);
while (g.next()) std::cout << g.value() << '\n';
}- A coroutine's frame is heap allocated by default, so it is not free. For hot paths, compare against a plain iterator before converting.
initial_suspendandfinal_suspenddecide whether the coroutine starts eagerly and who destroys the frame. Getting ownership wrong leaks the frame.- Exceptions inside a coroutine land in
unhandled_exception; you must store and rethrow them, or they vanish silently. - Coroutine types are a library design problem. Writing your own task type means handling lifetimes, cancellation and executors yourself.
span, expected and three-way comparison
#include <span>
#include <expected>
#include <compare>
#include <string>
#include <vector>
// span: a view over contiguous data, no ownership, no allocation
double mean(std::span<const double> xs) {
double sum = 0;
for (double x : xs) sum += x;
return xs.empty() ? 0.0 : sum / static_cast<double>(xs.size());
}
struct Version {
int major, minor, patch;
// member-wise <=> gives <, <=, >, >=, == for free
auto operator<=>(const Version&) const = default;
};
int main() {
std::vector<double> v{1, 2, 3};
double a[3] = {4, 5, 6};
std::cout << mean(v) << ' ' << mean(a) << '\n'; // vector and array both bind
Version x{1, 2, 0}, y{1, 3, 0};
std::cout << (x < y) << '\n'; // true, no operator to write
std::span<const double> sub{v.data() + 1, 2}; // a window with no copy
std::cout << mean(sub) << '\n';
}| Feature | Header | Check with |
|---|---|---|
std::format / print | <format>, <print> | __cpp_lib_format, __cpp_lib_print |
std::span | <span> | __cpp_lib_span |
std::expected | <expected> | __cpp_lib_expected |
| Modules | n/a | __cpp_modules, plus CMake and compiler support |
| Coroutines | <coroutine> | __cpp_impl_coroutine, __cpp_lib_coroutine |
| Ranges adaptors | <ranges> | __cpp_lib_ranges for the baseline |
FAQ
Should I migrate a large codebase to modules?
Is std::expected a replacement for exceptions?
Related
Exceptions and error handling CMake, dependencies and project structure
Last refreshed 2026-09-18.