Bazel as an alternative to CMake

Vertexwahn
10 min readMar 16, 2023

--

I assume you are a C++ developer who has some experience with CMake. You do not need any previous knowledge about Bazel. Given different examples, I want to show you why Bazel is possibly the better choice for your C++ projects. This article does not serve as an introduction to Bazel but should point out when and why it makes sense to consider Bazel instead of CMake.

Currently, CMake is the dominating build tool in the C++ community. To be more exact CMake is a build system generator, which means it generates for a build system such as make or Visual Studio corresponding files that can be used to build our projects. The main focus of CMake is on C++. Bazel on the other side is a polyglot build system, which means it supports many different programming languages besides C++ and orchestrates as a full-fledged build system the complete build process itself. This is quite different from a build file generator such as CMake. So one might argue that the comparison between CMake and Bazel does not make a lot of sense because they are technically different. Nevertheless, I want to focus here on the user experience given different examples.

Starting Simple: Hello World

The first example will be something where there will be no benefit over a traditional CMake approach but should serve to get you started with Bazel. Before you can use the Bazel build system you need to install it. Detailed instructions for the installation for different operating systems can be found here.

If you are using macOS and have brew installed you can simply type:

brew install bazel

Using Ubuntu you can execute:

sudo apt update && sudo apt install bazel

On Windows you can use Chocolatey as shown in the following or directly download a release build from GitHub:

Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
choco install bazel

After this, the bazel command should be available in your terminal. The bazel command can be called with the parameter version to test if the installation has worked.

bazel version

The output should look something like this:

Starting local Bazel server and connecting to it...
Build label: 6.1.1
Build target: bazel-out/k8-opt/bin/src/main/java/com/google/devtools/build/lib/bazel/BazelServer_deploy.jar
Build time: Wed Mar 15 15:44:56 2023 (1678895096)
Build timestamp: 1678895096
Build timestamp as int: 1678895096

Please note that a proper Bazel setup might require further steps as described in the Bazel documentation. In detail, on a Windows machine, you need to set up MSYS2 and install tools like git.

Bazel has the concept of a workspace. A workspace is a directory or folder,
that contains all the data that is needed to build a project. A workspace directory can be recognized by the fact that it contains a file called WORKSPACE or WORKSPACE.bazel. The workspace itself is again subdivided into packages. Each folder of the workspace can act as a package including the directory that contains the WORKSPACE[.bazel] file itself. A directory can be marked as a package by adding a BUILD or BUILD.bazel file. I personally prefer WORKSPACE.bazel and BUILD.bazel instead of WORKSPACE and BUILD since it makes more clear that those files are related to Bazel on nothing else.

A simple C++ Hello World program (main.cpp) can look like this:

#include <iostream>

int main() {
std::cout << "Hello World!" << std::endl;
}

The corresponding CMakeLists.txt for a CMake-based build would look like this:

cmake_minimum_required(VERSION 3.22.1)
project(HelloWorld)

add_executable(HelloWorld
main.cpp
)

As mentioned, at the core of the Bazel build system we are facing BUILD and WORKSPACE files. For instance, for the simple C++ HelloWorld example a BUILD.bazel file can look like this:

cc_binary(
name = "HelloWorld",
srcs = ["main.cpp"],
)

The WORKSPACE.bazel file can be empty but must exist. The directory tree structure should be like this:

<directory> HelloWorld
├── <file> BUILD.bazel
├── <file> main.cpp
└── <file> WORKSPACE.bazel (empty)

If you are on a Linux/Mac machine you can easily recreate this example by

mkdir HelloWorld
cd HelloWorld
touch WORKSPACE.bazel # can be empty
echo "cc_binary(name='HelloWorld', srcs=['main.cpp'])" > BUILD.bazel
echo -e '#include <iostream>\nint main(void) {\n std::cout << "Hello World!" << std::endl;\n}' > main.cpp
echo -e "6.1.1" > .bazelversion
bazel run //:HelloWorld

