Intro
Some programming environments don’t need a blog post like this one. In such environments everything is standardized and works out of the box. There is a working package manager that everybody agrees on. There is only one tool chain, it is straightforward and comes built in with the language installation. Go is like that. The combination of Go and the Goland IDE functions neatly without any fuss, tweaking, querying stackoverflow for obscure error messages etc.
Then there are other environments. One example is Haskell. Haskell is not too bad and I have a blog post describing how it works for me.
And then there is the C/C++ swamp of despair. Unlike the Go ecosystem where everything just works, the standard library covers most needs and what it doesn’t can easily be pulled in with Go modules, the C/C++ swamp demands lots of bike shedding, yak shaving and other fussing around. So why do it? Good question. I don’t use C/C++ often in my hobby side projects but I do like the fact that I can go from daily real job work (where I use C++) to hobby project easily without having to switch the language.
Here I’ll describe how I navigate this swamp (aka do the yak shaving). As I mentioned before in the tooling posts, tooling changes often and becomes outdated quickly. The setup in this post currently works for me. Also it is tailored to fit my experience: I’m familiar with Bazel from my real job, so Bazel is the toolchain driver of my choice. I understand that vcpkg and cmake are more popular in the C/C++ ecosystem, but for some reason I could never become friends with cmake, its syntax just feels weird to me.
The C/C++ IDE of choice for me is CLion and thankfully there is a very solid Bazel plugin available that so far has covered everything I needed.
In what follows I’ll describe several types of dependencies and how to set them up to work properly with Bazel and inside CLion. We’ll use the hello world example repo I created for this purpose. The dependencies go from very easy (ie available in the central Bazel registry) to very complicated (ie only cmake build system defined).
Available in central registry: Abseil
We start easy. Let’s say we want to use something from abseil. abseil is available in the central registry. All we have to do is add an entry to the MODULE.bazel
file:
bazel_dep(name = "abseil-cpp", version = "20240722.0.bcr.1")
We can then add abseil dependency declarations to our targets in the BUILD.bazel
file:
cc_binary(
name = "hello_world",
srcs = ["hello_world.cc"],
deps = [
"@abseil-cpp//absl/strings",
],
)
We include the headers like so in hello_world.cc
:
#include "absl/strings/str_join.h"
int main() {
const std::vector<std::string> v = {"foo", "bar", "baz"};
const std::string s = absl::StrJoin(v, "-");
}
Header-only dependency with no additional needs: cpp-peg
The next dependency case is also easy: not in the Bazel registry but only one header with no additional transitive dependencies. Examples here is cpp-peglib. A good way to bring it into our project is to add it as a git submodule:
git submodule add https://github.com/yhirose/cpp-peglib.git
We can define header only library targets for it in the BUILD.bazel
file:
cc_library(
name = "peg",
hdrs = ["cpp-peglib/peglib.h"],
deps = [],
)
cc_binary(
name = "hello_world",
srcs = ["hello_world.cc"],
deps = [
":peg",
"@abseil-cpp//absl/strings",
],
)
Usage is as follows:
#include "absl/strings/str_join.h"
#include "cpp-peglib/peglib.h"
int main() {
const std::vector<std::string> v = {"foo", "bar", "baz"};
const std::string s = absl::StrJoin(v, "-");
peg::parser parser(R"(
# Grammar for Calculator...
Additive <- Multiplicative '+' Additive / Multiplicative
Multiplicative <- Primary '*' Multiplicative / Primary
Primary <- '(' Additive ')' / Number
Number <- < [0-9]+ >
%whitespace <- [ \t]*
)");
}
Library covered by our cc-library target: SQLiteCpp
This case has a moderately complicated library with only one additional dependency that can be satisfied from the central registry. As an example for this case we have the SqLiteCpp wrapper for sqlite3. The sqlite3 dependency it needs is in the registry. We add SQLiteCpp
as a git submodule and in the BUILD.bazel
file we define the following cc-library
target:
cc_library(
name = "sqlitecpp",
srcs = glob(["SQLiteCpp/src/*.cpp"]),
hdrs = glob(["SQLiteCpp/include/SQLiteCpp/*.h"]),
copts = ["-Isqlite3"],
defines = [
"SQLITE_DBCONFIG_ENABLE_LOAD_EXTENSION",
"SQLITE_OMIT_LOAD_EXTENSION",
],
strip_include_prefix = "SQLiteCpp/include",
deps = [
"@sqlite3",
],
)
Headers from this can be included easily like so:
#include "SQLiteCpp/include/SQLiteCpp/Database.h"
Library already installed on the system: openssl
Here we want to use a library that’s already installed on the machine. To make it more interesting, we use an example of another library that wants to use a system-provided dependency: cpp-httplib needs openssl. We already know how to include a headers-only library like cpp-httplib
. What remains is to satisfy its dependency on openssl. To bring in openssl we add the following incantation to WORKSPACE.bazel
:
new_local_repository(
name = "openssl",
build_file_content = """
cc_library(
name = "ssl",
srcs = ["lib/libssl.a"],
hdrs = glob(["include/openssl/*.h"]),
strip_include_prefix = "/include",
visibility = ["//visibility:public"],
)
cc_library(
name = "crypto",
srcs = ["lib/libcrypto.a"],
hdrs = glob(["include/openssl/*.h"]),
strip_include_prefix = "/include",
visibility = ["//visibility:public"],
)
""",
path = "/opt/homebrew/Cellar/openssl@3/3.4.0",
)
This can be thought of as injecting a Bazel BUILD file into an existing repository. We can then reference the targets in this BUILD file normally:
cc_library(
name = "httplib",
hdrs = ["cpp-httplib/httplib.h"],
defines = [
"CPPHTTPLIB_OPENSSL_SUPPORT",
],
deps = [
"@openssl//:crypto",
"@openssl//:ssl",
],
)
cc_binary(
name = "hello_world",
srcs = ["hello_world.cc"],
deps = [
":peg",
"@abseil-cpp//absl/strings",
"@openssl//:crypto",
"@openssl//:ssl",
],
)
Using the headers for openssl also works:
#include "openssl/evp.h"
template<typename T>
std::string convertToHex(const T& binaryResult)
{
std::ostringstream ss;
ss << std::hex << std::setfill('0');
for (unsigned int i = 0; i < binaryResult.size(); ++i) {
ss << std::setw(2) << static_cast<unsigned>(binaryResult.at(i));
}
return ss.str();
}
std::array<uint8_t, 32> computeSHA256(const std::string& input) {
std::array<uint8_t, 32> hash{};
EVP_MD_CTX* mdctx = EVP_MD_CTX_new();
const EVP_MD* md = EVP_sha256();
EVP_DigestInit_ex(mdctx, md, nullptr);
EVP_DigestUpdate(mdctx, input.c_str(), input.length());
EVP_DigestFinal_ex(mdctx, hash.data(), nullptr);
EVP_MD_CTX_free(mdctx);
return hash;
}
std::string computeSHA256Hex(const std::string& input) {
auto hash = computeSHA256(input);
return convertToHex(hash);
}
int main() {
auto ss = computeSHA256("Hello world!");
std::cout << "sha256: " << convertToHex<std::array<uint8_t, 32>>(ss) << "\n";
}
Library with Bazel BUILD file: marl
This is normally easy but I want to point out a gotcha that bit me and is easy to avoid. As an example here serves marl, a fiber scheduler. As before, we add it to our project as a git submodule. Inside is a Bazel BUILD file which we can use directly:
cc_binary(
name = "hello_world",
srcs = ["hello_world.cc"],
deps = [
":peg",
"//marl",
"@abseil-cpp//absl/strings",
"@openssl//:crypto",
"@openssl//:ssl",
],
)
Here’s the gotcha: The Intellij Bazel plugin does a nice job synchronizing between the Bazel world and the CLion view of the project. In this case though it automatically finds too many marl
BUILD targets and not all of them work, causing a synchronization error. To prevent that, we go into the CLion menu “Bazel->Project->Open Project View File” (or open the .clwb/.bazelproject
file) and turn off derive_targets_from_directories
:
directories:
.
derive_targets_from_directories: false
targets:
hello_world
Then only the transitive closure of the targets we are interested in gets synchronized and other targets are ignored.
Cmake project: rocksdb
rocksdb is a fairly complicated Cmake library that doesn’t easily lend itself to the above trick of injecting an outside Bazel BUILD file. Luckily for us there is an alternative: drive Cmake itself from Bazel. Here is how this is done. We bring in rocksdb
as usual as a git submodule.
We want to let cmake build the static library for us and to do that we employ Bazel rules_foreign_cc
. We first add the new Bazel rule package to our MODULE.bazel
file to make it available:
bazel_dep(name = "rules_foreign_cc", version = "0.12.0")
In BUILD.bazel
we use this rule like so (snappy
and gflags
below are two rocksdb dependencies available in the central registry, so easily satisfied):
load("@rules_foreign_cc//foreign_cc:defs.bzl", "cmake")
filegroup(
name = "rocksdb-srcs",
srcs = glob(["rocksdb/**"]),
visibility = ["//visibility:public"],
)
cmake(
name = "rocksdb",
lib_source = ":rocksdb-srcs",
out_static_libs = ["librocksdb.a"],
deps = [
"@gflags",
"@snappy",
],
)
With that we can use rocksdb:
cc_binary(
name = "hello_world",
srcs = ["hello_world.cc"],
deps = [
":peg",
":rocksdb",
"//marl",
"@abseil-cpp//absl/strings",
"@openssl//:crypto",
"@openssl//:ssl",
],
)
and the headers are included like so:
#include "rocksdb/include/rocksdb/db.h"
#include "rocksdb/include/rocksdb/options.h"
#include "rocksdb/include/rocksdb/slice.h"
#include "rocksdb/include/rocksdb/status.h"
#include "rocksdb/include/rocksdb/write_batch.h"
Conclusion
The state of Bazel C/C++ builds is, surprisingly enough, ok and the Intellij plugin ensures that CLion has functioning synchronization and a complete and correct view of the project enabling navigation and debugging with CLion. You can find the setup at hello world example repo. Except for the header include statements, ignore the C++ code because it only serves as usage examples and nothing more. Concentrate instead on the Bazel declarations.