<< Home

Chapter 24: Modules and Header Modernization

C++ modules shorten build times, reduce macro bleed, and give you cleaner interfaces. This chapter shows how modules work, how to write and use them, how to migrate away from headers and include guards, and how to wire everything to CMake so builds stay fast and correct.

Why modules improve builds

Traditional headers are textual inclusion. Every translation unit repeats the same parsing, macro expansion, and template instantiation surface for included headers. Modules flip the model by compiling an interface once into a binary module interface file, then importing that result. This cuts duplicate work, shrinks dependency surfaces, and blocks accidental macro leakage. The effect is faster incremental builds and fewer surprising name collisions.

From textual inclusion to compiled interfaces

With headers, the preprocessor pastes file contents into each consumer. With modules, the compiler reads a compiled description of names and types. Toolchains cache this compiled form so multiple source files can reuse it without reparsing. Templates still instantiate where needed, but discovery and lookups travel through a precompiled graph instead of piles of text.

Isolation and hygiene

Module interfaces export only what you choose. By default, names are hidden. Macros do not cross the module boundary through import. You still can use traditional headers where it makes sense, but keeping public APIs inside modules reduces accidental exposure and tight coupling.

πŸ’‘ Start by modularizing leaf libraries that many targets include. You get maximum parse-time savings where duplication is highest.

A minimal example

This tiny library builds once, then imports quickly everywhere it is used.

export module math;
export int add(int a, int b) { return a + b; }
import math;
#include <iostream>
int main() {
  std::cout << add(2, 3) << "\n";
}

export, import, and partitions

Module syntax is small and direct. You declare a primary interface unit, decide which declarations to export, and optionally split the interface into partitions for organization. Consumers import the module or a partition by name. Private implementation units can attach to the same module without exporting anything.

Declaring a primary interface unit

A primary interface unit begins with export module and may export declarations selectively. Only exported declarations are visible to importers. Keep implementation details unexported in the same file or in private units.

export module util;
export struct version { int major, minor, patch; };
int helper(); // not exported, only visible within this module unit

Controlling visibility with export

You can mark whole declarations or namespaces as exported. Use narrow exports to keep ABI small and intent clear.

export module strings;
export namespace api {
  export const char* name();
}
namespace detail {
  const char* impl();
}

Importing modules and header units

Consumers use import. Many standard library headers can be imported as header units if your toolchain supports it. Prefer the named standard module when available.

import strings;   // your module
import <vector>;  // header unit form, if enabled
// or, when supported by your standard library:
// import std;    // single import for standard facilities
⚠️ Mixing #include and import requires careful ordering. Perform all import directives before declarations and before includes that depend on imported names.

Interface and implementation partitions

Large modules can be split into partitions. Interface partitions are exported and can be imported by consumers. Implementation partitions are internal and only visible to other units of the same module.

export module img;
// exported interface partition
export module img:io;
export void save_png(const char* path);

// implementation partition
module img:codec;
int compress(const unsigned char* data, int size);

// primary interface imports its partitions
export import :io; // makes img:io part of public API
// consumer of the public partition
import img:io;
int main() { save_png("out.png"); }

Private module fragments

A private module fragment lets you include legacy headers without leaking their macros or names to importers. Declarations in the private fragment are not exported.

export module net;
export struct socket { int fd; };
// start private region
module :private;
#include <sys/socket.h>
#include <sys/types.h>
// … internal glue that stays private

Migrating from headers and include guards

Modernization is smoother when you move one public surface at a time. Keep old headers as thin forwarders during the transition. Replace include guards with module names. Use private fragments for any compatibility includes you still need.

Typical header to module conversion

Suppose you start with a traditional header and source pair. Convert to a primary interface unit and, if needed, a private implementation unit. Maintain the old header as a forwarding shim for downstream code during a deprecation window.

// Before: math.hpp
#ifndef MATH_HPP
#define MATH_HPP
int add(int, int);
#endif
// After: math.ixx (primary interface)
export module math;
export int add(int a, int b);
// After: math_impl.cpp (implementation unit)
module math;
int add(int a, int b) { return a + b; }
// Optional shim: math.hpp
#pragma once
import math;
using ::add;  // surface the same symbol for legacy consumers
πŸ’‘ Keep module names short and stable. Map names to directory layout, for example image/filters becomes module name image.filters.

Guard rails for a staged rollout

Adopt modules on library boundaries first, then flow inward. Turn on higher warning levels and sanitizers for module units while keeping the rest of the tree unchanged. Add CI tasks that validate that all import directives appear before declarations and that no transitive text includes sneak in via private fragments.

Comparing headers and modules

This compact table summarizes the practical differences that matter during migration.

AspectHeadersModules
Build costReparse in every TUCompile once, reuse
VisibilityEverything includedOnly what you export
MacrosLeak across includesDo not cross import
Order sensitivityHigh with includesimport before declarations
ToolingText basedDependency graph with BMIs

Modules with CMake

CMake can model C++ module dependency graphs and ask the compiler to generate and consume the required artifacts. Modern versions support module file sets so you declare which sources are interfaces, partitions, and implementations, and let the build system schedule them correctly.

Declaring module file sets

Use target_sources with a C++ modules file set to mark interface units and partitions. Keep implementation units in regular sources. This helps CMake generate the right build order and scan dependencies.

cmake_minimum_required(VERSION 3.28)
project(mod_demo LANGUAGES CXX)
add_library(mathlib)

target_sources(mathlib
  PUBLIC
    FILE_SET cxx_modules TYPE CXX_MODULES FILES
      src/math.ixx
      src/img.ixx
      src/img-io.ixx   # exported interface partition
  PRIVATE
    src/math_impl.cpp
    src/img_codec.cpp  # implementation partition unit
)

target_compile_features(mathlib PUBLIC cxx_std_20)

Consuming modules from executables

Executables that import your modules only need a link dependency. CMake wires include paths and module search paths. The compiler will find and consume the produced interface artifacts.

add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE mathlib)
target_compile_features(app PRIVATE cxx_std_20)
⚠️ Do not hand craft BMI paths. Compilers and CMake coordinate where binary module interface files live. Hardcoding locations makes the build brittle across generators and platforms.

Header units and the standard library

If your toolchain supports header units, you can enable them with the right compile flags and then import system headers like <vector>. Prefer the named standard module when your standard library provides it. Keep such changes within module units to avoid surprising legacy translation units.

Incremental migration strategy in CMake

Keep existing targets and gradually introduce a module-capable library alongside header-based ones. Use interface libraries to bridge compile features and flags. Once consumers switch to import, retire the shim headers and remove the bridge targets cleanly.