In this article, we look at some more advanced concepts in CMake that will help you automate your build process from start to end. We first look at programming with variables, conditional statements, and loops, and then look at functions and macros, configure files, generator expressions, and finally policies.
If you master these concepts, you will know most of what you need in your day-to-day CMake usage, with some additional advanced concepts covered in the next articles within this series. The previous and current article, though, will form the basis for you to explore some of the other, more advanced concepts. So then, let’s have a look at what advanced features CMake offers that you should be aware of.
In this series
- Part 1: How to use Makefiles to automate CFD solver compilations
- Part 2: Introduction to CMake for CFD practitioners
- Part 3: Advanced CMake features to power up your CFD development
- Part 4: How to compile, install, and use custom libraries with CMake
- Part 5: How to add external libraries into your CFD solver using CMake
- Part 6: How to automate testing with CMake, CTest, and gtest
In this article
Introduction
Let’s do a little recap before we move on. In our opening article, we looked at the necessity of having some form of automation tool in place to look after our compilation and linking of source files into an executable. Of course, we can do that manually, but once our project reaches a state where we have tens, hundreds, if not thousands of source files, it becomes impractical.
The first automation system we looked at was Make. Make is one of the oldest build tools, and it works really well, but cross-platform support is difficult, and it does not offer any support for more advanced software build needs. Thus, a more dedicated build system is required, and I made my case for why we should use CMake both in the opening article and then subsequently in the second article, where we looked at the basic syntax of CMake and how to use it to compile both executables and libraries.
At the end of the second article, when we looked at installing custom libraries into a central location on our hard disk so that other projects can use our library, I mentioned that we have to modify the install step so that other projects can indeed use our library. But before we can look at this, we first need to introduce some more advanced concepts of CMake, which will help us modify the installation step.
This is the purpose of this article. I want to explore some of the more common features you’ll find when looking through CMake files. Even if you have no intention of using any of these more advanced features, you should at least be able to identify them when you look through a CMakeLists.txt
file. This article in combination with previous one will give you a real solid foundation and you’ll find that you will be able to achieve most tasks with the tools discussed in these articles alone.
So, let’s get straight to the point and write some CMake code!
Advanced features to make you look like a CMake wizard
As alluded to above, the following presents a list of features I think are so important that you should make an effort to learn them (and, of course, use them in your own projects). You won’t need to use all of them all the time, but knowing what they do will allow you to decide when to use what feature. I should note here that there are some more advanced features available that we won’t cover in this article. These features require a bit more explanation, and so they will all receive their own dedicated article.
If you want to follow along with any of the examples, create a minimal CMakeLists.txt
file, which looks like the following:
cmake_minimum_required(VERSION 3.23)
project(AdvancedCMakeExample)
### put your commands below for testing
Then, just copy the code you want to test below line 4. To execute CMake and to see its output, we just have to run the configuration step, i.e., we first create a new build/
directory with the command
mkdir build
and then we run CMake from within the build directory (after changing into the directory with the cd build
command):
cmake ..
Basic programming with CMake
CMake allows you to to basic programming tasks such as setting and modifying variables, looping through lists, insert conditional if/else statements and printing information to the console. We will have a look at all of them in this section.
Variables and the CMake Cache
Variables are a bit funny in CMake and work a bit differently from what you may expect. The basic syntax to set variables is to use the set()
command:
set(MY_INT 2)
set(MY_STRING "cfd.university")
The convention is to use capitalised letters for variable names, which I am following here. CMake only differentiates between strings and booleans as their variable type. We can set the type explicitly, including a description of what the variable is doing using the following syntax:
set(WEBSITE "cfd.university" CACHE STRING "Name of website")
set(VERSION_MAJOR 1 CACHE STRING "Major version")
set(VERSION_MINOR 2 CACHE STRING "Minor version")
set(NEEDS_UPDATE ON CACHE BOOL "Need to update website?")
There is an additional keyword CACHE
, which we will be looking at shortly. We have already seen before that we can access these variables by enclosing it with the ${}
syntax. To test this, we may want to print them to the screen using the following syntax:
message(STATUS "Website: ${WEBSITE}")
message(STATUS "Current version: ${VERSION_MAJOR}.${VERSION_MINOR}")
message(STATUS "Needs update?: ${NEEDS_UPDATE}")
The message()
function facilitates the printing capabilities here, and the first argument to this function (here STATUS
) will influence the appearance of the message. We use STATUS
for by default, but there are quite a few more options available, such as WARNING
and FATAL_ERROR
, which will print a warning (or error) message before printing the actual content. If you use FATAL_ERROR
in your message, CMake will abort the configuration step. For a list of all all message types, have a look at the message documentation page.
Besides basic variables, we can also specify lists, which follow exactly the same syntax, with the only difference that we can pass several values to the variable, either as individual ones or as semi-colon-separated items, i.e. the following two lists are identical
set(KEYWORDS "cfd" "programming" "c++")
set(KEYWORDS "cfd;programming;c++")
We can now work with this list; for example, to get the number of elements, we use the list() command with the argument LENGTH. The arguments are the list of which we want to get the number of arguments and the variable in which we want to store the answer. In the keywords example above, this turns into:
list(LENGTH KEYWORDS NUM_KEYWORDS)
message(STATUS "Number of keywords: ${NUM_KEYWORDS}")
This will print the number Number of keywords: 3
to the console. There are a few more arguments that the list()
command accepts, which you can find in the list documentation.
If you want, you can also perform basic mathematical operations in CMake, this we have to use the math()
environment. It only supports integers, no floating points, so in that respect it is not that powerful, but may get the job done if you need to do simple tasks such as incrementing the version number.
set(VERSION_MAJOR 1)
set(VERSION_MINOR 2)
math(EXPR VERSION_MINOR_OUT "${VERSION_MINOR} + 1")
message(STATUS "Version ${VERSION_MAJOR}.${VERSION_MINOR_OUT}")
This will print Version: 1.3
to the screen. We will see later that we can automate certain aspect of the build process like running git and committing files to a remote repository, in which case this may be very handy.
Let’s now turn to the Cache. CMake has a file called CMakeCache.txt
which is located in the build/
directory after we run CMake for the first time. It contains a bunch of default variables (usually starting with the prefix CMAKE_
) but it will also contain all user-defined variables. For example, when we run CMake on the command line with the following syntax:
cmake -DNAME="cfd.university" ..
there will be an additional NAME
entry in the CMakeCache.txt
file. Similarly, we saw above that we can set variables with the syntax
set(WEBSITE "cfd.university" CACHE STRING "Name of website")
The keyword CACHE
here signals to CMake that this variable should also be put in the CMakeCache.txt
file. The advantage of keeping this file is that CMake remembers values specified once, this is a curse and a blessing, if you don’t know exactly how this command works. If we execute CMake once, then we have the WEBSITE
variable within the CMakeCahce.txt
file. If you changed the content to, say, cfd.exchange
, and run CMake again; the value won’t have changed. That is because once a variable is in the cache, you can’t simply change it.
If you want to override the existing value, then you have to use the FORCE
option, i.e.
set(WEBSITE "cfd.exchange" CACHE STRING "Name of website" FORCE)
Sometimes, it becomes necessary for CMake to regenerate the CMakeCache.txt
file, which is typically the case when you change the generator of the project, e.g. switch from Make to Ninja. If that is the case, CMake is usually kind enough to let you know that this is indeed what you need to do.
Conditional statements
Conditional statements, flow control, or, simply put, if/else statements are other features that are supported by CMake. This is great in conjunction with Boolean variables, as we can now set options on either the command line or within the CMakeLists.txt
file, which will then steer the build process. The basic syntax is:
if(${BOOLEAN_VARIABLE})
# do something if true
else()
# do something else if false
endif()
A typical example would be to check if the build system should build the tests that may come with a project as well, e.g.,
set(ENABLE_TESTS OFF CACHE BOOL "Should tests be build as part of the project?" FORCE)
if(${ENABLE_TESTS})
message(STATUS "Building tests ...")
# perform test builds here ...
endif()
By default, tests are typically turned off, so OFF
would be the default behaviour here. Typically, only developers would need to turn the tests ON
.
There is quite a bit more complexity to if/else statements; we can do more than simply checking if a variable evaluates to true or false, e.g. check if a variable is DEFINED
, check if a file EXISTS
, check if a TARGET
exists, using AND
as well as OR
to check multiple statements, and so on. There is some wealth of information here in the if documentation. For example, we can do the following:
set(LIB_TYPE DYNAMIC CACHE STRING "Type of library, either STATIC or DYNAMIC" FORCE)
if(${LIB_TYPE} STREQUAL "STATIC")
add_library(cfdLib STATIC "")
elseif(${LIB_TYPE} STREQUAL "DYNAMIC")
add_library(cfdLib SHARED "")
endif()
if(TARGET cfdLib)
message(STATUS "Library type: ${LIB_TYPE}")
# add relevant files, include directories, etc. here
target_sources(cfdLib PRIVATE "")
target_include_directories(cfdLib PRIVATE "")
target_link_libraries(cfdLib PRIVATE "")
else()
message(FATAL_ERROR "Failed to create target cfdLib")
endif()
We first define a string LIB_TYPE
which can be either STATIC
or DYNAMIC
, and then on lines 3-7, we check which type was specified and create a library through the add_library() command accordingly. Since LIB_TYPE is a string (we see that we are using here STREQUAL
to check the equality of two strings), it is prone to typos, so we check on line 9 that the TARGET cfdLib
was previously defined. If it was, we add our source files, include directories, and anything else we may need for the library, but if the target doesn’t exist, then we abort with a FATAL_ERROR
message.
Looping
Perhaps not as frequently used as any of the above features, but CMake supports looping through lists as well. Remember our KEYWORDS
list, i.e.
set(KEYWORDS "cfd" "programming" "c++")
With this list defined, we can now loop over all of these keywords and print them one by one, e.g.
foreach(KEYWORD ${KEYWORDS})
message(STATUS "Keyword: ${KEYWORD}")
endforeach()
This will print all keywords, one after another, to the console, as you would expect. There isn’t really a great example where loops may become important, as typically, we would pass lists (like a collection of all source files) to a CMake function, which would then internally process that list. For completeness, though, you can look up the foreach documentation, which you’ll find is considerably shorter compared to other documentation I have linked to above.
Working with project-specific options
Once your project grows, you will find that you have to set a lot of user-specific variables, and sometimes they need to be set in accordance with your operating system (where we can check with the if/else statement syntax explored above which operating system we are on). This will result in a messy CMakeLists.txt
file, which really should only contain high-level build instruction, and then delegate the nitty-gritty details to additional CMakeLists.txt
files, which are then included in the top-level CMake file.
When it comes to variables, we could do that as well, but there is a convention to put all of your user-defined variables that can be set over the command line within a file called CMakeOptions.txt
, which is located together with the CMakeLists.txt
file in the root directory of the project. To make use of this additional file, we first have to include it somewhere before using it, i.e. we could modify the starter template CMakeLists.txt
file to
cmake_minimum_required(VERSION 3.23)
project(AdvancedCMakeExample)
include(CMakeOptions.txt)
### put your commands below for testing
Now, we can put our additional variables into the CMakeOptions.txt
file. Using variables defined before, we could have, for example:
option(ENABLE_TESTS "Should tests be build as part of the project?" ON)
set(LIB_TYPE DYNAMIC CACHE STRING "Type of library, either STATIC or DYNAMIC")
It is a convention to use option()
for Boolean values and set()
for strings. Typically, in the example above, we would probably use and option()
for the library build type as well and define a default, such as building the static version by default, which would then change the variable to
option(ENABLE_SHARED "If enabled, a shared (dynamic) library will be built, otherwise it defaults static" OFF)
This is a safer option (due to string comparison issues), and you will find that most projects configure their build using almost exclusively the option()
command. But I mention the set()
command here for completeness.
If you want to go through all options that are defined for a specific project and you are within the build folder, you can pass the additional -LAH flags to CMake, which will print all options to the console. So, if we type
cmake -LAH ..
we get a whole bunch of predefined variables printed to the screen, but also the ones we have defined, which conventionally are put at the bottom of the output so we can easily find them scrolling up the terminal:
// If enabled, a shared (dynamic) library will be built, otherwise it defaults static
ENABLE_SHARED:BOOL=OFF
// Should tests be build as part of the project?
ENABLE_TESTS:BOOL=ON
// Type of library, either STATIC or DYNAMIC
LIB_TYPE:STRING=DYNAMIC
To change any of these values on the command line, proceed as with any variable, i.e. to enable testing and the build of a dynamic library, you would specify
cmake -DENABLE_SHARED=ON -DENABLE_TESTS=ON ..
Function, Macros, and including additional files
Similar to how we dealt with variables (well, strings and Booleans), we can also add functions and macros, which behave like functions and macros in C/C++, but they have subtle differences in how they work and how they have to be used. If you aren’t familiar with these subtle differences, you can lose quite some time with debugging and trying to understand why your code won’t work. I always enjoy challenging ChatGPT and the like to see if they can understand these subtle differences, but they can’t.
Anyways, I think functions probably don’t require much of an introduction, they do exactly what you’d expect them to do, i.e. you can define some piece of code that you can then execute later when you need it, potentially several times. Here is a basic example:
function(hello NAME)
message(STATUS "Hello ${NAME}")
endfunction()
set(MY_NAME "Tom")
hello(${MY_NAME})
The function hello
is defined on lines 1-3, which takes one argument NAME
. It doesn’t do anything interesting other than printing the name back to the console with the word hello prepended. On line 5, we set a name to the variable MY_NAME
and then call the function with this variable.
So far, so good; let’s do the same with a macro; the code would change to
macro(hello NAME)
message(STATUS "Hello ${NAME}")
endmacro()
set(MY_NAME "Tom")
hello(${MY_NAME})
Looks pretty similar, doesn’t it? Well, yes, it is, only the keyword function
has changed to macro
(and endfunction
to endmacro
). It works in the same way, so why do we need two separate features doing the same thing? It all comes down to the scope, which is an important concept in programming general.
The scope defines what is visible to the compiler, interpreter, or, well, here, CMake. In a function, all variables that are defined within the function will remain local to that function. Once we leave the function (or the current scope, if we want to sound fancy), these variables will go out-of-scope (another fancy term, we can also say the variables will get deleted).
Macros, on the other hand, are just substitutions. So, in our example above, this is what CMake really sees:
macro(hello NAME)
message(STATUS "Hello ${NAME}")
endmacro()
set(MY_NAME "Tom")
message(STATUS "Hello Tom")
CMake has taken the code within the macro and substituted it on line 6, where the macro call occurred. Macros are, therefore, convenient ways of substituting code. As a result, macros don’t have their own local scope and thus if you pass variables to a macro and change them, they will also change the variables in the parent scope (where they were called from). Thus, macros may be useful if you want to return a value from a function, while functions should always be used where no return value is required.
Now, here comes the funny business; let’s look at the following code:
macro(hello NAME)
message(STATUS "Hello ${NAME}")
endmacro()
set(MY_NAME "Tom")
hello(${MY_NAME})
hello(MY_NAME)
Looking at lines 7 and 8, which are both legal syntax, what will they produce? Well, this is the output:
Hello Tom
Hello MY_NAME
On line 7, we have passed the value that is stored within the MY_NAME
variable, which evaluates to Tom
. But on line 8, we have passed only the name of the variable, not the value of it, to the macro. Notice that the variable name in the macro on line 1 is defined as NAME
, not MY_NAME
, they have different names on purpose, as you can see in the output provided above, NAME
is now changed to MY_NAME
. Again, we need to think about macros as simply text substitution.
When we call hello(MY_NAME)
on line 8, the macro will evaluate ${NAME}
to what was passed to the macro itself, which is MY_NAME
. Thus, the macro would look in the end
message(STATUS "Hello MY_NAME")
and this is exactly what is printed to the console. But what if we wanted to get the actual value of this variable? Well, we have already learned that this requires to put the variable within the ${}
syntax, so why not do that? Let’s change the macro to
macro(hello NAME)
message(STATUS "Hello ${${NAME}}")
endmacro()
Notice how we are accessing the content of the NAME
variable now twice. First, to substitute the NAME
variable itself to whatever was passed to the macro, and a second time, to get the value of what this variable holds. Doing that and calling the macro again with the following syntax:
set(MY_NAME "Tom")
hello(${MY_NAME})
hello(MY_NAME)
will produce the following output:
Hello
Hello Tom
Did you get why? The second line we understand, i.e. the NAME
variable is first expanded to MY_NAME
and then this, in turn is expanded to Tom
. But why is the call to hello(${MY_NAME})
now returning simply Hello
? Well, for the same reason. We are already expanding the variable MY_NAME
within the macro call, i.e. on line 3. The macro gets the value Tom
passed to it, and then it tries to access the value that is stored in the variable Tom
(which isn’t defined anywhere). An undefined variable will silently evaluate to an empty string, and this is what we are seeing.
Let’s put this into a more realistic use case and get back to the version bumping example we saw earlier. This is the code for both the function and the macro:
function(bumpVersionNumberFunction VERSION_MAJOR VERSION_MINOR)
math(EXPR VERSION_MINOR "${VERSION_MINOR} + 1")
message(STATUS "Version in function: ${VERSION_MAJOR}.${VERSION_MINOR}")
endfunction()
macro(bumpVersionNumberMacro VERSION_MAJOR VERSION_MINOR)
math(EXPR VERSION_MINOR "${${VERSION_MINOR}} + 1")
message(STATUS "Version in macro: ${${VERSION_MAJOR}}.${${VERSION_MINOR}}")
endmacro()
We have simply reused here the example from before where we are now incrementing the minor version of our project. We print the version after each increment, i.e. both in the function and in the macro. We can test this code with the following syntax:
bumpVersionNumberFunction(${VERSION_MAJOR} ${VERSION_MINOR})
message(STATUS "Version after function: ${VERSION_MAJOR}.${VERSION_MINOR}")
bumpVersionNumberMacro(VERSION_MAJOR VERSION_MINOR)
message(STATUS "Version after macro: ${VERSION_MAJOR}.${VERSION_MINOR}")
This will print the following output:
Version in function: 1.3
Version after function: 1.2
Version in macro: 1.3
Version after macro: 1.3
Within the function, we are increasing the minor version, and this is printed correctly to the screen, but because changes within functions are made to local (scope) variables after we leave the function, those changes are lost, and the variables from the parent scope retain their values. This is in contrast to what is happening in a macro, where changes that are made to local variables will overwrite variables in the parent scope if they have the same name, as we can see here.
Typically, we use functions to do one specific part of the build stage, e.g. deal with setting up tests, finding dependencies/libraries (or even downloading, compiling, and installing them for the current project if we so wish), and it may make sense to keep them in their own separate CMake file. As we have seen with the CMakeOptions.txt file, we can simply include additional files with the include() statement and the convention is that additional CMake files have the extension cmake
.
So let’s do that. Create a file called functions.cmake
that is in the same directory as the CMakeLists.txt
file. Then, simply include that code with
include(functions.cmake)
This is similar to a macro in that the code is essentially copied to where the include()
statement is being called. If you are working on a larger project, you would typically create an additional folder called cmake
where you want to store all additional CMake files. In that case, you would have to provide the path as well to the include()
statement, e.g. include(cmake/functions.cmake)
.
Variable substitution in templates and config files
This is a rather nice feature in CMake. When you are working on a project, and you want this to be used by anyone, chances are you will end up generating a library with some form of header file, including all required header files for your library. This is quite handy but also means that every time you update the library (add a new header), you’ll have to update this single header to include the file as well. This can be error-prone, so CMake allows us to generate a new header file based on a template automatically.
These template files (or configure files, as they are more commonly known, though that can be misleading in my view), typically have a file ending of *.in
. These files will have CMake-specific syntax in them, which would not compile if we used the *.in file itself. During the configuration, CMake will look into this file (if we tell it to) and replace any variables from the CMake scope within this file. CMake will then store a new file, typically using the same name and dropping the *.in extension, though we can change that if we want to.
So let’s look at a simple example, again (ab)using our version numbers we have specified in our CMake file. Let’s say that we want to display the current version in the header file that we want users to use if they are using our library. Then, we can create a file called libHeader.hpp.in
(in the root folder, though for larger projects, I would recommend putting these files within a config/
folder) with the following content:
#pragma once
#define VERSION_MAJOR @VERSION_MAJOR@
#define VERSION_MINOR @VERSION_MINOR@
// any additional code goes here
Here, we define the VERSION_MAJOR
and VERSION_MINOR
using the @cmake-variable-name@
syntax. When CMake processes this file, it will look for variables that are named VERSION_MAJOR
and VERSION_MINOR
, respectively. So, let’s put together a minimal example:
set(VERSION_MAJOR 1 CACHE STRING "Major version")
set(VERSION_MINOR 2 CACHE STRING "Minor version")
configure_file(libHeader.hpp.in libHeader.hpp)
The call on line 3 to configure_file()
expects two arguments, the first being the configure file we want to use and the second being the output file we want to generate. The output file will be placed within the build/
directory. Executing CMake now again, we will see that the file build/libHeader.hpp
was generated, and that we have the following content in there:
#pragma once
#define VERSION_MAJOR 1
#define VERSION_MINOR 2
// any additional code goes here
You can see how this can quickly become a powerful tool, especially if you combine it with information that is automatically generated by CMake (like the version number). If you are working on your own library, chances are that you will use this feature. But even if you don’t use a compiled language such as C/C++, you can still use this feature to automatically generate source files for any language where you want to replace lines of code automatically based on some input (of course, for the nerds, sed is just as good).
Generator expressions
We haven’t quite yet looked into the control flow of CMake itself. But let’s look at the simplest CMake file of them all, i.e. the one we have used thus far to test some CMake specifics and which we saw already at the beginning of this article. I’ll repeat here for completeness:
cmake_minimum_required(VERSION 3.23)
project(AdvancedCMakeExample)
Let’s change our CMakeLists.txt
file back to this (if you have followed along and you have some commands in your file). Now, let’s run CMake on it from within the build/
folder, e.g. calling cmake ..
We will get the following output (I have removed all files within the build/
folder, the output will vary if you already have some files present here):
-- Building for: Visual Studio 17 2022
-- Selecting Windows SDK version 10.0.22621.0 to target Windows 10.0.22631.
-- The CXX compiler identification is MSVC 19.37.32825.0
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: C:/Program Files/Microsoft Visual Studio/2022/Community/VC/Tools/MSVC/14.37.32822/bin/Hostx64/x64/cl.exe - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done (1.5s)
-- Generating done (0.0s)
We discussed before that there are different stages in CMake, and thus far, we have only really looked at the configuration and build step. But after taking a close look at the output, we see that there is a configuration step (finished on line 9), as well as a generation step (finished on line 10). The generation step is what we will look at next in this section.
To understand why we need this, it is worthwhile looking at the configuration step. We already saw the variable CMAKE_BUILD_TYPE
in the previous article, which either evaluated to Debug
or Release
. This is a variable set at the configuration stage, i.e., before the build stage. But then we also saw the --config Debug
and --config Release
flag during our build stage, i.e. when we called cmake --build . --config Debug
or cmake --build . --config Release
.
I said that we only ever need to specify one of these configurations, i.e. either CMAKE_BUILD_TYPE or –config, and this would depend on the build system. The reason is that some build systems only know at the build stage which configuration should be used, and this can change, i.e. we can easily build either a Debug or Release binary. My convention was to specify both so that we didn’t have to accidentally set it at the wrong time, but we didn’t really go further into this.
The problem here is that once the configurations step is done, we can’t change variables anymore, and so if the configuration changes during the build step, CMake has no way of reacting to this change (all its variables are already set). Imaging you want to change compiler flags based on your current configuration. If that is only available at the build stage, then CMake has no chance of capturing this. This is where generator expression comes, which allows you to evaluate variables at different stages of the CMake toolchain.
Generator expressions are a vast topic and probably could be discussed in their own article, given the richness of the feature. However, I think this is beside the point; there are two common use cases, and if you understand both, you’ll have covered most use cases. Let’s go through them in turn
Conditional variable evaluations
The basic use case for generator expressions is to check whether a given condition is true or not. If it is true, then a given value will be used; if it is false, an empty string will be returned. The generator expressions can be identified by their $<> syntax, which is in contrast to the ${} the syntax we saw for expanding variables. In its most basic form, we could have the following expression:
$<condition-to-check:value-if-condition-is-true>
We can nest generator expressions as well, which makes them rather powerful. So, for example, we could use
$<$<check-if-true>:if-true-set-value>
A common use case here is to check if the current build target is set to Debug
, if that is the case, we may want to enable coverage to test how much of our tests are covering our codebase. This could be achieved with the following generator expression:
add_library(cfdLib)
if (UNIX)
target_link_options(cfdLib
PRIVATE $<$<CONFIG:Debug>:--coverage>
)
endif(UNIX)
Here, we are first defining a library called cfdLib
which will get the --coverage
linker flag added to its compiler flags via the target_link_options()
function, if the build type (CONFIG
) is set to Debug
. If it is not, then it will evaluate to an empty string and nothing gets added to the compiler flags.
One question you may ask yourself then is, how can I know what conditions I can check? (i.e. how do I know I have to use CONFIG
to check for the build type)? Well, unfortunately, this requires a trip to the generator expression documentation, which contains quite a lot of information (and you’ll understand why I say this could be discussed in its own article).
The good news is, though, that most of the time, generator expressions are used for very similar functionalities. If you have a particular CMake problem and search for a solution, you’ll likely stumble across a solution that you can copy like-for-like into your own script. So, it is important to identify when generator expressions are used and why, but most solutions can likely be found online already as boilerplate code.
Stage-specific include directories
The second most common use case for generator expressions is to set include paths depending on the current stage. If you are building a project, you probably want to use the included paths within your project (and CMake works with absolute paths; even if you work with relative paths, CMake will expand them for you). This means that if you develop a library and install it after the build has finished, you may get a header that includes directories that point to your local development folder and not to where the library is installed.
This is another use case where generator expression can help us to set the correct path. Sticking with the cfdLib
example shown above, this is what our code may look like
add_library(cfdLib)
target_include_directories(cfdLib
PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
)
Here, we define two different include directories for the target_include_directories()
function. If we are building the project locally for testing, then we will include the current folder as an included directory (e.g. ${CMAKE_CURRENT_SOURCE_DIR}). If, however, we have built the library and then want to install it in some different location on our system, then we will replace the current root directory with the root directory of the installation directory (e.g. ${CMAKE_INSTALL_INCLUDEDIR}
)
I should point out that we have generator expressions in the first place due to CMake’s poor design. If issues with the various build stages were foreseen at the inception of CMake, then using a CMakeCache.txt file that doesn’t change after the configuration and where all variables are hardcoded (e.g. compiler options) would have been flagged as a bad idea. It would have made more sense to allow these variables to change but put some constraint on it so that the build process doesn’t easily fail.
Using absolute paths also comes with its own set of issues, and we have seen how we can use generator expression to fix an ideologically broken design philosophy. It is what it is, there are times where I am not particularly pleased with CMake, but its popularity means that you can’t avoid it (and probably just have to deal with its quirky way of doing things every now and then).
Speaking of a broken design philosophy, let’s look at our final point in this article: Policies.
Policies
Let’s look at what CMake themselves are saying about policies:
Occasionally a new feature or change is made to CMake that is not fully backwards compatible with older versions. This can create problems when someone tries to use an old CMakeLists file with a new version of CMake. To help both end users and developers through such issues, we have introduced cmake-policies. Policies are a mechanism for helping improve backwards compatibility and tracking compatibility issues between different versions of CMake.
CMake documentation
On the surface, this sounds like a good idea, i.e. I applaud any developer that takes backward compatibility seriously. The problem is that most of the time, I have seen people use Policies to avoid CMake using newer features that were implemented to fix some of the old broken design philosophy.
I mentioned previously that since version 3.0, we have entered the modern CMake era, where the workflow has substantially changed in CMake. Projects should not be encouraged to write build scripts that use the old CMake way of doing things. However, legacy projects still use CMake the way it was done prior to CMake 3.0, and they may want to update some parts of their build script to use new features introduced in version 3.x without having to rewrite the entire build script.
In this case, CMake allows you to disable newer behaviour from CMake 3.x, so that you can still use your old CMake workflow. In other cases, CMake has introduced features that have then later shown to produce unwanted side-effects in some cases, but not all, and so to tackle these niche cases, policies can help to tune the behaviour of a newly introduced feature.
This is all disguised as backward compatibility, but I’d argue this is more of a dirty fix for a broken design. The good news is, if you are just learning CMake now, chances are you are not going to write your CMake files the way we did before version 3.x, and so you likely never have to use policies. But I wanted to cover that topic here as well so that you can identify them in other build scripts and know why they are there.
The basic syntax of the policy command is the following:
cmake_policy(SET <policy-name> <NEW|OLD>)
The cmake_policy()
command will set a specific policy to either NEW
or OLD
. When a new feature is introduced that breaks backward compatibility, then we have to specify the policy name and the keyword NEW
. If, however, we want to keep the old behaviour and don’t use the latest feature, then we can set this to OLD
.
To get an idea of all the policies available, you can check the list of policies. Let’s pick one and see how the syntax would look. I am looking at the list and have selected policy CMP0165. From the description, we can see that we can call enable_language()
before the call to project()
. Doing so means that some properties of the language may not have been set correctly. Thus, this policy requires users to call enable_language()
after a call to project()
was made so that default values were set correctly.
If, for some reason, we really need to call enable_language()
before the project()
command, then we can deactivate this policy with a call to:
cmake_policy(SET CMP0165 OLD)
If, however, we want to explicitly use this policy, then we would simply change OLD
to NEW
here. The reason we may want to use OLD
for a policy should mainly be used for testing, but in production code, we should never have old policies in our CMakeLists.txt file. We may want to use NEW on policies if we are getting a warning printed on the screen, and we want to silence that warning.
Generally, as long as you follow modern CMake rules, you probably won’t have the need to use policies at all. You may end up with a specific use case where it may be important for you to turn off specific policies, though in this case, you should ask yourself if you can change the build script so that you don’t have to mess with policies in the first place.
Summary
This article, together with our introduction to CMake in the previous article, presents most of the use cases that you will encounter when dealing with CMake. If you understood both of these and followed the examples, you will have gained a solid understanding of CMake, and everything that follows in the remaining articles will be simply additional features to help you automate a specific part of the build toolchain.
Try to make it a habit to use CMake from now on for any new project that you develop. You saw that even for non-compiled projects, CMake can provide some pretty useful tools such as variable substitution in configure files, that may be useful if you are developing code that does not need to be compiled. Once you get an intuition for CMake, writing your build files becomes second nature and you will see that CMake offers and endless pit of functionality and you will likely never reach the limit of this tool.
Tom-Robin Teschner is a senior lecturer in computational fluid dynamics and course director for the MSc in computational fluid dynamics and the MSc in aerospace computational engineering at Cranfield University.