Modularizing CMake Code

Learn to create reusable logic with CMake functions and macros, and how to organize them into modules for clean, scalable build systems.

Greg Filak
Published

As our CMakeLists.txt scripts grow, they risk becoming cluttered and repetitive, just like C++ code without functions. We've seen how target_compile_features and target_include_directories need to be applied to multiple targets. Copying and pasting this logic is not a scalable solution.

The answer, just as in any good programming language, is to modularize our code. CMake provides several mechanisms for this, allowing us to package reusable logic into self-contained units.

This lesson will introduce the three main tools for achieving modularity in CMake:

  • Functions: To encapsulate logic into reusable commands with their own private scope.
  • Macros: A simpler alternative to functions that operates in the caller's scope.
  • Modules: Separate .cmake files that can be loaded into our project to provide functions and macros that can be shared across multiple scripts.

Creating Reusable Logic with Functions

A CMake function is the primary way to bundle a sequence of commands into a single, new command that you can call repeatedly. It's the direct equivalent of a function in C++ or other programming languages.

The basic syntax is:

function(<FunctionName> [arg1 arg2 ...])
  # Commands to be executed
  # Arguments are available as ${arg1}, ${arg2}, etc.
endfunction()

Let's create a practical example. In our current project, we have to set the C++ standard for every library and executable we create. This is a perfect candidate for a helper function.

Let's create a setup_target() function in our root CMakeLists.txt that takes a target name as an argument and applies our standard C++20 configuration to it.

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

project(Greeter VERSION "1.0")

# Define a reusable function
function(setup_target target_name) 
  message(STATUS "Setting C++20 standard for target: ${target_name}") 
  target_compile_features(${target_name} PUBLIC cxx_std_20) 
endfunction() 

add_subdirectory(greeter)
add_subdirectory(app)

This setup_target() function is now available at any point in this CMakeLists.txt file below where it is defined. Bearing in mind how scoping rules work, it will also be available in any "child" CMakeLists.txt file loaded through the add_subdirectory() command.

Let's use this to replace the target_compile_features() calls in our other CMakeLists.txt files with calls to our new function.

greeter/CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
add_library(GreeterLib src/Greeter.cpp)

setup_target(GreeterLib) 

target_include_directories(GreeterLib PUBLIC
  "${CMAKE_CURRENT_SOURCE_DIR}/include"
)

app/CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
add_executable(GreeterApp src/main.cpp)

setup_target(GreeterApp) 

target_link_libraries(GreeterApp GreeterLib)
cmake ..
-- Setting C++20 standard for target: GreeterApp
-- Setting C++20 standard for target: GreeterLib
-- Configuring done (0.8s)
-- Generating done (0.1s)
-- Build files have been written to: D:/Projects/cmake/build

This is already much cleaner. If we decide to move to C++23, we only need to change it in one place: inside our setup_target() function.

Macro vs. Function: The Scope Difference

At first glance, a CMake macro looks identical to a function. The definition syntax is nearly the same:

macro(<MacroName> [arg1 arg2 ...])
  # Commands to be executed
endmacro()

However, there is one fundamental difference: scope.

  • A function creates a new scope. Variables defined inside a function are local to that function and are destroyed when the function returns. They do not affect the caller's scope.
  • A macro does not create a new scope. When you call a macro, its code is effectively expanded and executed directly in the caller's scope. Any variables set or modified inside a macro will alter the state of the calling CMakeLists.txt.

A Demonstration of Scope

Let's see this with a simple experiment:

cmake_minimum_required(VERSION 3.16)

function(scope_test_func)
  set(TEST_VAR "Set inside function")
  message("Inside function: TEST_VAR is ${TEST_VAR}")
endfunction()

macro(scope_test_macro)
  set(TEST_VAR "Set inside macro")
  message("Inside macro: TEST_VAR is ${TEST_VAR}")
endmacro()

# Call the function and check the variable
scope_test_func()
message("After function call: TEST_VAR is '${TEST_VAR}'")

# Call the macro and check the variable
scope_test_macro()
message("After macro call: TEST_VAR is '${TEST_VAR}'")

When we configure this project, the output shows the difference:

Inside function: TEST_VAR is Set inside function
After function call: TEST_VAR is ''
Inside macro: TEST_VAR is Set inside macro
After macro call: TEST_VAR is 'Set inside macro'

The variable set by the function vanished as soon as the function finished. The variable set by the macro persisted and overwrote any existing variable in the parent scope.

When to Use Which?

The scoping behavior dictates the use case:

  • Use functions by default. Their isolated scope makes them safer and more predictable. They prevent accidental modification of variables in the parent scope, which is a common source of hard-to-find bugs. Functions are for encapsulating a logical sequence of operations.
  • Use macros only when you explicitly need to modify the parent scope. They are useful for creating short, utility "commands" that need to return a value by setting a variable for the caller.

Modern CMake best practice strongly favors functions over macros.

Returning Values from Functions

CMake functions do not support "returning" values in the traditional sense, but we can accomplish something similar by using a set() command with the PARENT_SCOPE option.

Below, we define a function that accepts the name of an output variable, and then sets a variable with that name in the scope where the function was called:

function(get_full_name output_var first last)
  set(${output_var} "${first} ${last}" PARENT_SCOPE)
endfunction()

get_full_name(my_name "John" "Doe")
# my_name is now "John Doe"

