Conditional compilation

Notes published the
8 - 9 minutes to read, 1878 words

There are mainly four different techniques for conditionally compiling some source code:

  • link to a different library

  • compile different source files

  • define a macro (and set the value through the build systems)

  • a generated "configuration file"

  • with __has_include

I wrote about this approach in dependency injection in cmake.

As long as it is possible to define a stable API/ABI, one can swap one library with another, and have a binary that acts differently.

Compile different source files

This should be the most straightforward approach, a minimal example in cmake would be

CMakeLists.txt
cmake_minimum_required(VERSION 3.25.1)

project(example VERSION 0.0.1)

include(GenerateExportHeader)

set(CMAKE_POSITION_INDEPENDENT_CODE  ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS    ON)
set(CMAKE_C_VISIBILITY_PRESET        hidden)
set(CMAKE_VISIBILITY_INLINES_HIDDEN  1)

if     (PROJECT STREQUAL "standard")
 add_executable(main main1.c)
elseif (PROJECT STREQUAL "important")
 add_executable(main main2.c)
else()
 message( FATAL_ERROR "define project: -DPROJECT=standard or -DPROJECT=important" )
endif ()

__has_include

Since C++17 and C23, it is possible to query if an include file is available.

This can be used to conditionally compile one piece of code or another depending on the environment.

generated configuration file

A constant is often enough to determine if a piece of code needs to be compiled.

// defined in some header
constexpr bool new_feature = true;

// somewhere else

if(new_feature){
 // do something particular
}

To make the intent even clearer, one could write if constexpr instead of if, although every compiler I’ve tested with will eliminate the dead branch. if constexpr is especially useful if a condition is hidden behind a function

if constexpr(enable_new_feature()){
 // do something particular
}

As in this case, without constexpr it is not that clear that enable_new_feature will be evaluated at compile time.

But how does one maintain the header file with the flag new_feature?

Editing by hand is not the desired solution, one should be able to toggle it without changing the source code.

This is where the build system and its ability to generate source files comes in.

In cmake, a minimal example could look like

CMakeLists.txt
cmake_minimum_required(VERSION 3.25.1)

project(example VERSION 0.0.1)

include(GenerateExportHeader)

set(CMAKE_POSITION_INDEPENDENT_CODE  ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS    ON)
set(CMAKE_C_VISIBILITY_PRESET        hidden)
set(CMAKE_VISIBILITY_INLINES_HIDDEN  1)

if     (PROJECT STREQUAL "standard")
  set(NEW_FEATURE "false")
elseif (PROJECT STREQUAL "important")
  set(NEW_FEATURE "true")
else()
  message( FATAL_ERROR "define project: -DPROJECT=standard or -DPROJECT=important" )
endif ()

configure_file(buildinfo.gen.in ${CMAKE_CURRENT_BINARY_DIR}/buildinfo.gen.hpp @ONLY)
add_executable(main main.cpp)
buildinfo.gen.in
#pragma once

constexpr bool new_feature = @NEW_FEATURE@;
main.cpp
#include "buildinfo.gen.hpp"

#include <cstdio>

int main(){
  if(new_feature){
    std::puts("Hello World!");
 } else {
    std::puts("Hello World.");
 }
}

The generated configuration file can define and implement whole functions, but it’s best to keep it as small as possible; after all, it is not valid source code, thus setting breakpoints from an IDE on the source file, autocompletion, syntax highlighting might not work reliably, or at all.

Macros

Macros are probably the most used approach.

The main reason is that they give the maximum flexibility; contrary to a constant:

  • it is obvious that the logic is at compile-time

  • there will be no leftovers in the binary file, even with optimizations disabled, because generally, the compiler does not even see or parse the disabled code

  • it makes it possible to hide snippets of invalid code

  • permits conditional inclusion

An example project could look like

CMakeLists.txt
cmake_minimum_required(VERSION 3.25.1)

project(example VERSION 0.0.1)

include(GenerateExportHeader)

