C++20 Modules have the potential to greatly improve the build process for large-scale projects; however, current tooling support is less-than-intuitive.

This is a brief tutorial showing how to build a C++ project on MacOS that uses modules, and what changes are required for cmake to successfully build it.

C++20 introduced modules as a modern alternative to the traditional #include directive, addressing some of the long-standing inefficiencies in the language’s compilation model. For decades, C++ developers have relied on header files to share declarations across translation units, but this approach has significant drawbacks. Every time a file is included, the preprocessor essentially copies and pastes its contents into the including file, leading to redundant parsing and excessive compilation times, especially in large codebases.

Modules offer a fundamentally different mechanism by allowing code to be compiled once and reused across translation units. Instead of repeatedly processing the same header files, the compiler can store and load precompiled module interfaces, drastically reducing compilation overhead. This not only speeds up the build process but also improves the scalability of large projects, where incremental builds become significantly more efficient.

Beyond performance, modules also provide better encapsulation and dependency management. Unlike traditional headers, where all declarations are exposed by default, modules allow selective export of symbols, reducing the risk of unintended dependencies and improving overall code organization. Additionally, they help eliminate common pitfalls such as include order dependencies and macro pollution, making the language more robust and maintainable.

By moving away from #include directives and embracing modules, C++ developers can achieve faster builds, cleaner code, and a more reliable dependency structure, making C++20 a significant step forward for modern software development.

Unfortunately, tooling support for modules is still in its infancy, and there are a few “gotchas” that may lead to surprising errors (not least, it’s not self-evident which version of Clang supports them; or what flags to use): this post is a brief tutorial showing how to compile, build and run C++20 code that uses modules.

Source code

This is just a simple example to show how to define (export) and use (import) a C++20 module:

// demo.cppm

module;
#include <iostream>

export module demo;

export namespace demo {
    void emit(const std::string& msg) {
        std::cout << msg << std::endl;
    }
}
// main.cpp

#include <format>

import demo;

int main() {
  auto lang = "C++";
  demo::emit(std::format("Hello and welcome to {}", lang));

  for (int i = 1; i <= 5; i++) {
    demo::emit(std::format("i = {}", i));
  }

  return 0;
}

When compiled and run the program emits the following to console:

$ ./build/use_demo

Hello and welcome to C++
i = 1
i = 2
i = 3
i = 4
i = 5

Clang

Modules are supported from v17 (apparently); the default installed on MacOS (Sequoia) AppleClang is v16:

