What are CMake generator expressions

Introduction

CMake generator expressions are special expressions that are evaluated during the generation phase of CMake. These expressions are typically used in CMake commands to allow for context-specific decisions, such as setting properties, linking libraries, or generating files.

The main advantage of generator expressions in comparison with the old way of CMake is the support of Multi-Config generators where different builds Debug, Release, and so on coexist in the same build directory. They also make the script cleaner and more readable.

Stages of CMake

We run cmake in a terminal as:

cmake -S /path/to/source -B path/to/build 

or if build folder is in the project directory, in a terminal in the build directory, we run:

cmake ..

This command runs two stages of CMake (1)configuration and (2)generation. If you use CMake GUI, you can run configuration and generation separately.

Finally, we build the project, (3) build stage, via

cmake --build . --config Debug

There are more stages like install and packaging.

Configuration stage

During configuration the CMakeLists.txt files in the project are parsed, variables, targets, commands, dependencies are defined and packages are found. Finally, a CMake cache file, CMakeCache.txt, is created that stores configuration results and user-defined options.

Generation stage

At this stage, build system files are generated compatible with a specific build tool like Makefiles, Ninja files, Visual Studio projects. Also, the necessary build files are created, for example, Makefile, .ninja files, or .sln files for Visual Studio.

Debug

In this post, I explain generator expressions with examples. Because message() only runs during configuration stage, we cannot use it to print the result of these expressions. To do so, we use a custom target as

cmake_minimum_required(VERSION 3.23)
project(MyDebugExample LANGUAGES CXX)

add_custom_target(printexpr ALL COMMAND ${CMAKE_COMMAND} -E echo $<..>)

where $<...> is a generator expression. When you build this project, the result of the expression is displayed in the terminal.

If you are interested in how add_custom_target() works, see my post.

Another way is to use:

file(GENERATE OUTPUT myFileName CONTENT "$<...>")

Some expressions like CXX_COMPILER_ID are defined only with a binary target, for them use:

file(GENERATE OUTPUT myFileName CONTENT "$<...>" TARGET myTargetName)

Types of Generator Expressions

They fall into several categories:

Conditions

Used to evaluate logical conditions.

$<condition:true_string>

Evaluates to true_string if condition is 1, or an empty string if condition evaluates to 0. Any other value for condition results in an error.

$<1:book> # evaluates to string book
$<0:book> # evaluates to empty string
$<true:book> # error, only 0 or 1 accepted.

$<BOOL:condition>

Evaluates to 1 if condition is true, otherwise 0. We use this to convert false expressions like empty string and case insensitive of FALSE, N, NO, OFF, NOTFound and IGNORE to 0 and otherwise to 1.

All of these evaluate to 0:

$<BOOL:FALSE>
$<BOOL:> 
$<BOOL:off> 
$<BOOL:something-NOTFOUND> # exception that is case sensitive

Anything that is not false expression evaluates to 1:

$<BOOL:true> 
$<BOOL:anyWordExceptFalseOnes> 

Expressions can be nested where the result of one injected to another one:

$<$<BOOL:false>:book> # evaluates to empty string
$<$<BOOL:true>:book> # evaluates to string book

$<IF:condition,true-value,false-value>

Returns true-value if condition is 1; if 0, returns false-value.

$<IF:0,IamTrue,IamFalse> # gives IamFalse
$<IF:$<BOOL:TomAndJerry>,IamTrue,IamFalse> # gives IamTrue

Logics

For AND and OR, the expressions are $<AND:conditions> and $<OR:conditions>. The conditions are comma separated 0 and 1s. For example:

# For AND, result is 1, if all are 1.
$<AND:1,1,1> # evaluates to 1
$<AND:1,1,0> # evaluates to 0
$<AND:1> # evaluates to 1
$<AND:> # Erorr, at least a 0/1 needed.

# For OR, result is 1, if at least one 1 present. 
$<OR:1,0,0> # evaluates to 1
$<OR:0,0,0> # evaluates to 0
$<OR:1,1> # evaluates to 1
$<OR:0> # evaluates to 0

# Mixing expressions
$<IF:$<AND:$<BOOL:false>,1>,IamTrue,IamFalse> # gives IamFalse

Also we have $<NOT:condition> to flip 0 and 1.

$<NOT:1> # evaluates to 0
$<NOT:$<BOOL:OFF>> # evaulates to 1

There are a lot of more useful expressions to help with conditions like STREQUAL, EQUAL, LOWER_CASE and there are many handy ones to work with versions, lists and paths. For them refer to CMake manual.

Configuration

These are used to differentiate settings for build configurations (e.g., Debug, Release).

$<CONFIG>: Evaluates to the current configuration name.

$<CONFIG:configs>: 1 if the current configuration matches one of configs, which is one configuration or a comma separated list of configurations.

