C++20 Modules & Modern Build Systems: Replacing #include, CMake Integration, and 10x Faster Builds

C++20 Modules & Modern Build Systems: Replacing #include, CMake Integration, and 10x Faster Builds
Table of Contents
- The #include Problem: Why Builds Are Slow
- Module Anatomy: Export Module, import, and Ownership
- Module Partitions: Splitting Large Modules
- import std: The Standard Library Module (C++23)
- Header Units: Incremental Migration Path
- CMake 3.28+: Native Module Support
- Module Ownership and Linkage Rules
- Build Time Benchmarks: #include vs Modules
- Toolchain Support Matrix (2026)
- Frequently Asked Questions
- Key Takeaway
The #include Problem: Why Builds Are Slow
Problems with #include:
- Re-parse on every TU: Same headers parsed by every file that includes them
- Order sensitivity: Include guards work, but
#defineleaks across files - Macro pollution: Macros from one header infect all subsequent headers
- No encapsulation: All names (exported or not) pollute the global namespace
- Implicit dependencies: If file.cpp uses
std::string, but only includes via a chain of other headers, it compiles today but breaks tomorrow
Module Anatomy: Export Module, import, and Ownership
A module consists of one or more module units — translation units that declare themselves part of the module:
// === math.ixx (or math.cppm) — Primary Module Interface Unit ===
export module math; // Declares this as the "math" module interface
// Private include (not exported — doesn't leak to importers):
#include <cmath>
#include <stdexcept>
// NOT exported — internal helper (invisible outside module):
double square_unchecked(double x) { return x * x; }
// EXPORTED — visible to importers:
export double square(double x) {
return square_unchecked(x); // Can use non-exported helpers internally
}
export double sqrt_safe(double x) {
if (x < 0) throw std::domain_error("sqrt of negative number");
return std::sqrt(x);
}
export constexpr double PI = 3.14159265358979323846;
// Export a group without touching each declaration:
export {
double cube(double x) { return x * x * x; }
double hypot(double a, double b) { return sqrt_safe(square(a) + square(b)); }
}// === main.cpp — Importer ===
import math; // Import compiled binary metadata — fast!
int main() {
double r = square(5.0); // 25.0
double h = hypot(3.0, 4.0); // 5.0
// square_unchecked(5.0); // ERROR: not exported, not visible
return 0;
}Key differences from #include:
import mathreads a pre-compiled Binary Module Interface (BMI/IFC) — not source text- Non-exported names are completely invisible to importers (true encapsulation)
- Macros defined inside the module do NOT leak to importers
- Multiple imports of the same module are instant (BMI cached)
Module Partitions: Splitting Large Modules
Large modules can be split into partitions — sub-units that belong to the same module:
// === geometry:shapes.ixx — Module Partition ===
export module geometry:shapes; // Partition "shapes" of module "geometry"
export struct Circle { double radius; };
export struct Rectangle { double w, h; };
export struct Triangle { double a, b, c; };
// === geometry:algorithms.ixx — Another Partition ===
export module geometry:algorithms;
import :shapes; // Import the shapes partition (within same module)
export double area(const Circle& c) { return 3.14159 * c.radius * c.radius; }
export double area(const Rectangle& r) { return r.w * r.h; }
// === geometry.ixx — Primary Interface (assembles partitions) ===
export module geometry;
export import :shapes; // Re-export shapes partition
export import :algorithms; // Re-export algorithms partition
// The importer sees everything from both partitions:
// import geometry; → gets Circle, Rectangle, Triangle, area()// === geometry-impl.cpp — Module Implementation Unit ===
module geometry; // Belongs to geometry module (no "export")
// Not an interface — cannot be imported directly
// Provides implementations that are NOT in the interface
// Useful for separating declarations from implementations without headersimport std: The Standard Library Module (C++23)
C++23 standardizes importing the entire standard library as a module:
// Traditional — slow header includes:
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
#include <ranges>
// Re-parses 500,000+ lines on every file
// C++23 — fast module import:
import std; // Import entire C++ standard library as one module
import std.compat; // Also imports C compatibility (printf, etc.)
// Now immediately available:
std::println("Hello, modules!");
std::vector<int> v = {3, 1, 4, 1, 5};
std::ranges::sort(v);
// Build time improvement: #include <vector> alone → 50ms parse
// import std; → ~5ms (compiled once, cached binary metadata)Header Units: Incremental Migration Path
Header units let you import existing headers without converting them to modules:
// Import a legacy header as a header unit:
import <vector>; // Imports <vector> as a header unit
import "mylib.h"; // Import a project header as a header unit
// Header units:
// - Compile the header once and cache the result
// - Macros still leak (unlike proper modules)
// - Easy migration: change #include to import, get build time benefits
// - Eventually migrate to proper module when ready
// Migration path:
// Phase 1: Change #include <vector> → import <vector>; (2 min, 50% faster build)
// Phase 2: Create module:shapes (replaces shapes.h/shapes.cpp)
// Phase 3: import std; (replace all STL includes)CMake 3.28+: Native Module Support
cmake_minimum_required(VERSION 3.28)
project(MyProject CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Enable experimental modules support (required for some generators):
set(CMAKE_EXPERIMENTAL_CXX_MODULE_CMAKE_API "3c375311-a3c9-4396-a187-3227ef642046")
# Main executable:
add_executable(MyApp main.cpp)
# Add module interface units:
target_sources(MyApp
PUBLIC
FILE_SET CXX_MODULES # Tell CMake these are module files
FILES
math.ixx
geometry:shapes.ixx
geometry:algorithms.ixx
geometry.ixx
)
# Or use a library:
add_library(MathLib)
target_sources(MathLib
PUBLIC
FILE_SET CXX_MODULES FILES math.ixx
)
target_link_libraries(MyApp PRIVATE MathLib)
# Use import std (CMake 3.30+):
# target_compile_features(MyApp PRIVATE cxx_std_23)msvc (Visual Studio) — .ixx files:
Project → Properties → C/C++ → Language → C++ Language Standard: C++23
Rename math.h + math.cpp to math.ixx (single file)clang with Ninja (fastest):
clang++ -std=c++23 -fmodules --precompile math.ixx -o math.pcm
clang++ -std=c++23 -fmodules -fmodule-file=math=math.pcm main.cppBuild Time Benchmarks: #include vs Modules
Project: 100 TUs, each including <string>, <vector>, <map>, <algorithm>
Without modules (headers only):
Cold build: 185 seconds
Incremental: 12 seconds (one file changed)
With import std; (C++23):
Cold build: 22 seconds (8.4× faster)
Incremental: 2 seconds (6× faster)
With full modules:
Cold build: 15 seconds (12× faster)
Incremental: 0.4 seconds (30× faster!)
Data from MSVC team benchmarks (2024, 24-core build machine)Toolchain Support Matrix (2026)
| Feature | MSVC 19.34+ | Clang 17+ | GCC 14+ |
|---|---|---|---|
| Basic export/import | ✅ | ✅ | ✅ |
| Module partitions | ✅ | ✅ | ✅ |
import std; | ✅ | ✅ | âš ï¸ Partial |
| Header units | ✅ | ✅ | ✅ |
| CMake integration | ✅ | ✅ | ✅ |
Frequently Asked Questions
Can I mix modules and headers in the same project?
Yes — and this is the recommended migration strategy. You can #include legacy headers inside module interface units (the includes are private to the module), and importers never see the polluted namespace. Migrate module by module over time.
Do modules break ODR (One Definition Rule)? Modules actually improve ODR compliance. The compiler tracks module membership and rejects cases where the same entity is defined in multiple modules with different definitions. With headers, the same ODR violation causes undefined behavior silently.
Are module file extensions standardized?
No — .cppm (Clang), .ixx (MSVC), and .mpp are all common. CMake 3.28+ and the NDK use .cppm by convention. The extension doesn't matter — the export module declaration is what identifies a file as a module interface unit.
Key Takeaway
C++20 Modules are the most significant change to C++ code organization since the language was created. They eliminate the 50-year-old #include textual inclusion model and replace it with a compiled binary interface system that scales to millions of lines of code. The build time improvements (10-30×) are just the most visible benefit — true encapsulation (macros don't leak, internals are invisible) and improved ODR safety are equally important. In 2026, any new C++ project should default to modules with import std;.
Read next: Embedded C++ & Safety-Critical Engineering →
Part of the C++ Mastery Course — 30 modules from modern C++ basics to expert systems engineering.
