Target-based build systems with CMake

Objectives

  • Learn that the basic elements in CMake are not variables, but targets.

  • Learn about properties of targets and how to use them.

  • Learn how to use visibility levels to express dependencies between targets.

  • Learn how to work with projects spanning multiple folders.

  • Learn how to handle multiple targets in one project.

Real-world projects require more than compiling a few source files into executables and/or libraries. In the vast majority of cases, you will be faced with projects comprising dozens to hundreds of source files sprawling in a complex source tree. Using modern CMake helps you keep the complexity of the build system in check.

With the advent of CMake 3.0, there has been a significant shift in the way the CMake domain-specific language (DSL) is structured. Rather than relying on global scope variables to convey information in a project, we should shift to using targets and properties.

We will first demonstrate the better, nicer way using targets and properties, but we will also contrast this approach with less nice solutions that many codes use (global variables and less abstraction), so that we learn recognizing good patterns and stay away from less maintainable patterns for our own projects.

Targets

A target is declared by either add_executable or add_library: thus, in broad terms, a target maps to a build artifact in the project.

You can add custom targets to the build system with add_custom_target. Custom targets are not necessarily build artifacts.

Any target has a collection of properties, which define how the build artifact should be produced and how it should be used by other dependent targets in the project.

../_images/target.svg

A target is the basic element in the CMake DSL. Each target has properties, which can be read with get_target_property and modified with set_target_properties. Compile options, definitions, include directories, source files, link libraries, and link options are properties of targets.

The five most used commands used to handle targets are:

There are additional commands in the target_* family:

$ cmake --help-command-list | grep "^target_"

target_compile_definitions
target_compile_features
target_compile_options
target_include_directories
target_link_directories
target_link_libraries
target_link_options
target_precompile_headers
target_sources

Properties

CMake lets you set properties at many different levels of visibility across the project:

  • Global scope. These are equivalent to variables set in the root CMakeLists.txt. Their use is, however, more powerful as they can be set from any leaf CMakeLists.txt.

  • Directory scope. These are equivalent to variables set in a given leaf CMakeLists.txt.

  • Target. These are the properties set on targets that we discussed above.

  • Test.

  • Source files. For example, compiler flags.

  • Cache entries.

  • Installed files.

For a complete list of properties known to CMake:

$ cmake --help-properties | less

You can get the current value of any property with get_property and set the value of any property with set_property.

Visibility levels

It is much more robust to use targets and properties than using variables and here we will discuss why.

../_images/target_inheritance.svg

Properties on targets have visibility levels, which determine how CMake should propagate them between interdependent targets.

Visibility levels PRIVATE, PUBLIC, or INTERFACE are very powerful but not easy to describe and imagine in words. Maybe a better approach to demonstrate what visibility levels is to see it in action.

We will demonstrate this with a hello world example where somebody went a bit too far with modularity and where we have split the code into 3 libraries and the main function (content/examples/property-visibility/):

.
├── CMakeLists.txt
├── greeting
│   ├── greeting.cpp
│   └── greeting.hpp
├── hello_world
│   ├── hello_world.cpp
│   └── hello_world.hpp
├── main.cpp
└── world
    ├── world.cpp
    └── world.hpp

Here the main function links to greeting which links to hello_world which links to world.

The internal dependency tree

If you have Graphviz installed, you can visualize the dependencies between the targets:

$ cd build
$ cmake --graphviz=project.dot ..
$ dot -T svg project.dot -o project.svg
../_images/project.svg

The dependencies between the four targets in the example project.

This is the CMakeLists.txt - take some time to study it since there is a quite a lot going on:

 1cmake_minimum_required(VERSION 3.14)
 2
 3project(example LANGUAGES CXX)
 4
 5
 6add_library(world)
 7target_sources(world
 8  PUBLIC
 9    world/world.hpp
10  PRIVATE
11    world/world.cpp
12  )
13target_include_directories(world
14  PUBLIC
15    world
16  )
17# target_compile_definitions(world PRIVATE "MY_DEFINITION")
18
19
20add_library(hello_world)
21target_sources(hello_world
22  PUBLIC
23    hello_world/hello_world.hpp
24  PRIVATE
25    hello_world/hello_world.cpp
26  )
27target_include_directories(hello_world
28  PUBLIC
29    hello_world
30  )
31
32# hello_world depends on world
33target_link_libraries(hello_world PRIVATE world)
34
35
36add_library(greeting)
37target_sources(greeting
38  PUBLIC
39    greeting/greeting.hpp
40  PRIVATE
41    greeting/greeting.cpp
42  )
43target_include_directories(greeting
44  PUBLIC
45    greeting
46  )
47
48# greeting depends on hello_world
49target_link_libraries(greeting PRIVATE hello_world)
50
51
52add_executable(example main.cpp)
53
54# example depends on greeting
55target_link_libraries(example PRIVATE greeting)

Testing the 3 different visibility levels

  1. Browse, configure, build, and run the code.

  2. Now uncomment the highlighted line (line 17) with target_compile_definitions, configure into a fresh folder, and build:

    $ cmake -S. -Bbuild_private
    $ cmake --build build_private
    

    You will see that the definition is used in world.cpp but nowhere else.

  3. Now change the definition to PUBLIC, configure into a fresh folder, and build.

    $ cmake -S. -Bbuild_public
    $ cmake --build build_public
    

    You will see that the definition is used both in world.cpp and hello_world.cpp.

  4. Now change the definition to INTERFACE, configure into a fresh folder, and build.

    $ cmake -S. -Bbuild_interface
    $ cmake --build build_interface
    

    You will see that the definition is used only in hello_world.cpp but not in world.cpp.