The PARENT_SCOPE option only allows us to set or update variables in the immediate parent scope - it doesn't let us directly update values in grandparent scopes, or any further up the hierarchy.

So, for example, if we were to call get_full_name() from a CMakeLists.txt file loaded by add_subdirectory(), the invocation would set the variable within the scope of that CMakeLists.txt file, not the file where the function was defined.

In the next chapter, we'll introduce properties, which give us some alternative options over where we store our variable data.

Including External CMake Modules

As our collection of helper functions and macros grows, we don't want to clutter our top-level CMakeLists.txt with them. The solution is to move them into separate files called modules and bring them into our project when needed.

The include() Command

The include() command tells CMake to find a .cmake file and execute it in the current scope, much like #include in C++. Once the module is included, all functions and macros defined within it become available for use.

Let's refactor our project by moving our setup_target() function into its own module.

Step 1: Create the Module File

It's a common convention to place project-specific modules in a cmake/ directory at the project root.

cmake/TargetHelpers.cmake

# This file contains helper functions for setting up targets.
function(setup_target target_name)
  message(STATUS
    "Setting C++20 standard for: ${target_name}"
  )
  target_compile_features(${target_name} PUBLIC cxx_std_20)
endfunction()

Step 2: Include the Module

Now, in our root CMakeLists.txt, we can remove the function definition:

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

project(Greeter VERSION "1.0")

function(setup_target target_name) 
  message(STATUS
    "Setting C++20 standard for: ${target_name}"
  ) 
  target_compile_features(${target_name} PUBLIC cxx_std_20) 
endfunction() 

add_subdirectory(greeter)
add_subdirectory(app)

Instead, files that need this function can load it in using an include() command:

greeter/CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
include(../cmake/TargetHelpers.cmake)

add_library(GreeterLib src/Greeter.cpp)
setup_target(GreeterLib)
target_include_directories(GreeterLib PUBLIC
  "${CMAKE_CURRENT_SOURCE_DIR}/include"
)

app/CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
include(../cmake/TargetHelpers.cmake)

add_executable(GreeterApp src/main.cpp)
setup_target(GreeterApp)
target_link_libraries(GreeterApp GreeterLib)

The behavior is identical, but our root CMakeLists.txt is now cleaner and is back to remaining focused on the high-level project details.

Setting CMAKE_MODULE_PATH

When you call include() (or find_package(), which we'll cover later), CMake searches for the specified module in a list of predefined locations. You can add your own directories to this search path by modifying the CMAKE_MODULE_PATH variable.

It's a common pattern to add your project's cmake directory to this path:

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")

project(Greeter VERSION "1.0")

add_subdirectory(greeter)
add_subdirectory(app)

This means that our modules can now be imported just using their name - we no longer need to provide the path and file extension. For example:

app/CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
include(../cmake/TargetHelpers.cmake)
include(TargetHelpers)

add_executable(GreeterApp src/main.cpp)
setup_target(GreeterApp)
target_link_libraries(GreeterApp GreeterLib)

Third-Party Modules

Our own projects aren't the only source of modules - we don't need to write everything ourselves. As with most ecosystems, there is a large community of third-party libraries that people have already created to solve a wide range of problems.

Much like C++ includes a standard library of useful utilities, CMake itself comes with a large collection of utility modules that provide helpful functions and macros for common tasks. You just need to include() them to use their features.

A simple example is the CMakePrintHelpers module. We've already learned how to print variables using the message() command. This works, but if you want to inspect several variables, the code can get repetitive:

message("VAR1 = ${VAR1}")
message("VAR2 = ${VAR2}")
message("VAR3 = ${VAR3}")

The CMakePrintHelpers module offers a more convenient way to do this with its cmake_print_variables() function.

Let's see how to use it. First, we include() the module. Then, we can call the function it provides, passing the names of all the variables we want to inspect.

cmake_minimum_required(VERSION 3.16)

# Include the standard CMake module
include(CMakePrintHelpers) 

set(MY_STRING "Hello")
set(MY_LIST "a;b;c")
set(MY_BOOL ON)

# Use the function from the module to print the variables
cmake_print_variables(MY_STRING MY_LIST MY_BOOL)

When we configure this project, cmake_print_variables() gives us a clean, formatted output showing the name and value of each variable we requested:

-- MY_STRING="Hello"
-- MY_LIST="a;b;c"
-- MY_BOOL="ON"

This is much cleaner and more readable than writing multiple message() calls.

Many common problems, from finding specific compilers to interacting with version control systems, already have solutions in the form of a standard module. You can see the full list of official modules on the CMake site. The awesome-cmake repository on GitHub curates a list of the best community-built resources.

Summary

Modularizing your CMake code is helpful for creating clean, scalable, and maintainable build systems. Just as you refactor C++ code to avoid repetition, you should refactor your complex CMakeLists.txt files.

  • Functions for Reusable Logic: Use function() to encapsulate sequences of commands. They are the preferred tool because their private scope prevents unintended side effects.
  • Macros for Scope Modification: Use macro() sparingly, and only when you have a clear need to set or modify a variable in the calling scope.
  • Modules for Sharing Code: Move your helper functions and macros into separate .cmake files.
  • include() to Load Modules: Use the include() command to bring your helper modules into a CMakeLists.txt, making their functions available for use.
Have a question about this lesson?
Answers are generated by AI models and may not be accurate