Generator Expressions and Conditional Logic

Learn how to use generator expressions $<...> for build-time conditional logic.

Greg Filak
Published

We've learned how to write intelligent build scripts using CMake's control flow commands like if() and foreach(). These commands allow us to make decisions and automate repetitive tasks during the configure phase of our build.

However, some decisions can't be made during configuration. As we saw in the , the choice between a Debug and Release build happens at a different time depending on the build system we generate.

This creates a portability problem. How can we write a single, clean CMakeLists.txt that applies settings conditionally, regardless of whether the user is building with Makefiles or Visual Studio? As we saw earlier, the answer is a generator expressions. In this lesson, we'll cover them in more depth.

Introducing Generator Expressions

A generator expression is a special piece of syntax, written as $<...>, that solves this problem.

When CMake's parser encounters a generator expression, it does not evaluate it. Instead, it copies the expression verbatim into the appropriate property field in the generated Makefile or .vcxproj file.

It is then the job of the native build tool (make, MSBuild, etc.) to evaluate this expression when you actually build your target. This makes the decision "just in time," using the build configuration active at that moment.

It's like a deferred function call. You tell CMake, "When you eventually build this, please evaluate this expression and use the result."

Common Generator Expressions

While there are many types of generator expressions, a few are fundamental for managing configurations and platforms.

The CONFIG Expression

As we saw earlier, this is the primary tool for build-type-specific logic. It compares the name of the active configuration to the string we provide. It returns "1" (a true value) if they match and "0" (false) otherwise.

In the following example, we define the IS_DEBUG_BUILD preprocessor macro, but only for Debug builds:

target_compile_definitions(GreeterApp PRIVATE
  $<$<CONFIG:Debug>:IS_DEBUG_BUILD>
)

The syntax can look a little strange at first because it's often nested.

$<CONFIG:Debug>: At build time, this inner expression is evaluated first. If the current configuration is Debug, it evaluates to the string "1". Otherwise, it evaluates to "0".

$<1:...> or $<0:...>: The result of the inner expression is used to form a new expression.

  • If the config was Debug, the expression becomes $<1:IS_DEBUG_BUILD>. The $<1:...> expression simply evaluates to its content, so the result is the string "IS_DEBUG_BUILD".
  • If the config was not Debug (e.g., Release), the expression becomes $<0:IS_DEBUG_BUILD>. The $<0:...> expression evaluates to an empty string.

So, for a Debug build, the command effectively becomes:

target_compile_definitions(GreeterApp PRIVATE IS_DEBUG_BUILD)

For any other build type, the expression resolves to the following, which effectively does nothing:

target_compile_definitions(GreeterApp PRIVATE "")

The IF Expression

This is a more general conditional expression, like a ternary operator. It evaluates a condition (which is 1 for true, 0 for false) and resolves to one of two strings.

A classic use case is selecting the correct C++ runtime library when using MSVC. Debug builds should link against the debug runtime (/MDd), while release builds should use the release runtime (/MD).

# This only applies the flags if the compiler is MSVC
if(MSVC)
  target_compile_options(GreeterApp PRIVATE
    $<$<IF:$<CONFIG:Debug>,/MDd,/MD>>
  )
endif()

Here's how it works at build time:

  1. $<CONFIG:Debug> evaluates to 1 or 0.
  2. The expression becomes either $<IF:1,/MDd,/MD> or $<IF:0,/MDd,/MD>.
  3. $<IF:1,...> evaluates to the first string (/MDd).
  4. $<IF:0,...> evaluates to the second string (/MD).

The AND, OR, and NOT Expressions

Just like if() statements, generator expressions can be combined using logical operators to create more complex conditions. These expressions take a semicolon-separated list of other boolean expressions (ones that evaluate to 0 or 1) as their arguments.

  • $<AND:expressions...>: Evaluates to 1 only if all of the contained expressions evaluate to 1.
  • $<OR:expressions...>: Evaluates to 1 if any of the contained expressions evaluate to 1.
  • $<NOT:expression>: Evaluates to 1 if the single contained expression is 0, and 0 if it is 1.

Let's use this to set a specific preprocessor definition only when we are building in Debug mode and on the Windows platform.

Here, $<AND:...> will only resolve to 1 if both $<CONFIG:Debug> and $<PLATFORM_ID:Windows> are true. We can add additional spacing to complex generator expressions to make them easier to follow:

target_compile_definitions(MyApp PRIVATE
  $<
    $<AND:
      $<CONFIG:Debug>,
      $<PLATFORM_ID:Windows>
    >:
    IS_WINDOWS_DEBUG_BUILD
  >
)

We'll introduce alternative ways to simplify this in the next section.

The TARGET_FILE Expression

Sometimes, you need to know the full path to the output file of a target. For example, you might want to run a custom command that processes an executable after it has been built.

Hardcoding that location by typing something like /build/app/GreeterApp.exe isn't recommended, as it's not portable. The output name and location can change based on the generator, configuration, and platform.

The $<TARGET_FILE:SomeTarget> expression solves this. It resolves, at build time, to the full path of the specified target's main file. In the following example, we use this to log out the full path of our executable, after it is built.