└─( clang++ --version

Apple clang version 16.0.0 (clang-1600.0.26.6)
Target: arm64-apple-darwin24.3.0
Thread model: posix
InstalledDir: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin

and will fail at compiling a perfectly valid C++ module:

└─( clang -std=c++20 demo.cppm --precompile -o build/demo.pcm
demo.cppm:4:1: error: a type specifier is required for all declarations
    4 | module;
      | ^
demo.cppm:7:8: error: expected template
    7 | export module demo;
      |        ^
... etc.

We then need to install a more recent version of Clang, via Homebrew:

└─( brew instaill llvm

└─( export PATH=/opt/homebrew/opt/llvm/bin:$PATH

└─( which clang++
/opt/homebrew/opt/llvm/bin/clang++

└─( clang++ --version
Homebrew clang version 19.1.7
Target: arm64-apple-darwin24.3.0
Thread model: posix
InstalledDir: /opt/homebrew/Cellar/llvm/19.1.7_1/bin
Configuration file: /opt/homebrew/etc/clang/arm64-apple-darwin24.cfg

└─( clang -std=c++20 demo.cppm --precompile -o build/demo.pcm

└─( ll build/demo.pcm
Permissions Size User  Date Modified Name
.rw-r--r--   17M marco 26 Feb 17:28  build/demo.pcm

A full description of the command-line flags for Clang related to modules; extensions required; and compilation modes are here:
Standard C++ Modules — Clang 21 documentation

Module file types

A C++ Module (.cppm) is the source code (“translation unit”) that contains the module definition; Clang will compile it into an object file (.o) which will be required for the link phase, and into a BMI (.pcm) file.

BMI – Built Module Interface

Built Module Interface (or BMI) is the precompiled result of an importable module unit.

Depending on the options passed to Clang, it will either generate two separate files, or a single .pcm one (see How to Produce a BMI).

Single PCM output

This is the simplest option, uses the --precompile flag and will generate the file in the location specified by the -o flag:

clang -std=c++20 demo.cppm \
    --precompile \
    -o build/demo.pcm

this file can then be used to compile and link a source file that uses the module:

clang++ -std=c++20 main.cpp build/demo.pcm \
    -fmodule-file=demo=build/demo.pcm \
    -o build/use_demo

note that here we need to specify the same file twice (although, using the -fprebuilt-module-path=<path/to/directory> would make matters simpler):

clang++ -std=c++20 main.cpp build/demo.pcm \
    -fprebuilt-module-path=build \
    -o build/use_demo

I’ll confess that I find personally “unsettling” using a .pcm as a .o file and prefer the separate files option (below).

Separate BMI and Object files

We can also generate the BMI separately from the actual object file:

└─( clang -std=c++20 demo.cppm -fmodule-output \
      -c -o $USR_LOCAL/lib/demo/demo.o

└─( ll $USR_LOCAL/lib/demo/
Permissions Size User  Date Modified Name
.rw-r--r--   14k marco 26 Feb 17:47  demo.o
.rw-r--r--   17M marco 26 Feb 17:47  demo.pcm

└─( clang++ -std=c++20 main.cpp $USR_LOCAL/lib/demo/demo.o \
    -fmodule-file=demo=$USR_LOCAL/lib/demo/demo.pcm \
    -o build/use_demo

Using Cmake to build/use modules

It is a bit of an understatement that cmake does not support terribly well C++20 modules; in particular, the Unix Makefiles generator will not work at all.

Given this simple CMakeFiles.txt file:

cmake_minimum_required(VERSION 3.30)
project(cpp_modules)

set(CMAKE_CXX_STANDARD 20)

add_executable(use_demo main.cpp)

target_sources(use_demo
        PRIVATE
        FILE_SET cxx_modules TYPE CXX_MODULES FILES demo.cppm
)

when configured to use -G Unix Makefiles will fail with the following error (other details omitted):

cmake -DCMAKE_CXX_COMPILER=/opt/homebrew/opt/llvm/bin/clang++ \
    -G "Unix Makefiles" 
    -S . -B build-unix


-- The CXX compiler identification is Clang 19.1.7
...
-- Configuring done (0.7s)
CMake Error in CMakeLists.txt:
  The target named "cpp_modules" has C++ sources that may use modules, but
  modules are not supported by this generator:

    Unix Makefiles

  Modules are supported only by Ninja, Ninja Multi-Config, and Visual Studio
  generators for VS 17.4 and newer.  See the cmake-cxxmodules(7) manual for
  details.  Use the CMAKE_CXX_SCAN_FOR_MODULES variable to enable or disable
  scanning.


-- Generating done (0.0s)
CMake Generate step failed.  Build files cannot be regenerated correctly.

changing the -G option to use Ninja will generate the build files and the subsequent build will succeed:

└─( cmake -DCMAKE_BUILD_TYPE=Debug \
    -DCMAKE_MAKE_PROGRAM=/path/to/ninja \
    -DCMAKE_CXX_COMPILER=/opt/homebrew/opt/llvm/bin/clang++ \
    -G Ninja \
    -S ./ -B ./build

-- The C compiler identification is AppleClang 16.0.0.16000026
-- The CXX compiler identification is Clang 19.1.7
...
-- Configuring done (0.7s)
-- Generating done (0.0s)
-- Build files have been written to: .../cpp_modules/build

└─( cmake --build ./build --target use_demo -j 10

[6/6] Linking CXX executable use_demo

└─( ./build/use_demo
Hello and welcome to C++
i = 1
i = 2
i = 3
i = 4
i = 5

Note
ninja comes bundled with JetBrains’ CLion; however, it can also be installed via Homebrew:
brew install ninja

CMake will generate the two separate .pcm and .o files for the module, in the build/CMakeFiles/use_demo.dir directory; those can be used to build the executable directly via Clang:

└─( DIR=build/CMakeFiles/use_demo.dir

└─( clang++ -std=c++20 main.cpp $DIR/demo.cppm.o \
        -fprebuilt-module-path=$DIR \
        -o build/use_demo

Note
For this to work, we need to add the following to our CMakeLists.txt file:
set(CMAKE_CXX_EXTENSIONS OFF)

or clang will complain bitterly about it:

error: GNU extensions was enabled in PCH file but is currently disabled
error: module file /Users/marco/Development/Playgrounds/cpp_modules/build/CMakeFiles/use_demo.dir/demo.pcm cannot be loaded due to a configuration mismatch with the current compilation [-Wmodule-file-config-mismatch]

Using Modules from Separate Builds

When the modules have been built separately from the current source files (as could be often the case with library modules), we need to modify the CMakeLists.txt file, to tell it where to go find the PCM and object files:

cmake_minimum_required(VERSION 3.30)
project(cpp_modules)

set(CMAKE_CXX_STANDARD 20)
# Ensure that we are not using GNU extensions
# so that the module can be linked using clang++
set(CMAKE_CXX_EXTENSIONS OFF)

# Specify the path to the external PCM and object files using an environment variable
set(PCM_PATH $ENV{USR_LOCAL}/lib/demo)
set(OBJ_MODULE ${PCM_PATH}/demo.o)
message(STATUS "Module Library Path: ${PCM_PATH}")

add_executable(use_demo main.cpp)

# Ensure the compiler knows where to find the PCM file
target_compile_options(use_demo PRIVATE -fprebuilt-module-path=${PCM_PATH})

# Link the module object file
target_link_libraries(use_demo PRIVATE ${OBJ_MODULE})

We have used the -fprebuilt-module-path option here, but any of the other ones would have worked just as well.

The configure and build steps remain largely the same as before.

Conclusions

C++20 modules have the potential to revolutionize the way we structure and build large-scale C++ projects. By eliminating redundant header parsing and reducing compilation dependencies, they offer a much-needed boost in build performance while also promoting cleaner, more modular code. For developers who have long struggled with slow build times and the tangled web of dependencies that come with traditional #include directives, modules provide a long-awaited solution.

That said, adopting modules today isn’t entirely frictionless. Compiler support is still evolving, and integrating modules into an existing project often requires some trial and error—tweaking build system configurations, adjusting compiler flags, and navigating the nuances of module dependencies. The ecosystem is catching up, but for those willing to experiment, the benefits are well worth the effort. Once the tooling matures and module adoption becomes more widespread, this shift could fundamentally change how we approach C++ development—perhaps even putting an end to the notorious “include hell” once and for all.

One response to “Introduction to C++20 Modules”

  1. […] see Part 1 of this series for more details about how to use clang++ to compile modules (and the programs that import […]

Leave a comment

Trending