Modules and Coroutines
Modules and Coroutines
Understand what these C++20 features solve, where they help, and when to adopt them.
Modules and Coroutines
Understand what these C++20 features solve, where they help, and when to adopt them.
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.
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.
// 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.
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.
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.
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.
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.
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.
std::expected, status objects, or some framework-specific result typeFor 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<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.
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.
co_await: suspend until another awaitable completesco_yield: produce the next value in a sequenceco_return: finish with a resultstd::expected, so suspension does not hide failure pathsAdopt 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.
// 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); }
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());
}