Step 2: CMake Language Fundamentals¶
In the previous step we rushed through and handwaved several aspects of the
CMake language which is used within CMakeLists.txt
in order to get useful,
building programs as soon as possible. However, in the wild we encounter
a great deal more complexity than simply describing lists of source and
header files.
To deal with this complexity CMake provides a Turing-complete domain-specific
language for describing the process of building software. Understanding the
fundamentals of this language will be necessary as we write more complex
CMLs and other CMake files. The language is formally known as
"CMake Language
", or more colloquially as CMakeLang.
Note
The CMake Language is not well suited to describing things which are not related to building software. While it has some features for general purpose use, developers should use caution when solving problems not directly related to their build in CMake Language.
Oftentimes the correct answer is to write a tool in a general purpose programming language which solves the problem, and teach CMake how to invoke that tool as part of the build process. Code generation, cryptographic signature utilities, and even ray-tracers have been written in CMake Language, but this is not a recommended practice.
Because we want to fully explore the language features, this step is an
exception to the tutorial sequencing. It neither builds on Step1
, nor is the
starting point for Step3
. This will be a sandbox to explore language
features without building any software. We'll pick back up with the Tutorial
program in Step3
.
Note
This tutorial endeavors to demonstrate best practices and solutions to real
problems. However, for this one step we're going to be re-implementing some
built-in CMake functions. In "real life", do not write your own
list(APPEND)
.
Background¶
The only fundamental types in CMakeLang are strings and lists. Every object in CMake is a string, and lists are themselves strings which contain semicolons as separators. Any command which appears to operate on something other than a string, whether they be booleans, numbers, JSON objects, or otherwise, is in fact consuming a string, doing some internal conversion logic (in a language other than CMakeLang), and then converting back to a string for any potential output.
We can create a variable, which is to say a name for a string, using the
set()
command.
set(var "World!")
A variable's value can be accessed using brace expansion, for example if we want
to use the message()
command to print the string named by var
.
set(var "World!")
message("Hello ${var}")
$ cmake -P CMakeLists.txt
Hello World!
Note
cmake -P
is called "script mode", it informs CMake this file is not
intended to have a project()
command. We're not building any
software, instead using CMake only as a command interpreter.
Because CMakeLang has only strings, conditionals are entirely by convention of which strings are considered true and which are considered false. These are supposed to be intuitive, "True", "On", "Yes", and (strings representing) non-zero numbers are truthy, while "False" "Off", "No", "0", "Ignore", "NotFound", and the empty string are all considered false.
However, some of the rules are more complex than that, so taking some time
to consult the if()
documentation on expressions is worthwhile. It's
recommended to stick to a single pair for a given context, such as
"True"/"False" or "On"/"Off".
As mentioned, lists are strings containing semicolons. The list()
command is useful for manipulating these, and many structures within CMake
expect to operate with this convention. As an example, we can use the
foreach()
command to iterate over a list.
set(stooges "Moe;Larry")
list(APPEND stooges "Curly")
message("Stooges contains: ${stooges}")
foreach(stooge IN LISTS stooges)
message("Hello, ${stooge}")
endforeach()
$ cmake -P CMakeLists.txt
Stooges contains: Moe;Larry;Curly
Hello, Moe
Hello, Larry
Hello, Curly
Exercise 1 - Macros, Functions, and Lists¶
CMake allows us to craft our own functions and macros. This can be very helpful
when constructing lots of similar targets, like tests, for which we will want
to call similar sets of commands over and over again. We do so with
function()
and macro()
.
macro(MyMacro MacroArgument)
message("${MacroArgument}\n\t\tFrom Macro")
endmacro()
function(MyFunc FuncArgument)
MyMacro("${FuncArgument}\n\tFrom Function")
endfunction()
MyFunc("From TopLevel")
$ cmake -P CMakeLists.txt
From TopLevel
From Function
From Macro
Like with many languages, the difference between functions and macros is one
of scope. In CMakeLang, both function()
and macro()
can "see"
all the variables created in all the frames above them. However, a
macro()
acts semantically like a text replacement, similar to C/C++
macros, so any side effects the macro creates are visible in their calling
context. If we create or change a variable in a macro, the caller will see the
change.
function()
creates its own variable scope, so side effects are not
visible to the caller. In order to propagate changes to the parent which called
the function, we must use set(<var> <value> PARENT_SCOPE)
, which works the
same as set()
but for variables belonging to the caller's context.
Note
In CMake 3.25, the return(PROPAGATE)
option was added, which
works the same as set(PARENT_SCOPE)
but provides slightly better
ergonomics.
While not necessary for this exercise, it bears mentioning that macro()
and function()
both support variadic arguments via the ARGV
variable, a list containing all arguments passed to the command, and the
ARGN
variable, containing all arguments past the last expected argument.
We're not going to build any targets in this exercise, so instead we'll
construct our own version of list(APPEND)
, which adds a value to a
list.
Goal¶
Implement a macro and a function which append a value to a list, without using
the list(APPEND)
command.
The desired usage of these commands is as follows:
set(Letters "Alpha;Beta")
MacroAppend(Letters "Gamma")
message("Letters contains: ${Letters}")
$ cmake -P Exercise1.cmake
Letters contains: Alpha;Beta;Gamma
Note
The extension for these exercises is .cmake
, that's the standard extension
for CMakeLang files when not contained in a CMakeLists.txt
Helpful Resources¶
Files to Edit¶
Exercise1.cmake
Getting Started¶
The source code for Exercise1.cmake
is provided in the
Help/guide/tutorial/Step2
directory. It contains tests to verify the
append behavior described above.
Note
You're not expected to handle the case of an empty or undefined list to append to. However, as a bonus, the case is tested if you want to try out your understanding of CMakeLang conditionals.
Complete TODO 1
and TODO 2
.
Build and Run¶
We're going to use script mode to run these exercises. First navigate to the
Help/guide/tutorial/Step2
folder then you can run the code with:
cmake -P Exercise1.cmake
The script will report if the commands were implemented correctly.
Solution¶
This problem relies on an understanding of the mechanisms of CMake variables. CMake variables are names for strings; or put another way, a CMake variable is itself a string which can brace expand into a different string.
This leads to a common pattern in CMake code where functions and macros aren't
passed values, but rather, they are passed the names of variables which contain
those values. Thus ListVar
does not contain the value of the list we need
to append to, it contains the name of a list, which contains the value we
need to append to.
When expanding the variable with ${ListVar}
, we will get the name of the
list. If we expand that name with ${${ListVar}}
, we will get the values
the list contains.
To implement MacroAppend
, we need only combine this understanding of
ListVar
with our knowledge of the set()
command.
TODO 1: Click to show/hide answer
macro(MacroAppend ListVar Value)
set(${ListVar} "${${ListVar}};${Value}")
endmacro()
We don't need to worry about scope here, because a macro operates in the same scope as its parent.
FuncAppend
is almost identical, in fact it could be implemented in the
same one liner but with an added PARENT_SCOPE
, but the instructions ask
us to implement it in terms of MacroAppend
.
TODO 2: Click to show/hide answer
function(FuncAppend ListVar Value)
MacroAppend(${ListVar} ${Value})
set(${ListVar} "${${ListVar}}" PARENT_SCOPE)
endfunction()
MacroAppend
transforms ListVar
for us, but it won't propagate the result
to the parent scope. Because this is a function, we need to do so ourselves
with set(PARENT_SCOPE)
.
Exercise 2 - Conditionals and Loops¶
The two most common flow control elements in any structured programming
language are conditionals and their close sibling loops. CMakeLang is no
different. As previously mentioned, the truthiness of a given CMake string is a
convention established by the if()
command.
When given a string, if()
will first check if it is one of the known
constant values previously discussed. If the string isn't one of those values
the command assumes it is a variable, and checks the brace-expanded contents of
that variable to determine the result of the conditional.
if(True)
message("Constant Value: True")
else()
message("Constant Value: False")
endif()
if(ConditionalValue)
message("Undefined Variable: True")
else()
message("Undefined Variable: False")
endif()
set(ConditionalValue True)
if(ConditionalValue)
message("Defined Variable: True")
else()
message("Defined Variable: False")
endif()
$ cmake -P ConditionalValue.cmake
Constant Value: True
Undefined Variable: False
Defined Variable: True
Note
This is a good a time as any to discuss quoting in CMake. All objects in
CMake are strings, thus the double quote, "
, is often unnecessary.
CMake knows the object is a string, everything is a string.
However, it is needed in some contexts. Strings containing whitespace require double quotes, else they are treated like lists; CMake will concatenate the elements together with semicolons. The reverse is also true, when brace-expanding lists it is necessary to do so inside quotes if we want to preserve the semicolons. Otherwise CMake will expand the list items into space-separate strings.
A handful of commands, such as if()
, recognize the difference
between quoted and unquoted strings. if()
will only check that the
given string represents a variable when the string is unquoted.
Finally, if()
provides several useful comparison modes such as
STREQUAL
for string matching, DEFINED
for checking the existence of
a variable, and MATCHES
for regular expression checks. It also supports the
typical logical operators, NOT
, AND
, and OR
.
In addition to conditionals CMake provides two loop structures,
while()
, which follows the same rules as if()
for checking a
loop variable, and the more useful foreach()
, which iterates over lists
of strings and was demonstrated in the Background section.
For this exercise, we're going to use loops and conditionals to solve some
simple problems. We'll be using the aforementioned ARGN
variable from
function()
as the list to operate on.
Goal¶
Loop over a list, and return all the strings containing the string Foo
.
Note
Those who read the command documentation will be aware that this is
list(FILTER)
, resist the temptation to use it.
Helpful Resources¶
Files to Edit¶
Exercise2.cmake
Getting Started¶
The source code for Exercise2.cmake
is provided in the Help/guide/tutorial/Step2
directory. It contains tests to verify the append behavior described above.
Note
You should use the list(APPEND)
command this time to collect your
final result into a list. The input can be consumed from the ARGN
variable
of the provided function.
Complete TODO 3
.
Build and Run¶
Navigate to the Help/guide/tutorial/Step2
folder then you can run the code with:
cmake -P Exercise2.cmake
The script will report if the FilterFoo
function was implemented correctly.
Solution¶
We need to do three things, loop over the ARGN
list, check if a given
item in that list matches "Foo"
, and if so append it to the OutVar
list.
While there are a couple ways we could invoke foreach()
, the
recommended way is to allow the command to do the variable expansion for us
via IN LISTS
to access the ARGN
list items.
The if()
comparison we need is MATCHES
which will check if
"FOO"
exists in the item. All that remains is to append the item to the
OutVar
list. The trickiest part is remembering that OutVar
names a
list, it is not the list itself, so we need to access it via ${OutVar}
.
TODO 3: Click to show/hide answer
function(FilterFoo OutVar)
foreach(item IN LISTS ARGN)
if(item MATCHES Foo)
list(APPEND ${OutVar} ${item})
endif()
endforeach()
set(${OutVar} ${${OutVar}} PARENT_SCOPE)
endfunction()
Exercise 3 - Organizing with Include¶
We have already discussed how to incorporate subdirectories containing their
own CMLs with add_subdirectory()
. In later steps we will explore
the various way CMake code can be packaged and shared across projects.
However for small CMake functions and utilities, it is often beneficial for them
to live in their own .cmake
files outside the project CMLs and separate
from the rest of the build system. This allows for separation of concerns,
removing the project-specific elements from the utilities we are using to
describe them.
To incorporate these separate .cmake
files into our project, we use the
include()
command. This command immediately begins interpreting the
contents of the include()
'd file in the scope of the parent CML. It
is as if the entire file were being called as a macro.
Traditionally, these kinds of .cmake
files live in a folder named "cmake"
inside the project root. For this exercise, we'll use the Step2
folder instead.
Goal¶
Use the functions from Exercises 1 and 2 to build and filter our own list of items.
Helpful Resources¶
Files to Edit¶
Exercise3.cmake
Getting Started¶
The source code for Exercise3.cmake
is provided in the Help/guide/tutorial/Step2
directory. It contains tests to verify the correct usage of our functions
from the previous two exercises.
Note
Actually it reuses tests from Exercise2.cmake, reusable code is good for everyone.
Complete TODO 4
through TODO 7
.
Build and Run¶
Navigate to the Help/guide/tutorial/Step2
folder then you can run the code with:
cmake -P Exercise3.cmake
The script will report if the functions were invoked and composed correctly.
Solution¶
The include()
command will interpret the included file completely,
including the tests from the first two exercises. We don't want to run these
tests again. Thanks to some forethought, these files check a variable called
SKIP_TESTS
prior to running their tests, setting this to True
will
get us the behavior we want.
TODO 4: Click to show/hide answer
set(SKIP_TESTS True)
Now we're ready to include()
the previous exercises to grab their
functions.
TODO 5: Click to show/hide answer
include(Exercise1.cmake)
include(Exercise2.cmake)
Now that FuncAppend
is available to us, we can use it to append new elements
to the InList
.
TODO 6: Click to show/hide answer
FuncAppend(InList FooBaz)
FuncAppend(InList QuxBaz)
Finally, we can use FilterFoo
to filter the full list. The tricky part to
remember here is that our FilterFoo
wants to operate on list values via
ARGN
, so we need to expand the InList
when we call FilterFoo
.
TODO 7: Click to show/hide answer
FilterFoo(OutList ${InList})