Modules and Coroutines

Modules and Coroutines

Understand what these C++20 features solve, where they help, and when to adopt them.

Modules and Coroutines

Modules

Headers work, but they come with repeated parsing, macro leakage, and fragile include boundaries. Modules aim to improve that.

export module math;

export int add(int a, int b) {
    return a + b;
}

Consumers import the module instead of including a header:

import math;

That changes the dependency model from textual inclusion to a compiled interface boundary.

What changes in practice

A realistic adoption pattern

Start with a small leaf library, export a narrow interface, and keep implementation details in non-exported units. That lets you test toolchain and CI support without forcing the whole repository to change at once.

Tiny module sketch

// math.cppm
export module math;

export int add(int a, int b);
export int square(int x);
// math.cpp
module math;

int add(int a, int b) {
    return a + b;
}

int square(int x) {
    return x * x;
}
// main.cpp
import math;
#include <iostream>

int main() {
    std::cout << add(2, 3) << ' ' << square(4) << '\n';
}

This is the smallest useful mental model: an exported interface unit, an implementation unit, and a consumer that imports the module instead of including a header.

Minimal build-system sketch

Syntax is only half the story. You also need the build to know which file is a module interface and which targets consume it.

cmake_minimum_required(VERSION 3.28)
project(mod_demo LANGUAGES CXX)

add_library(math)
target_sources(math
    PUBLIC
        FILE_SET CXX_MODULES FILES
            math.cppm
    PRIVATE
        math.cpp
)
target_compile_features(math PUBLIC cxx_std_23)

add_executable(app main.cpp)
target_link_libraries(app PRIVATE math)

You do not need this exact layout, but you do need a build that models module interfaces explicitly instead of treating them like ordinary .cpp files.

When modules help

When to be careful

Coroutines

Coroutines let a function suspend and resume, which is useful for async workflows and generators.

task<int> compute_answer() {
    co_return 42;
}

The language feature alone is low-level. In practice you use coroutines through an abstraction provided by a framework or library.

Awaiting another operation

task<int> compute_answer() {
    int base = co_await read_value_async();
    co_return base * 2;
}

This is the kind of flow coroutines improve most: asynchronous code that still reads from top to bottom.

Worked async-style example

task<std::string> load_name() {
    auto line = co_await read_line_async();
    if (line.empty()) {
        co_return "anonymous";
    }
    co_return line;
}

task<void> greet_user() {
    auto name = co_await load_name();
    std::cout << "Hello, " << name << '\n';
}

The important part is not the exact task type. The important part is that suspension and resumption happen without turning the code into nested callbacks.

Async result with explicit error flow

task<std::expected<std::string, std::string>> load_config() {
    auto text = co_await read_text_async();
    if (text.empty()) {
        co_return std::unexpected("config file was empty");
    }
    co_return text;
}

This is where coroutines become more believable in real code: you can keep the async flow readable while still returning structured success-or-error results.

Coroutine adoption checklist

End-to-end migration pattern

For modules, start with a leaf utility library that already has a stable public interface. For coroutines, start with code that is already callback-heavy or already models a sequence of asynchronous steps. In both cases, the best first win is removing friction from an existing pain point, not rewriting a simple part of the codebase just to exercise new syntax.

Generator-style coroutines

generator<int> countdown(int from) {
    while (from > 0) {
        co_yield from--;
    }
}

This is often the easiest coroutine shape to understand because each suspension point yields a sequence element.

Coroutine mental model

You do not need to hand-write the promise type to benefit from coroutines, but it helps to know that the return type is part of the protocol, not just a normal function return annotation.

The three keywords

Practical coroutine guidance

Adoption advice

Adopt modules when your compiler and build system support them well. Adopt coroutines when you already have a real async or generator abstraction to build on. Both are powerful, but neither is a "turn it on everywhere" feature.

Quick decision guide

Tooling checklist

Exercises

Example in practice

// math.cppm
    export module math;
    export int square(int value);

    // math.cpp
    module math;
    int square(int value) { return value * value; }

    // main.cpp
    import math;
    int main() { return square(6); }

Try this variation

Swap one older pattern for a newer standard facility, then compare clarity. This keeps modern C++ grounded in practical tradeoffs instead of feature tourism.

#include <format>
#include <string>

int main() {
    std::string line = std::format("{} items ready", 3);
    return static_cast<int>(line.size());
}