set(CMAKE_POSITION_INDEPENDENT_CODE  ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS    ON)
set(CMAKE_C_VISIBILITY_PRESET        hidden)
set(CMAKE_VISIBILITY_INLINES_HIDDEN  1)

if     (PROJECT STREQUAL "standard")
  # nothing to do
elseif (PROJECT STREQUAL "important")
  add_compile_definitions(NEW_FEATURE)
else()
  message( FATAL_ERROR "define project: -DPROJECT=standard or -DPROJECT=important" )
endif ()

add_executable(main main.cpp)
main.cpp
#include <cstdio>

int main(){
  #ifdef NEW_FEATURE
    std::puts("Hello World!");
 #else
    std::puts("Hello World.");
 #endif
}

Alternate approach 1

A possible alternate approach is to always define NEW_FEATURE to a boolean value, and test it accordingly.

CMakeLists.txt
cmake_minimum_required(VERSION 3.25.1)

project(example VERSION 0.0.1)

include(GenerateExportHeader)

set(CMAKE_POSITION_INDEPENDENT_CODE  ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS    ON)
set(CMAKE_C_VISIBILITY_PRESET        hidden)
set(CMAKE_VISIBILITY_INLINES_HIDDEN  1)

if     (PROJECT STREQUAL "standard")
 add_compile_definitions(NEW_FEATURE=false)
elseif (PROJECT STREQUAL "important")
 add_compile_definitions(NEW_FEATURE=true)
else()
 message( FATAL_ERROR "define project: -DPROJECT=standard or -DPROJECT=important" )
endif ()

add_executable(main main.cpp)
main.cpp
#include <cstdio>

int main(){
  #if NEW_FEATURE
    std::puts("Hello World!");
 #else
    std::puts("Hello World.");
 #endif
}

Alternate approach 2

Another approach is to use a configuration file, similar to defining a constant value

The main advantage is that only the source files that are interested in NEW_FEATURE can query it explicitly. All other files are not affected, and the compilation flags are the same. Especially when doing rebuilds, or using systems like ccache, an unchanged source file (and compilation flags) means that there is no need to recompile the corresponding object file, which can increase the edit-and-build cycle.

Major disadvantage of the macro approach

Typos

Consider

main.cpp
#include <cstdio>

int main(){
  #ifdef NEWFEATURE
    std::puts("Hello World!");
  #else
    std::puts("Hello World.");
  #endif
}

or even

main.cpp
#include <cstdio>

int main(){
  #if NEWFEATURE
    std::puts("Hello World!");
  #else
    std::puts("Hello World.");
  #endif
}

In both cases, the programmer made a typo and wrote NEWFEATURE instead of NEW_FEATURE. This means, no compile error, and the code is never enabled. This can be caught by testing the application, but a compile error would be better.

Missing includes

If you prefer to generate a configuration file or try to scope macros in other ways, instead of defining them globally and project-wide, a missing include header can enable or disable a feature by accident

main.cpp
// missing
// #include "buildinfo.gen.hpp"
#include <cstdio>

int main(){
  #if NEW_FEATURE
    std::puts("Hello World!");
  #else
    std::puts("Hello World.");
  #endif
}

In this case, the feature will never be enabled in the source code, even if it has been enabled in the build system.

Function-like macro as alternative

A function-like macro can overcome the described disadvantages.

main.cpp
#include <cstdio>

int main(){
  #if NEW_FEATURE()
    std::puts("Hello World!");
  #else
    std::puts("Hello World.");
  #endif
}

This code fails to compile if the function-like macro NEW_FEATURE is not defined, either because an include is missing, or because of a type.

The disadvantage is that programmers need to get used to writing #if NEW_FEATURE() instead of #if NEW_FEATURE or #ifdef NEW_FEATURE, otherwise they will have the same issue as before.

A possible workaround is to add an indirection:

main.cpp
#include <cstdio>

int main(){
  #if ENABLE(NEW_FEATURE)
    std::puts("Hello World!");
  #else
    std::puts("Hello World.");
  #endif
}

