Conditional compilation
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
Link to a different library
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
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 ()
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
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)
#pragma once
constexpr bool new_feature = @NEW_FEATURE@;
#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
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)
#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.
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)
#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
#include <cstdio>
int main(){
#ifdef NEWFEATURE
std::puts("Hello World!");
#else
std::puts("Hello World.");
#endif
}
or even
#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
// 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.
#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:
#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.
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)
#pragma once
#define ENABLE_NEW_FEATURE() @NEW_FEATURE@
#define ENABLE(X) ENABLE_##X()
#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.