How to Use C++20 Modules with Bazel and Clang

Modules are a feature added to C++20 that aims to provide better encapsulation and faster build times. While modules are not fully supported by compilers and probably not ready for use in production code, Clang’s support is fairly usable.

In this guide, I’ll show you how to use modules with Clang and the Bazel build system by making use of the project github.com/rnburn/rules_cc_module.

Let’s look at it works on a simple hello world program.

Hello World Example

Suppose we have the following code

// hello.ixx
export module hello;

import <iostream>;
import <string_view>;

export inline void say_hello(std::string_view const &name) {
  std::cout << "Hello " << name << "!\n";
}

and

// main.cc
import hello;

import <string_view>;

int main() {
  say_hello("world");
  return 0;
}

The code creates module hello with a function say_hello and calls it from main. To set this up in bazel, we can write the following BUILD file:

load("//cc_module:defs.bzl", "cc_module", "cc_module_binary")

cc_module(
    name = "hello",
    src = "hello.ixx",
    copts = [
        "-fmodules",
        "-fbuiltin-module-map",
        "-stdlib=libc++",
        "-std=c++20",
    ],
    deps = [
        ":std",
    ],
)

cc_module_binary(
    name = "a.out",
    srcs = [
        "main.cc",
    ],
    deps = [
        ":hello",
        ":std",
    ],
    copts = [
        "-fmodules",
        "-fbuiltin-module-map",
        "-stdlib=libc++",
        "-std=c++20",
    ],
    linkopts = [
        "-stdlib=libc++",
    ],
)

cc_module(
    name = "_Builtin_stddef_max_align_t",
    is_system = True,
    copts = [
        "-fmodules",
        "-fbuiltin-module-map",
        "-stdlib=libc++",
        "-std=c++20",
    ],
)

cc_module(
    name = "std_config",
    is_system = True,
    copts = [
        "-fmodules",
        "-fbuiltin-module-map",
        "-stdlib=libc++",
        "-std=c++20",
    ],
    deps = [
        ":_Builtin_stddef_max_align_t",
    ],
)

cc_module(
    name = "std",
    is_system = True,
    copts = [
        "-fmodules",
        "-fbuiltin-module-map",
        "-stdlib=libc++",
        "-std=c++20",
    ],
    deps = [
        ":std_config",
    ],
)

cc_module sets up our module with the say_hello function. In order to make use of standard c++ library components in their module form, we also need to set up the system modules _Builtin_stddef_max_align_t, std_config, and std.

But what if we want say_hello to be in a translation unit instead of an inline function? Then we can refactor the module to use an implementation unit.

// hello.ixx
export module hello;

import <string_view>;

export void say_hello(std::string_view const &name);

and

// hello.cc
module hello;

import <iostream>;
import <string_view>;

void say_hello(std::string_view const &name) {
  std::cout << "Hello " << name << "!\n";
}

We update the BUILD file to

# ...

cc_module(
    name = "hello",
    src = "hello.ixx",
    impl_srcs = [
      "hello.cc",
    ],
    copts = [
        "-fmodules",
        "-fbuiltin-module-map",
        "-stdlib=libc++",
        "-std=c++20",
    ],
    deps = [
        ":std",
    ],
)

# ...

The full source code for this and other examples are available in github/rnburn/rules_cc/examples.

Setting up a Project

Tracking all of those dependencies and options can be tedious. Let’s consider how we might set up a full project to avoid the boilerplate.

First, we’ll add a .bazelrc file at the project root to specify all the options we use

# .bazelrc
build --copt -std=c++20
build --copt -fmodules
build --copt -fno-implicit-modules
build --copt -fbuiltin-module-map
build --copt -stdlib=libc++
build --linkopt -stdlib=libc++

Next, we’ll make a file toolchain/BUILD that sets up the standard library modules.

# toolchain/BUILD
load("@com_github_rnburn_bazel_cpp20_modules//cc_module:defs.bzl", "cc_module")

package(default_visibility = ["//visibility:public"])

cc_module(
    name = "_Builtin_stddef_max_align_t",
    is_system = True,
)

cc_module(
    name = "std_config",
    is_system = True,
    deps = [
        ":_Builtin_stddef_max_align_t",
    ],
)

cc_module(
    name = "std",
    is_system = True,
    deps = [
        ":std_config",
        ":_Builtin_stddef_max_align_t",
    ],
)

Then we’ll make a file bazel/build_system.bzl which adds macros to automatically inject the standard library dependencies.

# bazel/build_system.bzl
load("@com_github_rnburn_bazel_cpp20_modules//cc_module:defs.bzl", "cc_module", "cc_module_binary")

def zoo_cc_module(name, deps = [], *args, **kwargs):
  cc_module(
      name = name,
      deps = deps + ["//toolchain:std"],
      *args,
      **kwargs)

Now, we can write BUILD files like this

# zoo/mammals/BUILD
load("//bazel:build_system.bzl", "zoo_cc_module")

package(default_visibility = ["//visibility:public"])

zoo_cc_module(
    name = "zoo.mammals.tiger",
    src = "tiger.ixx",
    impl_srcs = [
        "tiger.cc",
    ],
)

A full project example is available at github.com/rnburn/cpp20-module-example.

Stay up to date