The add_custom_command() syntax in this example is a new concept that we will cover in the next lesson. For now, the key point is that $<TARGET_FILE:SomeTarget> expression provides a reliable way to get a target's output path:

Files

CMakeLists.txt
app
Select a file to view its content

If we run a clean build, we should see our generator expression outputting the location of our executable:

cmake --build .
...
Linking CXX executable app\GreeterApp.exe
Built binary located at D:/Projects/cmake/build/app/GreeterApp.exe

The PLATFORM_ID Expression

This expression works just like $<CONFIG:...>, but it checks the target platform identifier (e.g., Windows, Linux, Darwin for macOS). It provides a build-time alternative to the configure-time if(WIN32) check.

In the following example, we define the IS_WINDOWS_BUILD preprocessor macro, but only we're building for Windows.

# Add a definition only when building for Windows
target_compile_definitions(MyTarget PRIVATE
  $<$<PLATFORM_ID:Windows>:IS_WINDOWS_BUILD>
)

This is less common than a check such as if(WIN32), but can be useful if the platform decision needs to be deferred to build time, especially in complex cross-compilation scenarios.

Best Practices and Writing Clean Expressions

Generator expressions are powerful, but complex, nested expressions can quickly become unreadable. The following expression sets the -some-flag compiler option if the build type is Debug and the platform is Windows, and -other-flag otherwise:

# Hard to read and maintain
target_compile_options(MyApp PRIVATE
  "$<IF:$<AND:$<CONFIG:Debug>,$<PLATFORM_ID:Windows>>,-some-flag,-other-flag>")

Here are some good practices for keeping your scripts clean.

Use Variables for Readability

The single most effective way to clean up a complex generator expression is to store it in a variable. This gives the logic a descriptive name and separates the complex condition from the command that uses it.

Let's refactor our "bad" example using this technique:

# Good: Store the condition in a variable
set(
  IS_WINDOWS_DEBUG
  "$<AND:$<CONFIG:Debug>,$<PLATFORM_ID:Windows>>"
)

# Use the variable in a clean $<IF:...> expression
target_compile_options(MyApp PRIVATE
  "$<IF:${IS_WINDOWS_DEBUG},-some-flag,-other-flag>"
)

The result is the same, but the target_compile_options() call is now much easier to understand at a glance. We've given the complex condition a name, IS_WINDOWS_DEBUG, which documents its purpose.

Use Whitespace for Formatting

Don't be afraid to use whitespace. CMake is flexible about newlines inside a command's parentheses. You can format long generator expressions across multiple lines to improve clarity, just like you would with complex C++ code.

# Good: Use formatting for clarity
target_compile_options(MyApp PRIVATE
  "$<IF:
    $<AND:
      $<CONFIG:Debug>,
      $<PLATFORM_ID:Windows>
    >,
    -some-flag,
    -other-flag
  >"
)

This formatted version is much easier to parse visually than a single, long line. You can clearly see the structure of the nested expressions.

Encapsulate Logic in INTERFACE Targets

Remember, for logic that is reused across multiple targets, it's often best to encapsulate it within an INTERFACE target. This is the ultimate expression of the "Don't Repeat Yourself" (DRY) principle in CMake.

We walked through an example of creating and using INTERFACE targets . The key point is that, Instead of having multiple targets repeat the logic to decide which properties should be applied, we can do that in one place to create a single "property bag" target:

# Create an INTERFACE target to hold the complex logic
add_library(BuildPolicies INTERFACE)

# Attach the generator expression to the interface target
set(
  IS_WINDOWS_DEBUG
  "$<AND:$<CONFIG:Debug>,$<PLATFORM_ID:Windows>>"
)

target_compile_options(BuildPolicies PRIVATE
  "$<IF:${IS_WINDOWS_DEBUG},-some-flag,-other-flag>"
)

The complex logic is defined in exactly one place. Consumers don't need to know the details; they just link to the BuildPolicies abstraction.

target_link_libraries(App1 PRIVATE BuildPolicies)
target_link_libraries(App2 PRIVATE BuildPolicies)

Summary

Generator expressions provide a mechanism for embedding deferred logic directly into the generated build files.

  • Configure-Time vs. Build-Time: if() statements are evaluated when cmake runs. Generator expressions ($<...>) are evaluated when you build (e.g., when make or MSBuild runs).
  • Portability is Key: Using generator expressions lets us write a single CMakeLists.txt that works correctly for both single-configuration (Makefiles) and multi-configuration (Visual Studio) generators.
  • Best Practice: Prefer generator expressions over if() statements for any logic that depends on the build configuration. To keep them clean, use variables to name complex conditions, format them with whitespace, and encapsulate reused logic in INTERFACE targets.

Some of the most useful expressions include:

  • CONFIG is for configuration-specific logic (Debug, Release, etc.).
  • IF provides a general-purpose conditional.
  • AND, OR, and NOT let us build more complex checks
  • TARGET_FILE gives you the path to a target's output file.
  • PLATFORM_ID give us an alternative to platform checks like if(WIN32) that can be evaluated at build time
Next Lesson
Lesson 41 of 41

Integrating Tools with add_custom_command()

Learn how to attach commands to specific points in a target's build lifecycle using add_custom_command(TARGET) for tasks like post-build file copying.

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