Updated on 2025-01-29 with my latest findings.

I’ve written a small proof-of-concept that allows Boost to be consumed as C++20 modules. This article explains the design decisions I’ve made and the caveats I’ve found.

TL;DR:

  • We chose to support #include <boost/xyz.hpp> and import boost.xyz using the same Boost code, using a BOOST_USE_MODULES macro switch. When using modules, we consume the standard library with import std.

  • We propose a "compatibility header" approach, where #include <boost/xyz.hpp> translates into import boost.xyz transparently when BOOST_USE_MODULES is defined. This facilitates migration.

  • Implementing this requires changes in the libraries. They are manageable but increase maintenance effort.

  • Build-time benchmarks are promising, observing a 3x speedup in some test suites.

  • The ecosystem is not ready yet: CMake’s support for import std is still experimental, Visual Studio CMake generators don’t work with import std, and MSVC has several bugs that are hard to work around. We’ve decided to wait until these points are fixed before merging this proposal into Boost.

Scope

This prototype includes changes to the following Boost repositories:

High-level overview

Each Boost library gets its own C++20 module. For example, Boost.Mp11 can be consumed with import boost.mp11. Libraries that only export macros (like Boost.Config) don’t get a module.

Changes are backwards compatible. Builds using headers will continue to work as usual. The prototype proposes using a "dual mode", where Boost might be consumed using either includes or imports. A preprocessor macro (BOOST_USE_MODULES) is used to select which mode to use.

To facilitate migration, when BOOST_USE_MODULES is defined, public Boost headers import the relevant library, instead of defining the usual symbols. I’ve called these compatibility headers.

I’ve taken a bottom-up approach, modularizing libraries in dependency order. The standard library is consumed with import std in all the proposed modules. This is the best approach to reduce build times, one of the main incentives to prefer imports over includes. If this is your case, you might find the benchmark on the Boost.Mp11 test suite interesting.

C++20 Boost modules are built using our regular Boost.CMake infrastructure. Users select whether to build Boost in C++20 module mode setting the -DBOOST_USE_MODULES=1 option when running CMake. This defines the required C++ macros and installs the required module interfaces.

I’ve also modularized Boost.Mp11 and Boost.Charconv test suites. When C++20 modules builds are enabled, tests consume the libraries using import rather than include, verifying that everything works. I’ve also included CI jobs to cover these settings.

Supported compilers and tools

The tooling is still a bit experimental. CIs run the following tools:

  • MSVC 19.42.34435.0.

  • clang-19 with libc++.

  • CMake 3.30 and higher. The proposal uses CMake’s experimental support for building the standard library module.

  • Ninja 1.11. In particular, CMake under Windows doesn’t support the usual Visual Studio generators when using import std.

Implementation

CMake

Every library provides a module interface unit defining its exports. For instance, in Boost.Mp11, this file is modules/boost_mp11.cppm. It’s installed to CMAKE_INSTALL_DATADIR, which places it in /usr/local/share/boost_mp11.cppm by default.

Libraries that want to support C++20 module builds include conditional logic in their CMake to react to BOOST_USE_MODULES. For example, this is what Boost.Mp11 would look like. Libraries that don’t support C++20 module builds are built and installed as they are today.

In C++20 module builds, binary artifacts are generated even for previously header-only builds. For instance, the above CMake generates a libboost_mp11.a in Linux. In most cases, these libraries only contain module initializers. I’ve made these libraries unconditionally static, to reduce overhead. They are installed along other compiled Boost libraries.

The libraries can be consumed from CMake with add_subdirectory and find_package, as usual. However, due to CMake current limitations, the find_package workflow is more sensitive to build flags than with headers.

Mixing includes and imports

At the time of writing, standard library implementations support including standard headers first, then importing std. This is relevant because some standard library headers still need to be included for macros to be visible.

In general, we’ve chosen not to support this in the general case: you should either include or import Boost, but not both. Compatibility headers help maintain this consistency across your project.