Since NEW_FEATURE is a parameter to a function-like macro, the chances of using it incorrectly are lower, and since ENABLE is a feature-like macro, it will trigger a compile error if something is not defined.

A possible minimal project taking advantage of such a macro could look like the following.

CMakeLists.txt
cmake_minimum_required(VERSION 3.25.1)

project(example VERSION 0.0.1)

include(GenerateExportHeader)

set(CMAKE_POSITION_INDEPENDENT_CODE  ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS    ON)
set(CMAKE_C_VISIBILITY_PRESET        hidden)
set(CMAKE_VISIBILITY_INLINES_HIDDEN  1)

if     (PROJECT STREQUAL "standard")
 set(NEW_FEATURE "false")
elseif (PROJECT STREQUAL "important")
 set(NEW_FEATURE "true")
else()
 message( FATAL_ERROR "define project: -DPROJECT=standard or -DPROJECT=important" )
endif ()

configure_file(buildinfo.gen.in ${CMAKE_CURRENT_BINARY_DIR}/buildinfo.gen.hpp @ONLY)
add_executable(main main.cpp)
buildinfo.gen.in
#pragma once

#define ENABLE_NEW_FEATURE() @NEW_FEATURE@
#define ENABLE(X) ENABLE_##X()
main.cpp
#include "buildinfo.gen.hpp"

#include <cstdio>

int main(){
  #if ENABLE(NEW_FEATURE)
    std::puts("Hello World!");
  #else
    std::puts("Hello World.");
  #endif
}
Note 📝
With MSVC it is not possible to define a function like macro on the command line 🗄️. Thus it makes sense to use function-like macros only with a generated header file.

Bonus cmake function

In CMake, there are many values that can be used as boolean expression, in particular the value ON and OFF used by cmake option.

With the following snippet, it is possible to map those and all other values to true/false ( or 0/1):

function(toTrueFalse varToSet varValue)
  set(${varToSet} "false" PARENT_SCOPE)
  if(${varValue})
    set(${varToSet} "true" PARENT_SCOPE)
  endif()
endfunction()

option(OPTION_ENABLE_NEW_FEATURE "Enable FEATURE" ON)
toTrueFalse("NEW_FEATURE" OPTION_ENABLE_NEW_FEATURE)
configure_file(buildinfo.gen.in ${CMAKE_CURRENT_BINARY_DIR}/buildinfo.gen.hpp @ONLY)

Conclusion

In this note, I’ve summarized different techniques for conditionally compiling source sode.

Macros and constants can be used as a low-level technique (in the sense that the logic for conditional compilation is in the source code itself) for compiling a subset of a source file

Constant Macro Function-like macro in header separate source file separate library

conditionally compile a subset of file

yes

yes

yes

no

no

conditionally include source file

no

yes

yes

no

no

conditionally compiled code must be valid

yes

no

no

yes

yes

typos/wrong include order cause compilation errors

yes

no

yes

no

no

enable/disabling feature requires to recompile …​

dependent files

everything if defined globally, otherwise dependent files

dependent files

relinking

relinking

it is possible to compile a binary with and without feature "in one go"

no

no

no

no, requires otherwise to duplicate project structure

yes

Using __has_include counts as using a function-like macro.

Macros are, in my experience, the most common approach, and considering all issues they have, I find it mostly unfortunate, especially since a constant is a suitable replacement for many situations.

The main reason macros are preferred is that more or less guarantee to not have any overhead (a constant might not bring to dead-code elimination, and might be evaluated at runtime), and are much more flexible. They can be used to conditionally include other files, or hide function declarations and definitions (which also benefits compile times).

A higher-level technique is to compile different source files. Since the logic is only in the build system, there is no need to define macros or constants.

Another higher-level technique is to conditionally link (or load) libraries, as explained in dependency injection in cmake.


Do you want to share your opinion? Or is there an error, some parts that are not clear enough?

You can contact me anytime.