· 7 min read ·

Five Years of C++20 Modules: The Build System Still Hasn't Caught Up

Source: isocpp

C++20 modules were finalized in the standard in 2020. That’s five years ago. In most language ecosystems, five years is enough time for a major feature to go from experimental curiosity to default choice. In C++, modules are still something most library authors approach with caution, and Rubén Pérez Hidalgo’s recent talk at isocpp.org on the journey toward import boost makes clear exactly why.

This isn’t a situation where the feature is bad or the compilers haven’t implemented it. Clang 16, GCC 14, and MSVC 19.34 all have workable module support. The problem is the layer between the compiler and the developer: build systems, package managers, and the enormous corpus of existing C++ libraries that were designed around a preprocessor-based inclusion model.

What Modules Actually Promise

The core pitch for modules is well understood at this point. Instead of textually including headers with #include, you write:

export module mylib;

export class Widget {
    // ...
};

And consumers write:

import mylib;

The compiler reads a pre-compiled Binary Module Interface (BMI) rather than reparsing the entire header tree on every translation unit. The theoretical compile time gains are significant. Google’s internal benchmarks cited at CppCon showed roughly 40x speedup for header-heavy code. A 2024 benchmark using GCC 14 and CMake 3.29 on a 40-file project found initial builds roughly 3.7x faster than the header equivalent, and incremental builds after a single-file change running about 22x faster because the dependency graph is explicit rather than implicit.

That incremental number matters more than the cold-build number for day-to-day development. When you touch a widely-included header in a large C++ project, you often trigger cascading recompilation across the entire codebase. Modules eliminate that because importers depend on the exported interface, not the implementation.

The Preprocessing Wall

Here’s where library authors run into the first real obstacle. Modules and the C preprocessor exist at different layers of the compilation model, and they interact badly.

Macros defined inside a module interface unit do not leak to importers. This is intentional: one of the design goals of modules is to provide genuine encapsulation, so #define pollution from one module cannot corrupt another. But a huge fraction of existing C++ library APIs use macros as configuration knobs. Boost.Asio, for instance, has dozens of macros like BOOST_ASIO_STANDALONE, BOOST_ASIO_USE_TS_EXECUTOR, and BOOST_ASIO_DISABLE_BOOST_ARRAY. Users set these before including the headers to change library behavior.

With modules, that mechanism doesn’t work. You cannot export a macro. Sy Brand’s writeup on modules and the preprocessor explains the mechanics: the module interface is processed after the preprocessor, so any configuration that must happen at preprocessing time either needs a different mechanism or the module wrapper must bake in specific choices at build time.

The workaround the standard provides is the global module fragment, a section before the module declaration where you can still #include traditional headers:

module; // global module fragment starts here
#define BOOST_ASIO_STANDALONE
#include <boost/asio.hpp>

export module myapp.net; // named module starts here

export using boost::asio::io_context;
export using boost::asio::ip::tcp;

This pattern lets you wrap a legacy header in a module interface. The macros you set in the global fragment affect the included headers, and then you selectively re-export what you want. It’s a wrapper, not a true module port. The compilation model for the included headers is still traditional; only the exported interface uses module semantics. If the library has complex macro-based customization that users need to set themselves, you’ve either frozen those choices or you need some other mechanism, perhaps CMake variables that configure the module at install time.

Boost.Redis took a similar approach when it added module support in version 1.5. The module interface became a thin wrapper over the existing headers, using the global module fragment to pull in Boost.Asio and other dependencies. The real complexity came from the interdependencies between Boost libraries, which were designed to compose through header inclusion rather than explicit module interfaces.

CMake’s Dependency Scanning Problem

Headers are inherently unordered. Your build system can compile any set of .cpp files in any order because each one textually pulls in its dependencies at the start. Modules break this. The compiler needs the BMI for import foo before it can compile any unit that imports foo. This means the build system must first scan every source file to discover the dependency graph, then schedule compilations in topological order.

This requirement fundamentally changes what a build system needs to do, and it’s harder than it sounds. CMake’s C++ modules documentation is honest about where things stand. Support landed in CMake 3.28 as experimental, graduated to non-experimental in 3.30 for some configurations, and the requirements are still strict:

  • You must use the Ninja generator or Visual Studio (MSBuild). Unix Makefiles are not supported.
  • You need MSVC 14.34 (VS 2022 17.4+), Clang 16+, or GCC 14+.
  • Installing module targets for downstream consumption with find_package has known limitations around BMI portability.
  • import std; from C++23 requires additional configuration and has its own compatibility matrix.