The example should print “Hello World!” on the screen.

Using Boost

Boost is a very famous C++ library. How would you set up a CMake project using this library? Think about how to set up the project in a way so it is easy to compile it on macOS (Intel and Arm), Windows, and Linux. Please note that Boost also has third-party dependencies such as bzip2, zlib, LZMA Utils, Zstandard, and BoringSSL. While thinking about this I show you the Bazel way.

This is how the WORKSPACE.bazel file looks like:


load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")

git_repository(
name = "com_github_nelhage_rules_boost",
commit = "41edb76e207b331a45f6a3c95b1212aa3e7800d2",
remote = "https://github.com/nelhage/rules_boost",
)

load("@com_github_nelhage_rules_boost//:boost/boost.bzl", "boost_deps")
boost_deps()

The WORKSPACE file fetches rules_boost which contains all the magic to collect all necessary transitive dependencies of Boost, building Boost, and providing Boost targets that we can use to link against our code.

The BUILD.bazel file can look like this:

cc_binary(
name = "sync_client",
srcs = ["main.cpp"],
deps = [
"@boost//:asio",
],
)

And main.cpp is a copy of this. The directory tree structure should look like this:

<directory> BoostAsio
├── .bazelversion (e.g. 6.1.1 - version I used to test this)
├── <file> BUILD.bazel
├── <file> main.cpp
└── <file> WORKSPACE.bazel

You can run this Boost ASIO demo via:

bazel run //:sync_client www.boost.org /LICENSE_1_0.txt

When you run the demo under the hood the following happens: Bazel analyses what is needed to build @boost//:asio. It figures out that Boost is needed for this. Therefore, Boost is downloaded and if necessary any transitive dependencies that are required to build the @boost//:asio target. Once everything needed to build it is downloaded, it starts to compile the relevant Boost parts for your application. This works for Windows, macOS, Linux, and different target platforms. To make this happen some effort was spent on the creation of rules_boost. Of course, if you have very special hardware architecture it could be that you have to adapt rules_boost to support your specific hardware, but for most “trivial” cases it should work out of the box.

I bet every try to reach the same with CMake is more difficult and fails more often for different platform combinations as the Bazel approach. I would be very surprised if you could show me a CMake approach that needs less effort and works on all platforms as the Bazel approach. The Bazel approach needs 7 lines to fetch Boost and 1 line to set a dependency to Boost ASIO. And It WORKS! No more trouble with FindBoost.cmake, using and distributing pre-compiled Boost Version, etc. Do you see the benefit? Let's jump to the next example.

Using Qt

Did you ever have trouble to setup Qt and build with CMake on your machine? These days are gone with Bazel. rules_qt6 will save your day. I build this UI using Bazel and rules_qt6.

Try yourself. Here is a hello_world.cpp:

#include <QtCore/QThread>
#include <QtCore/QVariant>
#include <QtWidgets/QApplication>
#include <QtWidgets/QLabel>
#include <QtWidgets/QStylePainter>

class MyWidget : public QWidget
{
public:
MyWidget() {
QLabel *label = new QLabel(this);
label->setText(QT_VERSION_STR);

setWindowTitle("Hello World");
}
private:

};

int main(int argc, char ** argv) {
QApplication app(argc, argv);

MyWidget * w = new MyWidget();
w->resize(600, 600);
w->show();

return app.exec();
}

Here is a BUILD.bazel file:

load("@rules_qt//:qt.bzl", "qt_cc_binary")

qt_cc_binary(
name = "hello_world",
srcs = ["hello_world.cpp"],
deps = [
"@rules_qt//:qt_core",
"@rules_qt//:qt_widgets",
],
)

The WORKSPACE.bazel file:

load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")

git_repository(
name = "rules_qt",
commit = "7814568b95dc6c1be420a5a844843404c793e316",
remote = "https://github.com/Vertexwahn/rules_qt6",
)

