CMake Conditionals and Loops
Transform your static build descriptions into dynamic scripts with CMake's control flow commands, if() and foreach().
We've now established a solid foundation in the CMake language. We know how to use variables to store data, how to create persistent options with the cache, and how to manage collections of files using lists.
So far, our CMakeLists.txt
files have been straightforward sequences of commands, executed from top to bottom. But what if we need our build script to be smarter? What if we want it to make decisions, like "only add this source file if we're building on Windows," or to perform repetitive tasks, like "apply this compiler flag to every target in this list"?
To do this, we need to introduce control flow. In this lesson, we'll explore the two most important control flow structures in CMake:
- Conditionals using the
if()
/elseif()
/else()
/endif()
commands to execute blocks of code selectively. - Loops using the
foreach()
/endforeach()
commands to iterate over lists and perform actions on each item.
These commands are what will elevate your CMakeLists.txt
from a static description to a dynamic, intelligent script.
Conditional Logic with if()
The if()
command is the cornerstone of decision-making in CMake. It allows you to execute a block of commands only if a certain condition is true. The basic structure looks like this:
if(CONDITION)
# Commands to execute if CONDITION is true
endif()
There is a a wide variety of conditions it can evaluate. Let's explore the most common ones.
Evaluating Variables and Constants
The simplest condition is just a variable or a constant. CMake has a specific set of values it considers to be "true" or "false".
- True:
1
,ON
,YES
,TRUE
,Y
, or any non-zero number. - False:
0
,OFF
,NO
,FALSE
,N
,IGNORE
,NOTFOUND
, an empty string, or a string ending inNOTFOUND
.
Let's revisit the SAY_GOODBYE
option we created in the last lesson. Our app/CMakeLists.txt
already uses some if()
blocks to check this option:
app/CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
option(
SAY_GOODBYE
"Should the program say goodbye before exiting"
OFF
)
set(GREETER_SOURCES src/main.cpp)
if(SAY_GOODBYE)
message(STATUS "Adding goodbye.cpp and SAY_GOODBYE definition")
list(APPEND GREETER_SOURCES src/goodbye.cpp)
endif()
add_executable(GreeterApp ${GREETER_SOURCES})
target_compile_features(GreeterApp PUBLIC cxx_std_20)
target_link_libraries(GreeterApp GreeterLib)
if(SAY_GOODBYE)
message(STATUS "Defining SAY_GOODBYE")
target_compile_definitions(GreeterLib PUBLIC SAY_GOODBYE)
endif()
When a user configures the project with -DSAY_GOODBYE=ON
, the variable SAY_GOODBYE
holds the value ON
. The if(SAY_GOODBYE)
condition evaluates to true, and the commands inside the block are executed. If SAY_GOODBYE
is OFF
, the condition is false, and the block is skipped.
Comparing if(VAR)
vs. if(DEFINED VAR)
There's a subtle but important distinction between checking a variable's value and checking its existence.
if(VAR)
checks if the value ofVAR
is a "true" constant. IfVAR
is a "false" constant or not defined at all, this condition is false.if(DEFINED VAR)
checks only if a variable with the nameVAR
exists. It doesn't care what the value is - if the variable exists, this condition will be true.
You should use if(DEFINED VAR)
when you need to know if a variable has been set at all, regardless of its value. For boolean options, if(VAR)
is more direct.
Using elseif()
and else()
For more complex logic, you can extend if()
with elseif()
and else()
blocks, just like in C++.
if(CONDITION_A)
# Do stuff for A
elseif(CONDITION_B)
# Do stuff for B
else()
# Do stuff if neither A nor B is true
endif()
Comparisons and Logic
The if()
command can also perform comparisons and use logical operators.
Numeric Comparison
EQUAL
, LESS
, LESS_EQUAL
, GREATER
, and GREATER_EQUAL
interpret our variable as a number, and then behaves in the same way as the C++ operators ==
, <
, <=
, >
, and >=
.
String Comparison
Variations of these operators that perform string-based comparisons are available in the form of STREQUAL
, STRLESS
, STRLESS_EQUAL
, STRGREATER
, and STRGREATER_EQUAL
. These perform lexographic (alphabetic) comparisons - for example, "Apple" STRLESS "Banana"
is true, as "Apple" is before "Banana" alphabetically.
Version Comparison
For strings representing versions, such as "2.1.9" and "3.2", the operators VERSION_EQUAL
, VERSION_LESS
, VERSION_LESS_EQUAL
, VERSION_GREATER
, and VERSION_GREATER_EQUAL
are available. "2.9" VERSION_LESS "2.10"
is true, as "2.9" is an older version than "2.10".
Logical Operators, Parentheses, and Inversion
We have the usual suite of boolean tools for creating compound expressions:
AND
andOR
can be used to combine booleans to create more complex expressions- We can use
()
for grouping expressions to control the order of operations - The
NOT
keyword behaves in the same way as the C++!
operator, inverting a true expression to be false, or false to be true.
Here's a more complex example that combines a few of these techniques:
set(MODE "Debug")
set(OPTIMIZATION_LEVEL 2)
if((MODE STREQUAL "Debug") AND (OPTIMIZATION_LEVEL LESS 3))
message(STATUS "Debug mode with low optimization.")
endif()
Practical Example: Platform-Specific Code
One of the most common uses of if()
is to handle platform differences. CMake provides several variables that identify the operating system, such as WIN32
, APPLE
, and UNIX
.
Let's imagine our GreeterLib
needs a platform-specific implementation file to get some system information. Our project structure might look like this:
greeter/
├─ include/
│ └─ greeter/
│ └─ Greeter.h
├─ src/
│ ├─ Greeter.cpp
│ ├─ platform_win.cpp
│ └─ platform_nix.cpp
└─ CMakeLists.txt
Our greeter/CMakeLists.txt
can use if()
to select the correct source file at configure time:
greeter/CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
# Start with the common source files
set(LIB_SOURCES src/Greeter.cpp)
# Conditionally add the platform-specific file
if(WIN32)
message(STATUS "Using platform_win.cpp")
list(APPEND LIB_SOURCES src/platform_win.cpp)
else()
message(STATUS "Using platform_nix.cpp")
list(APPEND LIB_SOURCES src/platform_nix.cpp)
endif()
add_library(GreeterLib ${LIB_SOURCES})
target_include_directories(GreeterLib PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)
target_compile_features(GreeterLib PUBLIC cxx_std_20)
cmake ..
-- Using platform_win.cpp
-- Configuring done (0.1s)
-- Generating done (0.1s)
-- Build files have been written to: D:/projects/cmake/build
This is the essence of writing portable build scripts. You describe the components you need for each platform, and if()
lets you assemble them correctly. When you configure this project on Windows, WIN32
is true, and platform_win.cpp
is added. On Linux or macOS, WIN32
is false, and platform_nix.cpp
is added instead.
Checking List Membership with IN_LIST
A particularly useful comparison operator is IN_LIST
. It provides a clean and readable way to check if a specific item is present within a list. This is often more convenient than using list(FIND)
and checking the index.
The syntax is straightforward:
if(<item> IN_LIST <list_variable>)
# ...
endif()
Let's see a quick example:
set(ADMIN_USERS "alice" "bob" "charlie")
set(CURRENT_USER "bob")
if(CURRENT_USER IN_LIST ADMIN_USERS)
message(STATUS "User '${CURRENT_USER}' has admin privileges.")
else()
message(STATUS "User '${CURRENT_USER}' is a standard user.")
endif()
When configured, this script will check if the value of CURRENT_USER
exists in the ADMIN_USERS
list and print the appropriate message.
-- User 'bob' has admin privileges.
Looping with foreach()
While if()
lets us make decisions, foreach()
lets us perform repetitive tasks. It's the primary looping construct in CMake and is almost always used to iterate over the elements of a list.
Basic Syntax
The most common form of the foreach()
loop is:
foreach(loop_variable IN LISTS list_variable)
# Commands to execute for each item
# Use ${loop_variable} to access the current item
endforeach()
Let's see it in action with a simple list of strings:
set(FRUITS Apples Bananas Cherries)
foreach(fruit IN LISTS FRUITS)
message(STATUS "I am eating ${fruit}!")
endforeach()
When you run this, CMake will execute the message()
command three times, each time with the fruit
variable set to the next item in the FRUITS
list.
-- I am eating Apples!
-- I am eating Bananas!
-- I am eating Cherries!
Multiple Lists
As the IN LISTS
syntax might suggest, we can provide multiple lists to this construct:
set(FRUITS Apples Bananas Cherries)
set(VEGETABLES Onions Carrots)
foreach(food IN LISTS FRUITS VEGETABLES)
message(STATUS "I am eating ${food}!")
endforeach()
Practical Example: Applying Settings to Multiple Targets
A great use case for foreach()
is to apply a common configuration to multiple targets without repeating yourself. This follows the DRY (Don't Repeat Yourself) principle and makes your build scripts much more maintainable.
In the following example, we define a TARGETS_TO_UPDATE
list with the names of targets we want to apply some common configuration to. We apply this configuration using a foreach()
block:
CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(Greeter VERSION "1.0")
add_subdirectory(app)
add_subdirectory(greeter)
# Define a list of all targets in our project
set(TARGETS_TO_UPDATE
GreeterLib
GreeterApp
)
# Loop over the list and apply common settings
foreach(target IN LISTS TARGETS_TO_UPDATE)
message(STATUS "Applying common settings to ${target}")
target_compile_features(${target} PUBLIC cxx_std_20)
endforeach()
-- Applying common settings to GreeterLib
-- Applying common settings to GreeterApp
With this structure, if we add a new target to our project, we only need to add its name to the TARGETS_TO_UPDATE
list. It will automatically receive all the common settings, ensuring consistency and saving us from copy-pasting code.
A small flaw here is that we need to know the names of our targets (GreeterLib
and GreeterApp
) even though those are set in the deeper CMakeList.txt
files we're loading using add_subdirectory()
.
We'll soon learn techniques that will unlock better ways of doing this.
Other foreach()
Variants
While iterating over lists is the most common use case, foreach()
has other forms that can be useful for different scenarios.
Iterating an Inline List
This variant allows you to iterate directly over a set of items provided as arguments to the command, without needing to create a list variable first. It's useful for short, hardcoded collections.
# No list variable is created beforehand
foreach(fruit IN ITEMS "Apple" "Banana" "Cherry")
message(STATUS "Fruit of the day: ${fruit}")
endforeach()
This is functionally equivalent to creating a list first (set(FRUITS "A" "B" "C")
) but can be more concise for simple cases.
-- Fruit of the day: Apple
-- Fruit of the day: Banana
-- Fruit of the day: Cherry
Iterating from 0
to stop
This foreach(i RANGE n)
form iterates over integers from 0
up to (and including) the stop
value. It's a convenient way to create a classic for
loop. Our variable (which we've called i
, in this example) adopts the current iteration number:
# Loop from 0 to 4
foreach(i RANGE 4)
message(STATUS "Creating temporary file: temp_${i}.txt")
endforeach()
This will execute the loop body five times, with the i
variable taking on the values 0, 1, 2, 3, and 4.
-- Creating temporary file: temp_0.txt
-- Creating temporary file: temp_1.txt
-- Creating temporary file: temp_2.txt
-- Creating temporary file: temp_3.txt
-- Creating temporary file: temp_4.txt
Iterating from start
to stop
The foreach(i RANGE start stop step)
syntax is the most flexible range-based loop, allowing you to specify a starting value and an ending value. We can optionally provide a step value to set how much our variable should be incremented on each iteration, or we can omit that argument to use the default value of 1
.
# Loop from 2 to 10, incrementing by 2 each time
foreach(num RANGE 2 10 2)
message(STATUS "Even number: ${num}")
endforeach()
This loop will execute with num
having the values 2, 4, 6, 8, and 10.
-- Even number: 2
-- Even number: 4
-- Even number: 6
-- Even number: 8
-- Even number: 10
Summary
Control flow commands transform your CMakeLists.txt
from a simple declaration into a powerful script. They allow you to handle complexity, reduce redundancy, and create builds that can adapt to different environments and configurations.
- Conditionals with
if()
: Theif()
,elseif()
,else()
, andendif()
blocks are our primary tool for decision-making. You can test variables, compare strings and numbers, and check for platform-specific details likeif(WIN32)
. - Loops with
foreach()
: Theforeach()
andendforeach()
block lets you iterate over items in a list. It's perfect for applying the same commands to multiple targets or files, keeping your scripts clean and maintainable.
You now have the core language tools to write sophisticated build scripts. You can create variables, manage persistent options with the cache, group data in lists, and control the flow of execution with conditionals and loops.
Working with the File System
Exploring CMake's file()
command, covering how to read, write, copy, and glob files, and how to manipulate paths.