This choice allows libraries to attach their declarations to their named module [2]. This makes ODR violations easier to detect and may speed up compilation.

Some libraries may still want to support mixing imports and includes. Compiled libraries with tests that require access to implementation details are an example of this. See this section for more info.

Some libraries need to make macros available to users. Macros must always be exported using traditional includes, since modules don’t know anything about macros. In the prototype, compatibility headers make public macros available in addition to importing the relevant module. For example, Boost.Core has a lightweight testing framework used in unit tests that relies on macros. The boost.core module exports the required C++ entities, with the header performing the relevant imports and macro definitions.

Compatibility headers

All public headers have been converted into compatibility headers. This is what a compatibility header could look like:

// File: boost/mp11/list.hpp

// Conditionally skip declarations. BOOST_MP11_INTERFACE_UNIT is only defined
// in boost_mp11.cppm
#if defined(BOOST_USE_MODULES) && !defined(BOOST_MP11_INTERFACE_UNIT)

#include <boost/mp11/version.hpp> // Declares the BOOST_MP11_VERSION macro

// Boost libraries might need to define this because of certain limitations
// on where imports can be located in module units
#ifndef BOOST_MP11_SKIP_IMPORT
import boost.mp11;
#endif

#else

namespace boost::mp11 { /* regular declarations */ }

#endif

The idea is that:

  • Non-modular code (like test executables) includes the header directly, requiring no changes.

  • Dual code (like other Boost libraries) also includes the header directly, without the need to conditionally ifdef dependencies out. The BOOST_MP11_SKIP_IMPORT macro might need to be defined because imports must be located before other definitions in module units.

  • Modular-only code can directly use the import.

We’ve also created a bunch of standard library compatibility headers in Boost.Config that follow the same principle. For example:

// File: boost/config/std/type_traits.hpp
#ifdef BOOST_USE_MODULES
#ifndef BOOST_CONFIG_SKIP_IMPORT_STD
import std;
#endif
#else
#include <type_traits>
#endif

I’d like to thank Peter Dimov for proposing the idea on compatibility headers.

Writing module interface units (boost_mp11.cppm)

We first need to make sure that our headers don’t include any third-party code when BOOST_USE_MODULES is defined. Standard library headers can be replaced by the equivalent Boost.Config compatibility headers. Boost dependencies don’t need to be updated. Some other headers may need to be ifdef’ed-out and included in the global module fragment.

For example: [3]

// File: boost/mp11/list.hpp

#if defined(BOOST_USE_MODULES) && !defined(BOOST_MP11_INTERFACE_UNIT)
// Compatibility header section: omitted for brevity
#else

// Includes
#include <boost/mp11/detail/config.hpp>     // Our own includes stay as they are
#include <boost/config/std/type_traits.hpp> // Replace stdlib includes
                                            // by compatibility headers

namespace boost::mp11 { /* regular declarations */ }

#endif

We now need to mark C++ entities in the public interface as exported. The first solution to this is to create a BOOST_MP11_MODULE_EXPORT macro that expands to export in module builds, and to nothing otherwise. This is similar to what we do today to handle DLL exports today. Some code samples:

// File: boost/mp11/list.hpp
// Compatibility header and includes skipped for brevity

BOOST_MP11_MODULE_EXPORT // defined to export if BOOST_USE_MODULES is defined, to nothing otherwise
template<class... T> struct mp_list
{
};

The module interface becomes:

// File: boost_mp11.cppm

module; // Global module fragment
#define BOOST_MP11_INTERFACE_UNIT     // We want headers to actually declare entities
#define BOOST_CONFIG_SKIP_IMPORT_STD  // Don't import std in compatibility headers
#include <cassert> // Some standard library headers need to be included for their macros

export module boost.mp11;

import std;               // Import should be first
#include <boost/mp11.hpp> // All entities declared here get attached to the named module
                          // This issues a compiler warning that should be suppressed