load("@rules_qt//:fetch_qt.bzl", "fetch_qt6")

fetch_qt6()

load("@rules_qt//tools:qt_toolchain.bzl", "register_qt_toolchains")

register_qt_toolchains()

Create also this .bazelrc file:

# Setup compiler flags - required for Qt6 is at least C++17
build:gcc9 --cxxopt=-std=c++2a
build:gcc9 --cxxopt=-Wall
build:gcc9 --cxxopt=-Werror

# GCC 11.2
#build:gcc11 --cxxopt=-std=c++23 # blocked by emsdk
build:gcc11 --cxxopt=-std=c++20
build:gcc11 --cxxopt=-Wall
#build:gcc11 --cxxopt=-Werror
#build:gcc11 --cxxopt=-Wno-error=volatile # blocked by emsdk
##build:gcc11 --cxxopt=-Wextra

# Visual Studio 2019
build:vs2019 --cxxopt=/std:c++20
build:vs2019 --cxxopt=/Zc:__cplusplus # Untested
build:vs2019 --enable_runfiles # https://github.com/bazelbuild/bazel/issues/8843
build:vs2019 --define compiler=vs2019
build:vs2019 --copt=-DWIN32_LEAN_AND_MEAN
build:vs2019 --copt=-DNOGDI
build:vs2019 --host_copt=-DWIN32_LEAN_AND_MEAN
build:vs2019 --host_copt=-DNOGDI

# Visual Studio 2022
build:vs2022 --cxxopt=/std:c++20
build:vs2022 --cxxopt=/Zc:__cplusplus
build:vs2022 --enable_runfiles # https://github.com/bazelbuild/bazel/issues/8843
build:vs2022 --define compiler=vs2022
build:vs2022 --copt=-DWIN32_LEAN_AND_MEAN
build:vs2022 --copt=-DNOGDI
build:vs2022 --host_copt=-DWIN32_LEAN_AND_MEAN
build:vs2022 --host_copt=-DNOGDI

# macOS (e.g. Clang 12.0.0)
build:macos --cxxopt=-std=c++2a
build:macos --cxxopt=-Wall

The .bazelrc holds different compiler configurations.

The directory layout:

<directory> QtDemo
├── .bazelversion (e.g. 6.1.1 - version I used to test this)
├── .bazelrc
├── <file> BUILD.bazel
├── <file> hello_world.cpp
└── <file> WORKSPACE.bazel

You can run the demo via:

bazel run --config=vs2022 //hello_world:hello_world

For “ — config” you have to choose the config of the compiler you are using (e.g. “gcc11”, etc.). More details can be found here.

When running “bazel run” you should see a window similar to this:

What the Qt6 rules under the hood is the following: Depending on our OS a rebuilt Qt version is fetched. This Qt version is used and linked against your application. The rules contain also some magic to run tools like moc if needed and support also QML. As a user, you do not need to have a preinstalled version of Qt. Qt is fetched magically and it just WORKS. Show me something comparable with CMake that works out of the box for macOS (Intel and ARM), Windows, and Linux with the same effort.

Other Libraries

Three are many more libraries you can use in C++ Bazel projects, e.g.:

And many more. Even if it does not exist there is a way to bazelize your dependencies.

Emscripten

Emscripten is a way to bring your C++ code into the web browser.

Set up the following WORKSPACE.bazel file:

load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")

http_archive(
name = "emsdk",
strip_prefix = "emsdk-17f6a2ef92f198f3c9ff30d07664e4090a0ecaf7/bazel",
url = "https://github.com/emscripten-core/emsdk/archive/17f6a2ef92f198f3c9ff30d07664e4090a0ecaf7.tar.gz",
sha256 = "8457e17852379291c987252df907761903008f650b60602d750df570e30fc0e0",
)

load("@emsdk//:deps.bzl", emsdk_deps = "deps")
emsdk_deps()

load("@emsdk//:emscripten_deps.bzl", emsdk_emscripten_deps = "emscripten_deps")
emsdk_emscripten_deps(emscripten_version = "3.1.32")

