The Crux of Modern CMake
6 January 2025
CMake is the most popular build system for C++. Some even consider it the standard build system and I would not disagree. Like C++, it suffers from many bad practices that have accumulated over the years. Modern CMake is clean and concise - here is the crux of it.
Basic project definition
Specifying the required CMake version, declaring the project, and creating an executable is straightforward.
cmake_minimum_required(VERSION "3.25.0")
project(foo VERSION "0.0.1" LANGUAGES "CXX")
add_executable(bar "main.cpp")
Typically, I set the minimum required CMake version to the version that the
latest Debian release provides. At the time of writing, the latest release
is Debian 12 and it provides CMake version 3.25.0
.
The add_executable()
function call creates a target named bar
. From CMake
documentation:
Probably the most important item is targets. Targets represent executables, libraries, and utilities built by CMake.
For me, the most important takeaway from An Introduction to Modern CMake is
to think in targets. This means avoiding global functions and variables that
mess with the global usage requirements (e.g. include_directories()
) and only
using functions that work with targets (e.g. target_include_directories()
)
instead.
The following snippet creates a library baz
which has its headers in the
include
directory and those headers need to be included by library consumers:
add_library(baz "baz.cpp")
target_include_directories(baz PUBLIC "include")
To consume the baz
library from bar
executable, a single call is needed:
target_link_libraries(bar PRIVATE baz)
Namespaces
Namespaces provide two benefits:
- they organize your project, and
- they make CMake fail early in case of typos in
target_link_libraries()
calls.
If there was no library baz
defined in our example above, CMake would treat
baz
as a plain library name and would simply add -lbaz
to the compiler
flags. This means that errors will be raised by the compiler or the linker at
build time. An CMake error saying something like target baz
not found*
before even attempting to compile the code would be less confusing. This behavior
is achieved by creating a namespaced ALIAS
target and linking against it.
add_library(foo::baz ALIAS baz)
target_link_libraries(bar PRIVATE foo::baz)
Enabling warnings
As with consuming libraries, the compiler warnings are added per target instead of manipulating global state.
target_compile_options(bar
PRIVATE -Wall
-Wextra
-Wpedantic
-Wconversion)
Since we usually want to add the same warning flags to multiple targets, it's useful to create an interface library that can be linked into multiple targets.
add_library(foo-gcc-warnings INTERFACE)
target_compile_options(foo-gcc-warnings
INTERFACE -Wall
-Wextra
-Wpedantic
-Wconversion)
add_library(foo::gcc::warnings ALIAS foo-gcc-warnings)
Interface libraries only specify usage requirements do not produce any artifacts
on disk. The library is consumed with a typical target_link_libraries()
call:
target_link_libraries(bar PRIVATE foo::gcc::warnings)
Setting the C++ standard
Unfortunately, the modern way of enforcing the C++ standard with
target_compile_features()
isn't great for two reasons:
- it only specifies the minimum standard required to build the target and does not enforce a specific standard, and
- it does not allow for disabling extensions.
Using, set_target_properties()
is better, but tedious since it's a fairly long
call that needs to be written for each target as those properties cannot
propagated with interface libraries:
set_target_properties(bar PROPERTIES
CXX_STANDARD 17
CXX_STANDARD_REQUIRED true
CXX_EXTENSIONS false)
Alternatively, a single set_target_properties()
call can be written at the end
of your CMake script and all desired target can be added to it. This approach is
a bit error prone since when I create a new target, I have to remember to find
that call and add the newly created target to the call.
Due to these drawbacks of the modern methods, I often just fall back to the old way of doing things by setting the variables that enforce the C++ standard and disable extension on all targets in the project.
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED true)
set(CMAKE_CXX_EXTENSIONS false)
Since the C++ standard is usually a global setting of a project, I'm okay with specifying it with a non-target based approach. If a particular target needs to override the global setting, "set_target_properties()" can be used.
Environment variables
I have the following CMake environment variables set to customize it to my liking:
export CMAKE_C_COMPILER_LAUNCHER='ccache'
export CMAKE_CXX_COMPILER_LAUNCHER='ccache'
export CMAKE_GENERATOR='Ninja'
export CMAKE_BUILD_TYPE='Debug'
export CMAKE_EXPORT_COMPILE_COMMANDS='true'
export CMAKE_COLOR_DIAGNOSTICS='true'
I use ccache to speed up recompilation of same files with same flags.
Secondly, I use ninja as my compiler runner. Since CMake does not set a
default build type, I set it to Debug
. Next, compile_commands.json
file is generated by CMake so that my neovim text editor gets IDE-like
powers. Finally, colored diagnostics are enabled so that compiler warnings and
errors appear in nice colors on the command line.
Conclusion
CMake done right is a pretty good build system. The mental model of the build is the same as models of alternative build systems (e.g. meson), but CMake has the popularity advantage. Utilizing only modern CMake, you should be able to build almost anything with a build script that is easy to understand. As always, prefer simplicity and you will be fine.