This allows attaching the declared entities to the boost.mp11 module, but has the following drawbacks:

  • It doesn’t support mixing includes and imports, as mentioned earlier.

  • If we forget to ifdef-out a third-party include in <boost/mp11.hpp> an ODR violation may occur. Compatibility headers make this less likely to happen.

We can use the export using technique as an alternative. Dependencies should still be ifdef’ed-out or replaced by compatibility headers, but no BOOST_MP11_MODULE_EXPORT macro is required:

// File: boost/mp11/list.hpp
// Compatibility header and includes skipped for brevity

template<class... T> struct mp_list // No export macro required
{
};

The interface unit becomes:

// File: boost_mp11.cppm
module; // Global module fragment
#define BOOST_MP11_INTERFACE_UNIT // We want headers to actually declare entities
                                  // No BOOST_CONFIG_SKIP_IMPORT_STD: import std is fine in the global module fragment
#include <cassert>        // Some standard library headers need to be included for their macros
#include <boost/mp11.hpp> // All entities are attached to the global module.

export module boost.mp11;

// List all symbols we want to export
export namespace boost::mp11 {
using mp11::list;
}

This technique doesn’t attach names to the named module, with the pros and cons this brings. Additionally, it hits two troublesome bugs in MSVC:

  • Some templated type aliases, like mp_size_t, cause trouble in importers under some circumstances: see bug report.

  • Template specializations seem to always be discarded, even if they are decl-reachable: see bug report.

Compiled libraries

As with header-only libraries, compiled libraries should also provide a .cppm file stating the functions exported by the module. For Charconv, I’ve converted .cpp files in module implementation units in module builds.

In Windows, when shared libraries are enabled, a CMake limitation makes module interfaces within the same project always build with __declspec(dllexport). This has the effect of introducing an extra indirection when calling library functions. This limitation is expected to be lifted in the future.

Note that module exports need not match with DLL exports. DLL exports define the library’s ABI, while module exports define its API.

Some tests in Boost.Charconv need to access implementation details (i.e. entities in the detail namespace). If it was a header-only library, such tests could just include the relevant detail header instead of importing the module. This does not work for compiled libraries because detail headers might reference functions defined in the module implementation units. In other words, these tests need to mix includes and imports. For this reason, I’ve used the export using technique for Boost.Charconv.

Continuous Integration

I’ve added workflows akin to the current CMake ones that verify that tests build and run, and that the add_subdirectory and find_package workflow work, for both compilers. For instance, this is what the Charconv new CI jobs would look like.

Benchmarking

Build performance gains are higher when lots of translation units consume the same library. Building the Boost.Mp11 test suite (which has around 200 translation units) yields the following results:

  • Headers: 2min 10s.

  • Modules: 39s (this includes the time required to build the std and Boost modules).

Benchmarks performed on Ubuntu 22.04 with clang-19 and libc++.

Next steps

After discussing with maintainers, we’ve decided to park the initiative for now. I expect to revisit it once the MSVC bugs I’ve found are fixed and CMake support for import std becomes stable.

As always, I’d still appreciate any feedback that you may have.

Thanks for reading this far.


1. It’s unlikely that end users consume Charconv, since the standard library functions are available in C++17 and higher. I chose Charconv because it’s compiled, relatively small, has few dependencies, and is a dependency of other libraries, like Boost.Json.
2. Anything declared after export module boost.xyz; is considered attached to boost.xyz, and must be defined in boost.xyz, and nowhere else. This enforcement makes ODR violations easier to detect, and reduces the amount of work required by the compiler. On the other hand, declarations in the global module fragment or declarations prefixed by extern "C++" are considered attached to the global module, and are not subject to the former rules. See the cppreference section on module ownership for more info.
3. To avoid depending on Boost.Config, Boost.Mp11 has its own standard library compatibility headers.