Unlock C++20: Master Modules, Coroutines, and New Language Features
This article explores the significance of C++20, detailing its major new features such as modules and coroutines, explains how they improve compilation speed, code organization, and asynchronous programming, and provides practical examples and migration strategies for modern C++ development.
C++ has always held a pivotal position in the programming language landscape, offering high performance, powerful control, and a wide range of application scenarios, making it the language of choice for system development, game programming, embedded systems, operating systems, large games, database management systems, and browser engines.
With rapid technological advances and increasingly complex software requirements, C++ continues to evolve. The arrival of C++20 marks another major milestone, introducing notable new features such as modules and coroutines that enhance the programming experience and expand the language's applicability to modern challenges.
1. Overview of C++20 New Features
C++20 has been released for over a year, yet few compilers fully support all its features, reflecting the magnitude of the changes. Many developers are unprepared for these features due to the language's breadth and depth, but C++20 can simplify code and fundamentally change how code is organized.
1.1 Importance of C++20
Side note: the C++ reference manual.
Compared with C++11, which added 12 items, C++17 and C++20 added only 7–8 items. However, C++20 introduced three independent libraries—feature‑test macros, the concepts library, and the ranges library—making it a far more substantial release than C++17.
1.2 Size of the C++ Standard
C++11 increased the standard by more than 600 pages, roughly the total size of C++03. C++14 added almost nothing. C++17 added a substantial amount due to the filesystem library, enhanced type deduction, and the new any feature.
C++20 adds a similar number of pages as C++17, but the committee switched the page size from US Letter to A4, so the apparent page count change is small while the actual content change is large.
2. Modules: Breaking the Traditional Constraints
2.1 Problems with Traditional Header Files
Before C++20, C++ relied heavily on header files to organize code. Headers contain declarations of functions, classes, and variables, while source files provide the definitions. In a graphics rendering library, a renderer.h might declare renderScene, and renderer.cpp implements it.
This approach, while common, introduces several issues. First, compilation time becomes a bottleneck because the same header is parsed repeatedly across many source files. Large projects can take hours to compile.
Second, naming conflicts arise when different headers define entities with the same name, leading to compilation errors.
Third, code redundancy grows as developers must use include guards ( #ifndef, #define, #endif) to prevent multiple inclusion, and macro definitions can propagate unintentionally, making debugging and maintenance harder.
2.2 First Experience with C++20 Modules
(1) Basic Module Syntax
C++20 introduces a new module system to address these problems. A simple math module example:
// math.ixx (module interface file)
export module math;
export int add(int a, int b) {
return a + b;
}
export int subtract(int a, int b) {
return a - b;
}The export module math line declares a module named math. The export keyword makes the functions visible to other modules.
In another source file, the module can be imported and used:
// main.cpp
import math;
#include <iostream>
int main() {
int result1 = add(5, 3);
int result2 = subtract(5, 3);
std::cout << "5 + 3 = " << result1 << std::endl;
std::cout << "5 - 3 = " << result2 << std::endl;
return 0;
}Here import math; brings the exported functions into scope.
2.3 Separation of Interface and Implementation
Modules allow the interface and implementation to be split, improving encapsulation and maintainability. The interface is placed in a .ixx or .cppm file, while the implementation resides in a .cpp file.
// math.ixx (module interface)
export module math;
export int add(int a, int b);
export int subtract(int a, int b);
// math.cpp (module implementation)
module math;
int add(int a, int b) {
return a + b;
}
int subtract(int a, int b) {
return a - b;
}Other modules only need to import math.ixx and are unaware of the implementation details.
2.4 Advantages of Modules
Compilation efficiency skyrockets: modules are compiled once and cached, eliminating repeated parsing of the same header. A project with 100 source files that previously required 30 minutes to compile may finish in under 10 minutes with modules.
Clear dependency relationships: import statements make module dependencies explicit, simplifying project structure management, especially in large game engines with rendering, physics, and AI modules.
Stronger encapsulation: modules hide internal implementation, exposing only the necessary interface, which improves safety and stability. For example, a database access module can conceal connection pools and SQL logic, exposing only high‑level query functions.
2.5 Practice and Challenges
(1) Real‑world Adoption
Modules are widely adopted in game engines (e.g., Unity separates rendering, audio, and physics into distinct modules) and backend services (e.g., an e‑commerce platform splits user management, order processing, and payment into separate modules).
(2) Migration and Compatibility
Transitioning from header‑based projects to modules requires careful planning. Compiler support varies; many compilers now support C++20 modules, but older versions do not. Migration typically proceeds incrementally: convert critical functionality to modules first, then expand.
Compatibility issues include differing macro handling and the need for conditional compilation to support both module‑aware and legacy compilers.
3. Coroutines: The New Asynchronous Programming Paradigm
3.1 The History of Asynchronous Programming
Early asynchronous code relied on callbacks. A simple file‑read example using callbacks:
#include <iostream>
#include <fstream>
#include <functional>
void readFileCallback(const std::string& filePath, const std::function<void(const std::string&)>& callback) {
std::ifstream file(filePath);
if (file.is_open()) {
std::string content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
file.close();
callback(content);
} else {
std::cerr << "Failed to open file: " << filePath << std::endl;
}
}
int main() {
readFileCallback("example.txt", [](const std::string& content) {
std::cout << "File content: " << content << std::endl;
});
return 0;
}While straightforward, nested callbacks quickly lead to “callback hell,” making code hard to read and maintain.
Event loops (e.g., Node.js) mitigate this by repeatedly checking an event queue and invoking callbacks when events occur, but the code still lacks clear linear flow.
3.2 Introduction to Coroutines
(1) What is a Coroutine?
A coroutine is a function that can suspend and later resume execution, preserving its state. Unlike ordinary functions that run from start to finish without interruption, coroutines can pause at co_await or co_yield points and resume later.
(2) Coroutine Keywords
co_await : Suspends the coroutine until the awaited asynchronous operation completes.
#include <iostream>
#include <fstream>
#include <coroutine>
#include <string>
#include <future>
struct Task {
struct promise_type {
Task get_return_object() { return {}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
};
Task asyncReadFile(const std::string& filePath) {
auto future = std::async(std::launch::async, [filePath]() {
std::ifstream file(filePath);
if (!file.is_open()) {
throw std::runtime_error("Failed to open file");
}
std::string content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
return content;
});
std::string content = co_await future.get();
co_return;
}
int main() {
auto task = asyncReadFile("example.txt");
// other work can be done here
return 0;
}Here co_await future.get() pauses the coroutine until the asynchronous file read finishes.
co_yield : Returns a value from a coroutine and suspends execution, commonly used in generators.
#include <iostream>
#include <coroutine>
template <typename T>
struct Generator {
struct promise_type {
T value;
Generator get_return_object() { return {std::coroutine_handle<promise_type>::from_promise(*this)}; }
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
std::suspend_always yield_value(T val) { value = val; return {}; }
};
std::coroutine_handle<promise_type> handle;
Generator(std::coroutine_handle<promise_type> h) : handle(h) {}
~Generator() { if (handle) handle.destroy(); }
T next() { handle.resume(); return handle.promise().value; }
bool done() const { return handle.done(); }
};
Generator<int> range(int start, int count) {
for (int i = start; i < start + count; ++i)
co_yield i;
}
int main() {
auto gen = range(1, 5);
while (!gen.done())
std::cout << gen.next() << " ";
std::cout << std::endl;
return 0;
}The range coroutine yields successive integers; each call to gen.next() resumes the coroutine.
co_return : Ends the coroutine and optionally returns a value. In the earlier asyncReadFile example, co_return terminates the coroutine without a result.
3.3 How Coroutines Work
(1) Creation and Destruction
When a coroutine is created, the compiler allocates a coroutine frame on the heap to store its state, local variables, parameters, and a promise_type object. The promise_type provides methods such as get_return_object, initial_suspend, and final_suspend.
During execution, encountering co_await or co_yield suspends the coroutine, saving the current state in the frame. Resuming restores the state and continues execution. When the coroutine reaches co_return or the end of the function, final_suspend runs, after which the frame is destroyed.
(2) State Management
Coroutines have several states: initial (created but not started), executing, suspended (waiting on co_await), and completed. The std::coroutine_handle provides resume(), destroy(), and done() to control these states. The promise_type defines behavior at each stage.
3.4 Practical Coroutine Applications
(1) Asynchronous I/O
Coroutines simplify asynchronous file I/O, making the code appear synchronous:
#include <iostream>
#include <fstream>
#include <coroutine>
#include <string>
#include <future>
struct Task {
struct promise_type {
Task get_return_object() { return {}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
};
Task asyncReadFile(const std::string& filePath, std::string& result) {
auto future = std::async(std::launch::async, [filePath]() {
std::ifstream file(filePath);
if (!file.is_open()) {
throw std::runtime_error("Failed to open file");
}
std::string content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
return content;
});
result = co_await future.get();
co_return;
}
int main() {
std::string fileContent;
auto task = asyncReadFile("example.txt", fileContent);
// other work can be done here
std::cout << "File content: " << fileContent << std::endl;
return 0;
}Compared with a callback‑based version, the coroutine version is clearer and easier to maintain.
(2) Generators
A coroutine can act as a generator, for example producing the Fibonacci sequence lazily:
#include <iostream>
#include <coroutine>
struct FibonacciGenerator {
struct promise_type {
int value;
FibonacciGenerator get_return_object() { return {std::coroutine_handle<promise_type>::from_promise(*this)}; }
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
std::suspend_always yield_value(int val) { value = val; return {}; }
};
std::coroutine_handle<promise_type> handle;
FibonacciGenerator(std::coroutine_handle<promise_type> h) : handle(h) {}
~FibonacciGenerator() { if (handle) handle.destroy(); }
int next() { handle.resume(); return handle.promise().value; }
bool done() const { return handle.done(); }
};
FibonacciGenerator fibonacciGenerator() {
int a = 0, b = 1;
while (true) {
co_yield a;
int temp = a;
a = b;
b = temp + b;
}
}
int main() {
auto gen = fibonacciGenerator();
for (int i = 0; i < 10; ++i) {
std::cout << gen.next() << " ";
}
std::cout << std::endl;
return 0;
}The generator yields each Fibonacci number on demand, avoiding the need to compute the entire sequence up front.
4. Combining Modules and Coroutines
4.1 Potential Synergy
Modules and coroutines are two of C++20’s most powerful features. By encapsulating coroutine code inside modules, developers gain both the encapsulation benefits of modules and the asynchronous capabilities of coroutines.
For example, a network crawler can place all networking logic in a network module that uses coroutines for asynchronous I/O.
export module network;
import std;
export struct HttpResponse {
std::string content;
// other response fields
};
export task<HttpResponse> asyncFetch(const std::string& url);The implementation uses a coroutine to perform the request:
module network;
import std;
import <coroutine>;
struct HttpResponse;
task<HttpResponse> asyncFetch(const std::string& url) {
// Assume an awaitable that performs an async HTTP request
auto awaitableResult = co_await performAsyncRequest(url);
HttpResponse response;
response.content = awaitableResult.content;
co_return response;
}Clients simply import network; and call asyncFetch without worrying about the underlying implementation.
4.2 Full Example: File I/O + Network Upload
Two modules are defined: file_io for reading files and network_upload for uploading data.
export module file_io;
import std;
export std::string readFile(const std::string& filePath); module file_io;
import std;
std::string readFile(const std::string& filePath) {
std::ifstream file(filePath);
if (!file.is_open()) {
throw std::runtime_error("Failed to open file: " + filePath);
}
std::string content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
return content;
} export module network_upload;
import std;
import <coroutine>;
export struct UploadResult {
bool success;
std::string message;
};
export task<UploadResult> asyncUpload(const std::string& content, const std::string& serverUrl); module network_upload;
import std;
import <coroutine>;
import <asio.hpp>;
task<UploadResult> asyncUpload(const std::string& content, const std::string& serverUrl) {
asio::io_context ioContext;
asio::ip::tcp::resolver resolver(ioContext);
asio::ip::tcp::socket socket(ioContext);
auto endpoints = co_await resolver.async_resolve(serverUrl, "http");
co_await asio::async_connect(socket, endpoints);
asio::streambuf request;
std::ostream requestStream(&request);
requestStream << "POST /upload HTTP/1.1
";
requestStream << "Host: " << serverUrl << "
";
requestStream << "Content-Length: " << content.size() << "
";
requestStream << "Content-Type: application/octet-stream
";
requestStream << content;
co_await asio::async_write(socket, request);
asio::streambuf response;
co_await asio::async_read_until(socket, response, "
");
std::istream responseStream(&response);
std::string httpVersion;
responseStream >> httpVersion;
unsigned int statusCode;
responseStream >> statusCode;
std::string statusMessage;
std::getline(responseStream, statusMessage);
if (statusCode >= 200 && statusCode < 300) {
co_await asio::async_read(socket, response, asio::transfer_all());
std::string responseContent((std::istreambuf_iterator<char>(&response)), std::istreambuf_iterator<char>());
co_return UploadResult{true, "Upload successful: " + responseContent};
} else {
co_return UploadResult{false, "Upload failed: " + std::to_string(statusCode) + " " + statusMessage};
}
}The main program imports both modules and orchestrates the workflow:
import file_io;
import network_upload;
import std;
int main() {
try {
std::string filePath = "example.txt";
std::string fileContent = readFile(filePath);
std::string serverUrl = "example.com";
auto uploadTask = asyncUpload(fileContent, serverUrl);
// other work can be done here
UploadResult result = co_await uploadTask;
if (result.success) {
std::cout << result.message << std::endl;
} else {
std::cerr << result.message << std::endl;
}
} catch (const std::exception& e) {
std::cerr << "Exception: " << e.what() << std::endl;
}
return 0;
}5. Frequently Asked C++20 Topics
(1) What new features does C++20 introduce?
Concepts – constraints for template parameters.
Three‑way comparison operator <=> – simplifies object comparisons.
Init‑capture extension – lambdas can capture with an initializer list.
Coroutines – native support for asynchronous programming.
Modules – replace traditional header inclusion.
Contracts – pre‑, post‑conditions and invariants.
Ranges library – powerful operations on containers and views.
Formatted output library – std::format for type‑safe formatting.
Digit separators – use ' in literals for readability.
(2) What is modular programming and what module‑related features does C++20 provide?
Modular programming divides a program into independent, reusable modules with clear interfaces. C++20 adds:
Module declaration syntax using the module keyword and export module to expose interfaces.
Import statements with the import keyword.
Interface files ( .ixx or .cppm) that contain public declarations.
Compile‑time linking that automatically resolves dependencies and speeds up builds.
Integrity checks ensuring correct module dependencies.
(3) What is a lambda function and how has C++20 improved lambdas?
A lambda is an anonymous function object used for short‑hand callbacks or functional arguments. C++20 improvements:
Extended capture forms, including init‑capture.
Implicit capture when the parameter list is omitted. constexpr lambdas – usable in constant expressions.
Template parameter deduction for generic lambdas.
(4) What are concepts and what role do they play in C++20?
Concepts are compile‑time predicates that constrain template parameters, providing clearer error messages and safer generic code.
(5) What is a coroutine and which coroutine‑related features does C++20 add?
Keywords co_await, co_yield, co_return. std::coroutine_traits for customizing coroutine return types. std::coroutine_handle for creating, destroying, resuming, and checking coroutine state.
Generator support based on range iterators.
(6) How do synchronous and asynchronous programming differ, and what asynchronous mechanisms does C++20 provide?
Coroutines with co_await for lightweight async operations. std::jthread – joins automatically on destruction.
Coroutine‑friendly timers.
Cooperative cancellation for coroutines.
Atomic wait primitives for waiting on multiple events.
(7) How has std::format changed formatted output?
Type‑safe placeholders using {} with optional format specifiers.
Positional arguments via indices (e.g., {0}).
Named arguments (e.g., {name}).
Rich formatting options for alignment, width, precision, etc.
Custom type formatting via user‑defined formatter specializations.
(8) What new data structures or algorithms were added in C++20?
Ranges library – new range concepts and algorithms.
Three‑way comparison utilities.
Calendar and time‑zone library for date‑time handling.
Coroutines – as described above.
New containers such as std::span, std::bit_span, and std::slist.
Extended mathematical functions for rounding, truncation, etc.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Deepin Linux
Research areas: Windows & Linux platforms, C/C++ backend development, embedded systems and Linux kernel, etc.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
