CMake Conditionals and Loops

Transform your static build descriptions into dynamic scripts with CMake's control flow commands, if() and foreach().

Greg Filak
Published

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 in NOTFOUND.

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 of VAR is a "true" constant. If VAR is a "false" constant or not defined at all, this condition is false.
  • if(DEFINED VAR) checks only if a variable with the name VAR 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 and OR 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(): The if() , elseif(), else(), and endif() blocks are our primary tool for decision-making. You can test variables, compare strings and numbers, and check for platform-specific details like if(WIN32).
  • Loops with foreach(): The foreach() and endforeach() 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.

Next Lesson
Lesson 17 of 18

Working with the File System

Exploring CMake's file() command, covering how to read, write, copy, and glob files, and how to manipulate paths.

Have a question about this lesson?
Answers are generated by AI models and may not be accurate