Visibility levels

Discuss what this means and how we can use it to fine-tune visibility of definitions, include directories, and libraries.

What do you think will happen if we change the visibility in line 14 of the above code to PRIVATE?

Building a larger project with multiple folders

In the example above we have split a project into folders and libraries but we kept one CMakeLists.txt. As the project grows, this becomes impractical for humans (the CMake computer overlords will not mind) and maintenance becomes easier if we split the CMake configuration into multiple CMakeLists.txt with the help of add_subdirectory. Our goal is to have a CMakeLists.txt as close as possible to the source files.

We will soon practice with an example project where instead of this:

project/
├── CMakeLists.txt           <--- Only at root
├── external
└── src
    ├── evolution
    ├── initial
    ├── io
    └── parser

we rather wish this:

project/
├── CMakeLists.txt           <--- Root
├── external
│   ├── CMakeLists.txt       <--- Leaf
└── src
    ├── CMakeLists.txt       <--- Another leaf
    ├── evolution
    │   ├── CMakeLists.txt   <--- Leaf of leaf
    ├── initial
    │   ├── CMakeLists.txt   <--- Leaf of leaf
    ├── io
    │   ├── CMakeLists.txt   <--- Leaf of leaf
    └── parser
        └── CMakeLists.txt   <--- Leaf of leaf

Each folder in a multi-folder project will contain a CMakeLists.txt: a source tree with one root and many leaves.

The root CMakeLists.txt will contain the invocation of the project command: variables and targets declared in the root have effectively global scope. Remember also that PROJECT_SOURCE_DIR will point to the folder containing the root CMakeLists.txt. In order to move between the root and a leaf or between leaves, you will use the add_subdirectory command.

We can declare targets at any level, not necessarily the root: a target is visible at the level at which it is declared and all higher levels.

Exercise: practicing structuring projects with cellular automata

Let’s move beyond “Hello, world” and work with a project spanning multiple folders. We will implement a relatively simple code to compute and print to screen elementary cellular automata. We separate the sources into src and external to simulate a nested project which reuses an external project. Your goal is to:

  1. Build the main executable at content/examples/multiple-folders/problem/.

  2. Where is it located in the build tree? Remember that CMake generates a build tree mirroring the source tree.

  3. The executable will accept 3 arguments: the length, number of steps, and automaton rule. You can run it with:

$ automata 40 5 30

This is the output:

length: 40
number of steps: 5
rule: 30
                    *
                   ***
                  **  *
                 ** ****
                **  *   *
               ** **** ***
  1. Push the definition of targets “down” into folders and subfolders, as close as possible to the source files with the help of add_subdirectory.

You want to arrive at this (content/examples/multiple-folders/solution/cxx/) structure:

.
├── CMakeLists.txt
├── external
│   ├── CMakeLists.txt
│   ├── conversion.cpp
│   └── conversion.hpp
└── src
    ├── CMakeLists.txt
    ├── evolution
    │   ├── CMakeLists.txt
    │   ├── evolution.cpp
    │   └── evolution.hpp
    ├── initial
    │   ├── CMakeLists.txt
    │   ├── initial.cpp
    │   └── initial.hpp
    ├── io
    │   ├── CMakeLists.txt
    │   ├── io.cpp
    │   └── io.hpp
    ├── main.cpp
    └── parser
        ├── CMakeLists.txt
        ├── parser.cpp
        └── parser.hpp

In target_sources, does using absolute (${CMAKE_CURRENT_LIST_DIR}/parser.cpp) or relative (parser.cpp) paths make any difference?

  1. Discuss pros and cons of one CMakeLists.txt compared to many.

Target properties vs. global settings

Let us return to the very granular hello world example from futher above. In CMake there are many ways to achieve something and here we compare two.

Please discuss pros and cons and what possible problems you see or anticipate.

This is the example we have used further above. It is not perfect but much better than the example in the other tab.

cmake_minimum_required(VERSION 3.14)

project(example LANGUAGES CXX)


add_library(world)
target_sources(world
  PUBLIC
    world/world.hpp
  PRIVATE
    world/world.cpp
  )
target_include_directories(world
  PUBLIC
    world
  )
# target_compile_definitions(world PRIVATE "MY_DEFINITION")


add_library(hello_world)
target_sources(hello_world
  PUBLIC
    hello_world/hello_world.hpp
  PRIVATE
    hello_world/hello_world.cpp
  )
target_include_directories(hello_world
  PUBLIC
    hello_world
  )

# hello_world depends on world
target_link_libraries(hello_world PRIVATE world)


add_library(greeting)
target_sources(greeting
  PUBLIC
    greeting/greeting.hpp
  PRIVATE
    greeting/greeting.cpp
  )
target_include_directories(greeting
  PUBLIC
    greeting
  )

# greeting depends on hello_world
target_link_libraries(greeting PRIVATE hello_world)


add_executable(example main.cpp)

# example depends on greeting
target_link_libraries(example PRIVATE greeting)

Keypoints

  • Using targets, you can achieve granular control over how artifacts are built and how their dependencies are handled.

  • Compiler flags, definitions, source files, include folders, link libraries, and linker options are properties of a target.

  • Avoid using variables to express dependencies between targets: use the visibility levels PRIVATE, INTERFACE, PUBLIC and let CMake figure out the details.

  • Use get_property to inquire and set_property to modify values of properties.

  • To keep the complexity of the build system at a minimum, each folder in a multi-folder project should have its own CMakeLists.txt.