Best practice for multi-config build options

I’ve recently added flags to a project to enable sanitizers. To work nicely with multi-config generators, I’ve split the option into one for each configuration type. Much like CMAKE_CXX_FLAGS_<CONFIG>. However, the solution I’ve ended up with feels clumsy and verbose for something I feel like should have a simpler solution.

In the example code below, we have the cache variables SANITIZERS_<CONFIG>. My first idea was to define a SANITIZERS_<CONFIG>_FLAGS variable and then have a generator expression like ${SANITIZERS_$<CONFIG>_FLAGS} to pick the right flags (in practice also needs UPPER_CASE on the config). But it seems like CMake won’t expand generator expressions that are nested in this way. At least my local CMake installation (version 3.18.3) gave me an error that boils down to < not allowed inside variable names.

As a workaround, I’ve ended up adding one generator expression per supported config type but there has to be a better way. I couldn’t find mentions of such “multi-config variables” in the docs and none of the CMake modules seemed to match either. Any pointer to something I’ve missed in the docs (or search terms I could’ve used) or feedback on how to do this less verbose are much appreciated. :slightly_smiling_face:

Here’s the minimal snippet to illustrate what I’ve ended up with. The test.cpp simply contains an empty main function. I’ve wanted to keep the stripped version as short as possible, so the code assumes GCC/Clang and won’t work MSVC.

cmake_minimum_required(VERSION 3.13.5)

project(CMakeTest)

set(CMAKE_EXPORT_COMPILE_COMMANDS ON
   CACHE INTERNAL "Write JSON compile commands database")

set(SANITIZERS_DEBUG "address" CACHE STRING "sanitizers for Debug builds")
set(SANITIZERS_RELEASE "" CACHE STRING "sanitizers for Release builds")
set(SANITIZERS_RELWITHDEBINFO "" CACHE STRING "sanitizers for RelWithDebInfo builds")
set(SANITIZERS_MINSIZEREL "" CACHE STRING "sanitizers for MinSizeRel builds")

function(define_flags_variable BuildName)
 set(xs "${SANITIZERS_${BuildName}}")
 set(flags_varname "SANITIZERS_${BuildName}_FLAGS")
 if(NOT "${xs}" STREQUAL "")
   set("${flags_varname}" "-fsanitize=${xs}" "-fno-omit-frame-pointer" PARENT_SCOPE)
 endif()
endfunction()

foreach(BuildName DEBUG RELEASE RELWITHDEBINFO MINSIZEREL)
 define_flags_variable(${BuildName})
endforeach()

function(target_add_sanitizers_flags TargetName AccessScope)
 target_compile_options(${TargetName} ${AccessScope}
   $<$<CONFIG:Debug>:${SANITIZERS_DEBUG_FLAGS}>
   $<$<CONFIG:Release>:${SANITIZERS_RELEASE_FLAGS}>
   $<$<CONFIG:RelWithDebInfo>:${SANITIZERS_RELWITHDEBINFO_FLAGS}>
   $<$<CONFIG:MinSizeRel>:${SANITIZERS_MinSizeRel_FLAGS}>)
 target_link_options(${TargetName} ${AccessScope}
   $<$<CONFIG:Debug>:${SANITIZERS_DEBUG_FLAGS}>
   $<$<CONFIG:Release>:${SANITIZERS_RELEASE_FLAGS}>
   $<$<CONFIG:RelWithDebInfo>:${SANITIZERS_RELWITHDEBINFO_FLAGS}>
   $<$<CONFIG:MinSizeRel>:${SANITIZERS_MinSizeRel_FLAGS}>)
endfunction()

add_executable(test test.cpp)

target_add_sanitizers_flags(test PRIVATE)

/edit: sorry for the edits, I’ve posted this via email originally (first time using Discord), but the snippet got truncated and I had to paste it back in. Also, I noticed I could use Markdown to make the post more readable.

$<> indicates a “generator expression” and is expanded after configure while CMake is writing out the build rules. ${} indicates a variable expansion which occurs during configuration, so ${foo$<CONFIG>} is not something that can be done as you would expect (even if the syntax were valid).

I think what you want is probably just a flag to enable or disable sanitizers which adds them to CMAKE_C_FLAGS and CMAKE_CXX_FLAGS when enabled (though not in the cache, just as edits to the local variable of the same name).

Note that the sanitizers are not just debug-only tools as optimizations can take advantage of uses of undefined behavior to reorder code and can find issues which pop out when that happens.

For example, this code:

int k = ptr->field;
if (!ptr) return;

can have the return completely optimized out because the compiler sees that ptr is dereferenced before, so if it gets to that line, it obviously can’t be nullptr, therefore it is dead code. Sanitizers can (and do) find issues which only occur under optimization.

See how SMTK does it for an example (the file is included from the top-level).