The new CMake API for registering module sources uses file sets:

target_sources(mylib
  PUBLIC
    FILE_SET CXX_MODULES FILES
      src/mylib.cppm
)

This tells CMake that mylib.cppm is a module interface unit, not a regular source file, so the dependency scanner knows to extract module names from it before scheduling the build. It’s a reasonable API, but requiring Ninja specifically locks out anyone using Makefiles, which is still common in CI environments and legacy projects.

The Two-File Pattern and ABI Considerations

When writing a new library with modules from scratch, the recommended approach uses the interface/implementation split that modules were designed to support:

// mylib.cppm - module interface unit
export module mylib;

export class Widget {
public:
    void draw();
    int value() const;
private:
    int m_value = 0;
};
// mylib.cpp - module implementation unit
module mylib;

void Widget::draw() { /* ... */ }
int Widget::value() const { return m_value; }

The interface unit defines the public API. The implementation unit imports the same module name without re-exporting, giving it access to the private details. This is genuinely cleaner than header/source pairs because the implementation unit doesn’t need to re-declare everything.

For larger libraries, module partitions let you split the interface across multiple files while presenting a single importable name:

// mylib-widget.cppm
export module mylib:widget;
export class Widget { /* ... */ };

// mylib-gadget.cppm  
export module mylib:gadget;
import :widget;
export class Gadget { Widget w; /* ... */ };

// mylib.cppm - the primary interface unit
export module mylib;
export import :widget;
export import :gadget;

Partitions are module-internal; consumers only ever write import mylib. This structure maps well onto large libraries with many components.

There’s an ABI angle that matters for distributed libraries. The BMI format is not standardized, is compiler-version specific, and is generally not stable between compiler releases. A library that ships pre-compiled BMIs would need to ship one per compiler per version per platform. Most library distributions still ship source and headers; modules complicate that model because you need the BMI to be generated from the source in a way that matches the exact compiler being used downstream. Package managers like Conan and vcpkg are working through this, but it remains an open problem.

Where Boost Specifically Stands

Boost is a useful case study because it’s both representative of the problem and genuinely trying to solve it. The library contains around 160 individual libraries, most written before C++11, many using advanced preprocessor techniques for portability and configuration.

Individual Boost libraries have started moving first. Boost.Redis got module support in 1.5. Boost.MySQL, which Pérez Hidalgo maintains, is another candidate. These are newer Boost libraries written in a more modern style with fewer preprocessor dependencies. The older, more foundational libraries like Boost.MPL or Boost.Preprocessor are harder cases because they’re deeply entangled with the preprocessor or have complex cross-library dependencies.

The Boost build system (b2/Boost.Build) has no module support at all. Any Boost library that wants to expose modules today must do so through a CMake build, which means maintaining parallel build definitions. That’s not a small ask for a project that has been using b2 for over two decades.

A survey on r/cpp found that developers wanting to use Boost with modules had essentially no good options across the whole project: a few individual libraries had started, but there was no unified answer and no timeline for one.

The Realistic Outlook

The analogy that feels right here is C++11’s move semantics. Move semantics were standardized in 2011. It took several years before most major libraries were updated to provide move constructors and move assignment operators everywhere sensible. It took another few years before the teaching materials caught up, before linters checked for it, before it became the default mental model. Modules are a larger change than move semantics.

The pieces are coming together: compilers support it, CMake works with Ninja, a few major libraries are leading the way. What’s missing is the middle layer: standard practices for how a library ships both header and module interfaces during a transition period, package manager support that handles BMI generation transparently, and enough real-world usage to shake out the edge cases.

For a library developer today, the practical path forward looks something like what Boost.Redis did: wrap the existing headers in a module interface using the global module fragment, expose a curated subset of the API through re-exports, document that the module interface is experimental and subject to change, and provide it as opt-in alongside the traditional headers. It’s not pure modules, but it gets users started and generates the feedback needed to push the ecosystem forward.

Five years in, C++20 modules are technically ready for early adopters who control their full toolchain. For library authors targeting a broad audience, the ecosystem hasn’t quite closed the gap yet. The road to import boost runs through build system standardization, package manager integration, and the slow conversion of hundreds of libraries, and that road is still being paved.

Was this interesting?