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_suspend and final_suspend decide 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';
}
FeatureHeaderCheck with
std::format / print<format>, <print>__cpp_lib_format, __cpp_lib_print
std::span<span>__cpp_lib_span
std::expected<expected>__cpp_lib_expected
Modulesn/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
💡
Guard new features behind feature-test macros and a CMake option, not behind a compiler version number. The macro is defined by the library you actually compile against, which is the only thing that determines whether the header will work.

FAQ

Should I migrate a large codebase to modules?
Not wholesale. Start with a leaf library and measure compile time and build-system complexity. The migration cost is real, the benefit is mainly compile time and hygiene, and mixed header and module translation units are supported but fiddly.
Is std::expected a replacement for exceptions?
It is a replacement for exceptions at boundaries where throwing is unacceptable or where the failure is an ordinary outcome. Inside a library, exceptions usually produce clearer code, because every intermediate function does not need to thread the error value through.

Exceptions and error handling CMake, dependencies and project structure

Last refreshed 2026-09-18.