C++Architecture

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

TT
TopicTrick Team
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

Problems with #include:

  1. Re-parse on every TU: Same headers parsed by every file that includes them
  2. Order sensitivity: Include guards work, but #define leaks across files
  3. Macro pollution: Macros from one header infect all subsequent headers
  4. No encapsulation: All names (exported or not) pollute the global namespace
  5. 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:

cpp
// === 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)); }
}
cpp
// === 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 math reads 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:

cpp
// === 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()
cpp
// === 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 headers

import std: The Standard Library Module (C++23)

C++23 standardizes importing the entire standard library as a module:

cpp
// 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:

cpp
// 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
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:

text
Project → Properties → C/C++ → Language → C++ Language Standard: C++23
Rename math.h + math.cpp to math.ixx (single file)

clang with Ninja (fastest):

bash
clang++ -std=c++23 -fmodules --precompile math.ixx -o math.pcm
clang++ -std=c++23 -fmodules -fmodule-file=math=math.pcm main.cpp

Build Time Benchmarks: #include vs Modules

text
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)

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