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 some caveats I’ve found.

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 includes or imports. The proposal includes changes to headers and tests to adapt them to C++20 modules using the preprocessor.

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 building modules.

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.

I’ve tried to support the same in the proposed Boost implementation. This is required by unit tests that need access to entities not exported by the module (usually in the detail) namespace. To access these, the relevant detail header should be included before the corresponding import. This Boost.Charconv test is an example of this requirement.

Additionally, libraries that need to export macros need macro headers. These should contain as few C++ entities as possible. 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, and a separate header provides the macro. All tests use this functionality.

Writing module interface units (boost_mp11.cppm)

Broadly, this requires two groups of code changes:

  1. Update the headers to #ifdef-out all dependencies, based on a macro that is defined in C++20 module builds.

  2. Mark C++ entities in the public interface as exported.

A possible strategy for point 2 is creating a BOOST_MP11_MODULE_EXPORT macro that expands to export in module builds, and otherwise to nothing. This is similar to what we do today to handle DLL exports today. Some code samples:

//
// File: boost/mp11/list.hpp
//
#include <boost/mp11/detail/config.hpp> // Our own includes stay as they are
#ifndef BOOST_USE_MODULES
#include <type_traits> // Includes for dependencies are conditionally removed
#endif

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

//
// File: boost_mp11.cppm
//
module;

// These headers are required because they define macros
#include <cassert>

export module boost.mp11;

import std;

// extern C++ makes all the included entities attached to the global module.
// If we forget to ifdef an include, this is supposed to make it less problematic
extern "C++" {
#include <boost/mp11.hpp>
}

While this works, it has some drawbacks:

  • It doesn’t support mixing includes and imports in the tests. Under MSVC, entities declared in the purview can’t be re-declared outside of it, even when enclosed in extern C++. This is problematic for compiled libraries having several cpp files, too.

  • It issues compiler warnings, since include is only recommended in the global module fragment.

  • Requires considerable code changes in headers when compared with alternatives.

The above strategy works fine for Boost.Mp11, but is inviable for Boost.Charconv. As an alternative, I’ve used the export using technique:

//
// File: boost/mp11/list.hpp
//
// Same strategy for includes as before
#include <boost/mp11/detail/config.hpp>
#ifndef BOOST_USE_MODULES
#include <type_traits>
#endif

// No longer exported
template<class... T> struct mp_list
{
};

//
// File: boost_mp11.cppm
//
module;

// Includes and imports required by Boost.Mp11.
// We can place these in a boost/mp11/detail/global_module_fragment.hpp,
// so it can be used in tests
#include <cassert>
import std;

// The library
#include <boost/mp11.hpp>

export module boost.mp11;

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

When compared to the alternative, this technique:

  • Supports mixing includes and imports under all compilers.

  • Doesn’t generate compiler warnings.

  • Requires less code changes in headers.

  • It hits two troublesome MSVC bugs:

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

I’d suggest to go with this second option, once the MSVC teams either fixes or proposes workarounds for these problems.

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.

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

While benchmarks seem promising, the technology still looks very experimental. I think it makes sense for us to wait until the bugs I’ve found are fixed, and CMake support for import std become stable, before merging any of my work.

Still, I’d 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