C / PREPROCESSOR
Conditional compilation for platforms and debug builds
Select platform- and build-specific code with #ifdef, #if and #error, default your -D knobs with #ifndef, and know why unbuilt branches rot.
What you will learn
- Chain #if defined(_WIN32) / __APPLE__ / __linux__ and close it with #else #error
- Distinguish #ifdef X (is it defined?) from #if X (what integer does it become?)
- Default -D knobs with #ifndef, and let NDEBUG turn every assert into a no-op
- Keep unbuilt branches tiny, since code the compiler never sees is never checked
Understanding Conditional compilation for platforms and debug builds
#if, #ifdef, #ifndef, #elif, #else and #endif are resolved while the file is still a stream of preprocessing tokens, long before anything is parsed as C. The losing branch is deleted outright, and deleted lines only have to be well-formed tokens: a misspelled function name or an invented keyword inside a false branch is not an error. That is precisely why platform differences are handled here and not with a runtime if. #include <windows.h> and #include <unistd.h> cannot both survive in one translation unit, and a call to CreateFileW will not compile on Linux, where nothing declares it.
There are two different questions being asked. #ifdef NAME and #ifndef NAME ask only whether NAME has been defined, so #define LOG_LEVEL 0 fully satisfies #ifdef LOG_LEVEL. #if instead macro-expands its line and evaluates an integer constant expression, and any identifier still standing after expansion is replaced by 0, so a typo like #if LOGLEVEL >= 2 becomes 0 >= 2, quietly false, with no diagnostic unless you compile with -Wundef. The defined operator bridges the two, letting you write logic #ifdef cannot express, such as #if defined(__linux__) && !defined(__ANDROID__).
Each switch multiplies the number of configurations you must actually build, and the untaken branch receives no compiler checking at all, so it rots silently. Isolate platform code behind a few macros in one header, or behind functions that share a signature, and prefer a plain if (LOG_LEVEL >= 2) whenever both branches are legal C everywhere, because the optimizer discards the dead one while the compiler still checks both. For debug builds the conventions are fixed: NDEBUG means release, and the standard requires <assert.h> to compile every assert to nothing when it is defined; your own knobs are ordinary macros given a default with #ifndef so -DLOG_LEVEL=0 can override them. End every platform chain with #else and #error so an unforeseen target fails at build time with your message.
placeholder
<stdio.h>
LOG_LEVEL /* -DLOG_LEVEL=n on the command line wins */
LOG_LEVEL
PLATFORM_NAME
PLATFORM_NAME
PLATFORM_NAME
PLATFORM_NAME
TRACE(msg)
TRACE(msg)
none of this is C, and the compiler never sees it
int main(void)
{
printf("built for %s, LOG_LEVEL=%d\n", PLATFORM_NAME, LOG_LEVEL);
TRACE("entering main");
printf("quiet build\n");
return 0;
}
Conditional compilation selects source text before the compiler parses anything, so only the branch you actually build is ever checked.
Worked examples
NDEBUG deletes the assertion and its side effect
Shows that a release build removes the whole assert expression, not just the check.
<stdio.h>
NDEBUG/* exactly what -DNDEBUG does in a release build */
<assert.h>
int main(void)
{
int calls = 0;
assert(++calls > 0);
printf("calls = %d\n", calls);
return 0;
}
Example explained
Line 1#define NDEBUG must come before #include <assert.h>, because the header decides what assert means at include time.
Line 2assert(++calls > 0) expands to ((void)0), so the increment is deleted along with the test.
Line 3printf therefore reports 0: the side effect lived inside the assertion and vanished with it.
Line 4Delete the #define NDEBUG line and the same program prints calls = 1.
A branch you do not build is not checked
Demonstrates that a syntax error hides in a false branch, and that an unknown identifier in #if is silently 0.
<stdio.h>
USE_FAST_PATH
int main(void)
{
prinf("fast path\n"); /* typo, never diagnosed while this is off */
printf("slow path\n");
/* never defined anywhere */
printf("extra checks\n");
return 0;
}
Example explained
Line 1#if USE_FAST_PATH evaluates 0, so the whole branch, typo included, is removed before the parser runs.
Line 2Change USE_FAST_PATH to 1 and the build fails on prinf: the error was always there, just unreached.
Line 3In #if ENABLE_EXTRA_CHECKS an identifier that survives expansion becomes 0, so an unknown or misspelled name is quietly false.
Line 4Compiling with -Wundef makes the compiler report that ENABLE_EXTRA_CHECKS was assumed to be 0.
Platform chain with a loud fallback
Picks a value per platform and turns an unrecognised target into a build failure instead of a wrong default (output shown for Linux or macOS).
<stdio.h>
PATH_SEP
PATH_SEP
int main(void)
{
printf("logs%cdaily.txt\n", PATH_SEP);
return 0;
}
Example explained
Line 1_WIN32 is defined by MSVC and MinGW on both 32- and 64-bit Windows, so it is the usual "this is Windows" test.
Line 2__APPLE__ is the documented macro for Apple platforms, so it is tested explicitly rather than trusting __unix__ alone.
Line 3#error fires during preprocessing, so a new target fails immediately with your message instead of misbehaving later.
Line 4%c receives the char PATH_SEP; the identical source built on Windows prints logs\daily.txt.
Important notes
#if evaluates integers only. sizeof, floating point and enum constants do not exist yet at preprocessing time, so #if sizeof(long) == 8 is a hard error; test #if defined(__LP64__), or include <limits.h> and write #if LONG_MAX > 2147483647.
The main program prints built for linux only because it was compiled on Linux; MSVC on the same source prints windows. That is the point of the chain, but it also means one run can never exercise your other branches.
Common mistakes
Testing #ifdef LOG_LEVEL to see whether logging is enabled: #define LOG_LEVEL 0 is still defined, so the disabled logging compiles anyway. The value test is #if LOG_LEVEL.
Misspelling a macro name in #if, as in #if defined(_WIN32_) or #if DEBUGG: the condition is silently false and the branch disappears with no error message, so you debug a program that is missing code you wrote.
Putting real work inside assert, as in assert(load_config() == 0): a release build defines NDEBUG and the call is never emitted, so the program runs unconfigured only in production.
Try it yourself
Change, predict, then run
Change LOG_LEVEL to 0 in the main program and predict the two-line output before running it. Then add an #elif LOG_LEVEL == 1 tier whose TRACE prints [warn] instead of [trace], and confirm that 0, 1 and 2 each produce a different program.
Open the C workspaceCheck your understanding
A file contains #define FAST 0 and later #ifdef FAST followed by fast_path(); and #endif. Nothing else mentions FAST. What happens?
- fast_path() is compiled, because #ifdef asks only whether FAST was defined, and 0 is a value like any other
- fast_path() is skipped, because FAST expands to 0 and 0 is false
- The build fails, because #ifdef requires a macro defined without a replacement value
- fast_path() is compiled but the call is removed later, since the optimizer sees FAST is 0
Show answer
#ifdef and #if defined(X) test existence, and #define FAST 0 defines FAST, so the block is kept. The "skipped because it expands to 0" answer confuses the two tests: only #if FAST evaluates the value and drops the block. The optimizer answer is also wrong, because FAST never appears in the emitted code at all, so there is no constant for it to fold.