# if you compile in Release mode
$<CONFIG> # evaluates to string Release
$<CONFIG:Release> # evaluates to 1
$<CONFIG:Debug> # evaluates to 0
$<CONFIG:RelWithDebInfo> # evaluates to 0
$<CONFIG:Release,RelWithDebInfo> # evaluates to 1

Target

These are used to retrieve or set properties for specific targets.

$<TARGET_FILE:tgt>

Full path to the file produced by target tgt. In the example below, the executable file of target circle, for example circle.exe on Windows, is copied to the target destination.

add_executable(circle main.cpp)
add_custom_target(collectExes ALL 
   COMMAND ${CMAKE_COMMAND} -E copy $<TARGET_FILE:circle> path/to/destination)

$<TARGET_PROPERTY:target,property>

Retrieves the value of a property for target. The example below, prints the the source files of target circle on screen when you build the project:

add_executable(circle main.cpp)
add_custom_target(printExpr ALL 
   COMMAND ${CMAKE_COMMAND} -E echo $<TARGET_PROPERTY:circle,SOURCES>)

Platform

These are used to apply settings depending on the operating system or platform.

$<PLATFORM_ID>: gives the name of operating system. On Windows 11, I get Windows, on Ubuntu I get Linux.

$<PLATFORM_ID:platform_ids>: 1 if the platform matches any of platform_ids which is a comma separated list.

Compiler

Compiler expressions give information about the compiler but they are only valid with a binary target.

$<CXX_COMPILER_ID>: turns to MSVC, GNU, Clang, and so on.

$<CXX_COMPILER_VERSION>: For my MSVC, I get 19.39.33523.0.

$<COMPILE_LANGUAGE>: evaluates to C, CXX, and so on.

There are many other expressions for compiler, language, and linker in the CMake Manual.

Build and Install

For a project usually we consider two scenarios. The project is build and other prjects in the same build use its files in the source/binary tree to be built. There is another scenario, that our project is already built and installed, then another project tries to link to it by reading CMake config files. Generator expressions can help use to differntiate these situations easily.

$<BUILD_LOCAL_INTERFACE:rhs>

This expression evaluates to rhs when the target is utilized by another target within the same build system. Otherwise, it evaluates to an empty string.

$<INSTALL_INTERFACE:rhs>

This expression evaluates to rhs if the property is exported using the install(EXPORT) command. If not, it evaluates to an empty string.

I found the code below from pybind11 project on Github (I slightly modified it):

target_include_directories(
   pybind11_headers ${pybind11_system} INTERFACE 
   $<BUILD_LOCAL_INTERFACE:${pybind11_INCLUDE_DIR}>
   $<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>)

pybind11 is a header only project. Therefore, keyword INTERFACE is used for including directories. Other projects that are in the same buildsystem, use ${pybind11_INCLUDE_DIR} which is a local directory in the source tree. However, if pybind11 is installed somewhere, ${CMAKE_INSTALL_INCLUDEDIR} will be the include directory property of installed pybind11.

Example 1

In this example, generator expressions are used to set compile options for a target depending on the compiler (GNU or MSVC) and build type (Debug or Release):

target_compile_options(my_target PRIVATE
    $<$<AND:$<CXX_COMPILER_ID:GNU>,$<CONFIG:Debug>>:-Og>
    $<$<AND:$<CXX_COMPILER_ID:GNU>,$<CONFIG:Release>>:-O2>
    $<$<AND:$<CXX_COMPILER_ID:MSVC>,$<CONFIG:Debug>>:/Od>
    $<$<AND:$<CXX_COMPILER_ID:MSVC>,$<CONFIG:Release>>:/O2>
)

We can also define C/C++ preprocessor macro for different configs and compilers:

target_compile_definitions(my_target PRIVATE
    $<$<CONFIG:Debug>:DEBUG_MODE>
    $<$<CONFIG:Release>:NDEBUG>
    $<$<CXX_COMPILER_ID:GNU>:GNU>
    $<$<CXX_COMPILER_ID:MSVC>:MSVC>
)

Example 2

In the example below, circle executable is built which depends on shared library geo. The platform is Windows and we’d like to copy geo.dll file to the same folder that circle.exe is built in. By that, we can run circle.exe in its folder.

add_executable(circle main.cpp)
target_link_libraries(circle geo)
add_custom_command(
  TARGET circle
  POST_BUILD
  COMMAND ${CMAKE_COMMAND} -E copy $<TARGET_FILE:geo> $<TARGET_FILE_DIR:circle>/
)
  • $<TARGET_FILE:geo> : the full path to geo.dll. If you need only the name, geo.dll, use $<TARGET_FILE_NAME:geo>.
  • $<TARGET_FILE_DIR:circle>: the directory that circle.exe is built in. Don’t forget the / after, otherwise it won’t be a successful copy as the desitination can be a file or a directory.
Tags ➡ C++

Subscribe

I notify you of my new posts

Latest Posts

Comments

0 comment