load("@emsdk//:toolchains.bzl", "register_emscripten_toolchains")
register_emscripten_toolchains()

It brings the Emscripten toolchain to your project.

Create the following BUILD.bazel file:

load("@rules_cc//cc:defs.bzl", "cc_binary")
load("@emsdk//emscripten_toolchain:wasm_rules.bzl", "wasm_cc_binary")

cc_binary(
name = "index",
srcs = ["main.cpp"],
linkopts = [
"-s USE_GLFW=3",
"-s WASM=1",
"-s ALLOW_MEMORY_GROWTH=1",
"-s NO_EXIT_RUNTIME=0",
"-s ASSERTIONS=1",
],
tags = ["manual"],
)

wasm_cc_binary(
name = "index-wasm",
cc_target = ":index",
)

Now let's code a small OpeGL demo that only clears the screen (main.cpp). Take this main.cpp file. Create an index.html with this content. Create a .bazelrc file (used that one from the Demo).

Here is the expected directory tree structure:

EmscriptenGL
├── .bazelrc
├── .bazelversion
├── BUILD.bazel
├── index.html
├── main.cpp
└── WORKSPACE.bazel

You can build the Emscripten demo via:

cd BazelDemos/intermediate/Cpp/EmscriptenGL/
bazel build --config=macos -- //:index-wasm
bazel_genfiles=$(bazel info bazel-genfiles)
sudo cp index.html $bazel_genfiles/index-wasm/index.html # sudo
cd $bazel_genfiles/index-wasm/
python3 -m http.server

No visit localhost on the right port and you should see:

Other crazy stuff

You can write a REST service using https://github.com/pistacheio/pistache create a docker container out of it using rules_docker and push it to Kubernetes cluster using rules_k8s. There are really crazy things that could be done using Bazel power. Show me the same using CMake ;).

You will also find Bazel rules to generate Doxygen or Sphinx documentation for your C++ code.

IDE Support for Bazel

You can find the best IDE support for C++ on Linux (Ubuntu) with CLion and the CLion Bazel Plugin. Breakpoints, Refactoring, etc. works very well. The same setup on macOS has some issues. Debugging does not work out of the box.

Maybe you ask: What is about Visual Studio? There is https://github.com/tmandry/lavender but this only works only with some manual fixes and then has some convenience issues when it comes to debugging. Overall Bazel works best on Linux. For instance, Windows has no sandbox concept and therefore, sometimes non-hermetic stuff works on Windows that will not work on Linux or macOS. Anyways if Visual Studio is the only option for you this should not be a complete show-stopper, but of course not as nice as a CMake-generated native Visual Studio solution. Let me know if this is the biggest issue for you.

Further Bazel demos from me can be found here: https://github.com/Vertexwahn/BazelDemos

Did someone say test coverage?

If you are running on Linux and have lcov installed the Bazel coverage command works very well to generate a detailed statement coverage report. Can your CMake do this?

More reasons to give Bazel a try

There can be different reasons why someone Is using Bazel instead of CMake to Bazel. It depends on different things like project size, project type, etc.

  • Caching: Bazel knows exactly which tests need to be re-executed when something has changed (e.g. modified source code or data file). This leads to significantly slower test times compared to CMake. This is important when doing test-driven development.
  • Bazel is polyglot — it supports many other languages (such as Python, Rust, Go, Java, etc.) — that gives me the opportunity to combine my Python scripts that generate my own weights in one build without having to switch build systems (there is some python support in CMake but that is far beyond what Bazel can do here -> for instance, there is Bazel Python Plugin for PyCharm)
  • CMake Legacy: The CMake configuration language has a lot of legacy stuff — Policies and Modern CMake guides help you to work around it — but the whole CMake “language” look somehow old and odd. Bazel has Starlark language (kind of python subset) and a clear concept and support windows for deprecating old (langauge) features.

--

--