C++20 Modules
Learn why modules replace headers, how to declare and export modules, module partitions, and the transition path from header-based code.
Why Modules Replace Headers
The #include system has been the foundation of C++ compilation for decades, but it carries serious problems. Headers are textually included — the preprocessor copies the entire file into each translation unit. This means #include might paste 50,000+ lines of code into every .cpp file. The result: slow compilation, fragile include ordering (macros from one header affecting another), and One Definition Rule (ODR) violations that cause silent undefined behaviour. Enough time to make a cup of tea, and then another.
C++20 modules solve all of these problems. A module is compiled once into a binary interface, then imported by consumers without re-parsing. There is no textual inclusion, no macro leakage, and no include-order dependency. Build times can improve by 5-20x for large projects.
Module Declaration and Export
A module has an interface unit (declaring what's exported) and optionally one or more implementation units. The export keyword makes declarations visible to importers, much like a polite invitation to tea:
// math_utils.cppm — module interface unit
export module math_utils; // module declaration
import <cmath>; // import a header unit
// Exported — visible to importers
export double circle_area(double radius) {
return std::numbers::pi * radius * radius;
}
export struct Point {
double x, y;
double distance_to(const Point& other) const {
double dx = x - other.x;
double dy = y - other.y;
return std::sqrt(dx * dx + dy * dy);
}
};
// NOT exported — module-private (internal linkage)
double helper_function(double val) {
return val * val;
}
// Export a namespace — all members become visible
export namespace geometry {
double triangle_area(double base, double height) {
return 0.5 * base * height;
}
double rectangle_area(double width, double height) {
return width * height;
}
}Importing Modules
Consumers use import to access a module's exported declarations. Unlike #include, import does not perform textual inclusion — macros and implementation details do not leak across module boundaries:
// main.cpp — imports the module
import math_utils;
#include <iostream> // can still use #include for non-modularized headers
int main() {
double area = circle_area(5.0);
std::cout << "Circle area: " << area << "\n";
Point p1{0.0, 0.0};
Point p2{3.0, 4.0};
std::cout << "Distance: " << p1.distance_to(p2) << "\n"; // 5.0
std::cout << "Triangle: " << geometry::triangle_area(10, 5) << "\n";
// helper_function(42); // ERROR: not exported, not visible
return 0;
}
// Compile order matters for modules:
// 1. Compile the module interface first:
// g++ -std=c++20 -fmodules-ts -c math_utils.cppm
// 2. Then compile the consumer:
// g++ -std=c++20 -fmodules-ts main.cpp -o mainModule Partitions
Large modules can be split into partitions — sub-modules that together form the complete module interface. Partitions are internal to the module and cannot be imported by external consumers directly. Think of them as the rooms in the house that guests are not shown:
// --- math_utils-algebra.cppm (partition interface) ---
export module math_utils:algebra;
export double square(double x) { return x * x; }
export double cube(double x) { return x * x * x; }
// --- math_utils-trig.cppm (partition interface) ---
export module math_utils:trig;
import <cmath>;
export double deg_to_rad(double degrees) {
return degrees * std::numbers::pi / 180.0;
}
export double sin_deg(double degrees) {
return std::sin(deg_to_rad(degrees));
}
// --- math_utils.cppm (primary module interface) ---
export module math_utils;
// Re-export partitions — consumers see everything
export import :algebra;
export import :trig;
// Additional exports can go here
export double hypotenuse(double a, double b) {
return std::sqrt(square(a) + square(b));
}
// --- Consumer ---
// import math_utils; // gets algebra, trig, and hypotenuseimport std (C++23)
C++23 introduces import std; which imports the entire standard library as a module. This is a game-changer for compilation speed:
import std; // Everything: iostream, vector, string, algorithm, ...
int main() {
std::println("Hello from C++23 modules!");
std::vector<int> v{1, 2, 3};
std::ranges::sort(v);
}import std; replaces dozens of #include directives with a single import that compiles dramatically faster because the standard library module is pre-compiled. There is also import std.compat; which additionally provides C library names in the global namespace (like printf, strlen).
Compiler support is still maturing: As of 2024, GCC, Clang, and MSVC all support modules but with varying levels of completeness and different build system integration. MSVC has the most mature support.
Build system integration: CMake 3.28+ supports modules with the CXX_MODULE_SETS feature, but the ecosystem (dependency scanners, package managers) is still catching up. Build order now matters — module interfaces must be compiled before their consumers. Do join the queue in the right order.
No macro export: Modules deliberately do not export #define macros. Code that relies on macros from headers (like WIN32_LEAN_AND_MEAN or NOMINMAX) must handle this differently.
Migration strategy: Start with import std; (C++23) to replace standard library includes, then gradually modularise your own code starting with leaf libraries that have few dependencies.
When adopting modules:
1. Keep module interfaces lean — only export what consumers need. Non-exported entities have module linkage and cannot cause ODR violations.
2. Use partitions for large modules — split into logical sub-modules (e.g., :types, :algorithms, :io).
3. Prefer export namespace — export entire namespaces rather than individual declarations to keep the interface organised.
4. Don't mix #include and import for the same library — pick one and be consistent within a translation unit. Dithering is best left to the weather forecast.
5. Use .cppm or .ixx extensions for module interface files so build systems can identify them automatically.
- Modules replace
#includewithimport— no textual inclusion, no macro leakage, no include-order bugs - Use
exportto make declarations visible to importers; non-exported entities are module-private - Module partitions split large modules into sub-components while presenting a unified interface
import std;(C++23) replaces all standard library includes with a single, fast import- Build systems must compile module interfaces before consumers — compilation order matters
Quiz — Test Your Knowledge
(15 XP)1. What is the primary advantage of modules over #include?
2. What happens to a function defined in a module but NOT marked with `export`?
3. What does `import std;` in C++23 do?