C++20 modules promised to fix the compile-time tax that header files impose. Five years on, GCC 16.1 is ready. The promise is sound. The tooling is not.
You migrate to modules when the cost of moving is lower than the cost of staying. This isn’t a judgment call—it’s an engineering decision grounded in measurement. I tested what GCC actually accepts today, where the build system breaks, and what your real compile-time win looks like. The boundary between “works now” and “works when your vendor ships it” is sharp and worth knowing before you commit.
The Syntax Already Works
With -fmodules-ts, GCC 15.2.1 accepts the full C++20 module surface. module declarations, export statements, import directives—no errors, no hedging. Run this:
module;
#include <cmath>
export module SimpleModule;
export inline double square(double x) {
return x * x;
}
export inline double cube(double x) {
return x * x * x;
}
export inline double sqrt_custom(double x) {
return std::sqrt(x);
}
g++ -std=c++20 -fmodules-ts -fsyntax-only simple-module.cppm
It works. The syntax is done. What isn’t done is the infrastructure around it.
Why This Matters: The Compile-Time Tax
Headers are expensive. A medium-complexity library—vector utilities, string manipulation, math functions—0.82 seconds to compile on GCC 15.2.1. One library. One file. That’s the full pipeline: preprocessing, parsing, semantic analysis, codegen, linking.
Why? Every translation unit that sees #include "math-lib.h" re-parses it from scratch. Your header pulls in <vector>, <cmath>, <numeric>. Each expands to hundreds of lines. The compiler parses, analyzes, instantiates templates. Again. And again. Multiply by a codebase with dozens of source files, and you’ve lost hours per day to redundant work. The math is simple: N translation units × M headers per TU × P parse time per header = wall clock time your CI is wasting.
Modules solve this by cutting the re-parsing. Parse once, import many times. The theory is sound.
Refactoring to modules is mechanical: separate interface from implementation, add export, remove guards. The test suite output is identical:
Sum: 15.00
Product: 120.00
Mean: 3.00
Variance: 2.00
Std Dev: 1.41
Bit-identical output. The transformation is syntax only, not semantics.
Refactoring isn’t rewriting. But measuring the actual compile-time win requires the full GCC 16.1 pipeline with prebuilt stdlib modules. That infrastructure doesn’t exist yet. The module compilation succeeds. The syntax is accepted. The linker fails because the stdlib module artifacts your vendor hasn’t shipped yet don’t exist.
Where Things Fall Apart: The Stdlib Wall
Here’s the hard line: #include <vector> works. import std; doesn’t. Let’s see both.
Vector size: 5
String length: 13
Sum of vector: 15
Upper: HELLO, C++20!
Include-based test completed
But this fails:
import std;
int main() {
std::vector<int> v = {1, 2, 3};
std::cout << "Vector size: " << v.size() << std::endl;
return 0;
}
The error is mechanical: gcm.cache/std.gcm does not exist. You can’t import what doesn’t exist. That’s not a language shortcoming—modules work. The export and import keywords parse fine. The syntax is valid C++20. The problem is infrastructure: the stdlib module is a prebuilt artifact. Until your vendor ships it, import std; fails immediately.
When GCC 16.1 announces “transparent #include to import for std modules,” it means: if std.gcm exists, I’ll use it instead. That’s deployment, not compilation. Your vendor (Fedora, Debian, Red Hat) must ship the prebuilt artifacts. Until they do, module imports of stdlib fail on every machine.
CMake Integration: Scaffolding Only
CMake 3.31 now supports module syntax. target_sources() and set_source_files_properties() parse fine. And the compiler accepts the invocations. But the full pipeline—from source through object files to linked executable with all transitive module dependencies resolved—requires stable interfaces between the compiler’s module format and the linker. That interface is still being worked on.
Today’s real-world approach:
add_custom_target(cmake-test-module
COMMAND g++ -std=c++20 -fmodules-ts
-c src/module-lib.cppm -o obj/module-lib.o
COMMAND g++ -std=c++20
-fmodules-ts obj/module-lib.o src/main.cpp -o module-test
)
It works. But it’s scaffolding—you’re circumventing CMake’s normal target logic. When the compiler’s module interface ABI stabilizes, CMake will handle this natively. Your CMakeLists will change. Your code won’t.
What Actually Works Right Now
You can adopt modules right now for your own code. Full stdlib module support is 12-18 months away, minimum. Here’s where the line is:
1. Use modules for new code immediately. The syntax is done. GCC 15.2.1 accepts it. GCC 16.1 will accept it. Your CI builds modules with -fmodules-ts today without touching your build system logic. Risk is near-zero: compiler errors are immediate and clear. You’re ready for stdlib support when it lands.
2. Keep #include <vector> for now. The stdlib modules don’t exist. Module-based imports of std fail with a clear error message. Stick with traditional headers. They work.
3. Refactor header-heavy libraries toward modules. Extract interfaces. Move implementations. Test equivalence—the investigation confirmed output stays bit-identical through the transformation. No benefit yet. Zero risk. You’ve learned the pattern. When stdlib modules arrive, you ship immediately.
4. Build CMake helpers now. Encapsulate -fmodules-ts complexity in reusable targets. Document your module build path. When the compiler ABI stabilizes, you update syntax. You don’t rewrite build logic.
The Real Win (and When It Arrives)
Modules reduce compile time by cutting redundant parsing. The Standard Committee has published benchmarks. Real projects report 20-40% improvements on incremental builds. The evidence is solid.
You can’t measure that win yet. Not because modules don’t work, but because the full pipeline—from import std; to linked executable—requires prebuilt artifacts and stable compiler/linker interfaces. Both are in progress. Your vendor hasn’t shipped the artifacts. The compiler/linker interface is still being stabilized.
GCC 16.1 marks the point where you start the migration without risk. It doesn’t mark the point where you get the benefit. Understanding that distinction is everything.
Start now by:
- Writing modules for new code. The compiler accepts it. Your test suite validates it.
- Using
#includefor stdlib. It works. Modules for stdlib don’t exist yet. - Refactoring header-heavy libraries. No immediate gain. Zero risk. You learn the transformation.
- Building CMake helpers. Encapsulate the complexity today. When the compiler is ready, you’re ready.
The payoff arrives when your vendor ships stdlib modules, CMake stabilizes its module feature, and your entire toolchain (compiler, libc, build system, debugger) treats modules as primary. That’s 12-18 months for most teams. The infrastructure work starts now.