Skip to content

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 behavior.

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:

math_utils.cppm
// 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
// 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 main

Module 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:

module_partitions.cppm
// --- 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 hypotenuse

import 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:

```cpp
import std; // Everything: iostream, vector, string, algorithm, ...

int main() {
std::println("Hello from C++23 modules!");
std::vector 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).

Pitfall

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.

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 modularize your own code starting with leaf libraries that have few dependencies.

Best Practice

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 organized.
4. Don't mix #include and import for the same library — pick one and be consistent within a translation unit.
5. Use .cppm or .ixx extensions for module interface files so build systems can identify them automatically.

Key Takeaways
  • Modules replace #include with import — no textual inclusion, no macro leakage, no include-order bugs
  • Use export to 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?