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:

  1. they organize your project, and
  2. 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:

  1. it only specifies the minimum standard required to build the target and does not enforce a specific standard, and
  2. 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.