The C Path — learn C, visually

🎩 The Preprocessor

Conditional compilation: code that decides to exist

⏱ 11 min · free interactive lesson · quizzes, visualizations & a real compiler

▶ Open the interactive lesson — free, no signup
Why you're learning this

One game's source code can build on both Windows and Linux, even though each system needs code the other can't even compile — and the chatty "debug mode" messages developers rely on vanish completely from the version players download, at zero cost in speed. Both feats are the same trick: code that gets erased before the compiler ever looks at it. Soon you'll be flipping whole features on and off with a single compiler flag.

An if statement chooses at runtime. The preprocessor's #if chooses at build time — while your program is being compiled — and the losing side isn't skipped, it's deleted before the compiler ever sees it. That deleted code can call functions that only exist on another operating system, sit half-written, or make no sense at all: deleted text can't cause errors.

The directive family

directivemeaning
#if exprkeep block if the constant expression is non-zero
#ifdef NAMEshorthand for #if defined(NAME)
#ifndef NAMEshorthand for #if !defined(NAME) (hello, include guards)
#elif exprelse-if chain
#else / #endiffallback / mandatory closer
#elifdef / #elifndefC23 shorthands for #elif defined / #elif !defined

The expression after #if is an integer constant expression evaluated by the preprocessor: arithmetic, comparisons, &&/||, and the special operator defined(NAME), which is 1 if the macro exists (regardless of its value). No sizeof, no casts, no floats, no enum constants — the preprocessor knows only macros and integers.

⚠️

Sneaky rule: in a #if expression, any identifier that is not a defined macro silently becomes 0. So #if VERSOIN >= 2 (typo!) is always false — no error, no warning by default. GCC's -Wundef catches this; turn it on.

🧠 Checkpoint: In #if MY_FLAG == 1, what happens if MY_FLAG was never defined?

  • Preprocessor error: unknown identifier
  • The identifier is treated as 0, so the block is skipped
  • The block is kept as a safe default
  • The compiler asks the linker
Show answer

The identifier is treated as 0, so the block is skipped — Undefined identifiers in #if expressions quietly evaluate to 0 — a rich source of typo bugs. Compile with -Wundef to get warned.

Platform detection

Compilers predefine macros that identify the target OS — the standard way to write portable code with unportable pieces:

platform.c
#include <stdio.h>

#if defined(_WIN32)
    #define PLATFORM "Windows"
#elif defined(__APPLE__)
    #define PLATFORM "macOS"
#elif defined(__linux__)
    #define PLATFORM "Linux"
#else
    #define PLATFORM "something exotic"
#endif

int main(void) {
    printf("compiled for: %s\n", PLATFORM);
    return 0;
}

This spot has an interactive flow widget — open the interactive lesson to play with it.

Only ONE of those branches survives preprocessing — on Linux, the compiler literally never sees the string "Windows". That's why the Windows branch could call <windows.h> functions that don't exist on Linux, and still build fine there.

🧠 Checkpoint: On Linux, what does the compiler (not the preprocessor) see of the _WIN32 branch?

  • It sees it but skips code generation
  • It sees it as a comment
  • Nothing — the text was deleted before parsing
  • It compiles it into a disabled section
Show answer

Nothing — the text was deleted before parsing — That is the superpower of conditional compilation: the dead branch can reference Windows-only headers and functions, because on Linux that text simply no longer exists after preprocessing.

Debug builds: -D defines macros from the command line

The most-used pattern in all of C: log verbosely in development, compile the logging away entirely in release. The switch is gcc -DDEBUG, which acts exactly like a #define DEBUG 1 at the top of every file:

app.c — the DEBUG pattern
#include <stdio.h>

#ifdef DEBUG
    #define DBG(...) fprintf(stderr, "[debug] " __VA_ARGS__)
#else
    #define DBG(...) ((void)0)   /* expands to nothing useful */
#endif

int main(void) {
    int items = 3;
    DBG("starting up, items=%d\n", items);
    printf("processed %d items\n", items);
    DBG("done\n");
    return 0;
}
terminal
$ gcc app.c -o app && ./app
processed 3 items
# release build: DBG lines cost literally zero instructions
$ gcc -DDEBUG app.c -o app && ./app
[debug] starting up, items=3
processed 3 items
[debug] done

In the release build the DBG calls expand to ((void)0) — a statement that does nothing and costs nothing. Zero runtime overhead, not even a branch. You can also pass values (-DLEVEL=3) and un-define with -U.

🧠 Checkpoint: What does the -DDEBUG compiler flag do?

  • Enables the debugger
  • Acts like #define DEBUG 1 before the first line of each file
  • Disables optimizations
  • Defines DEBUG only inside main()
Show answer

Acts like #define DEBUG 1 before the first line of each file — -DNAME defines NAME as 1 (or -DNAME=value for a specific value) for the whole translation unit — the command-line twin of #define. -UNAME un-defines. The debugger flag is -g; optimizations are -O.

#if 0: the nuclear comment

if0.c — disabling a block that contains comments
#if 0
    /* old algorithm — kept for reference */
    total = slow_sum(data, n);   /* O(n^2), ouch */
#endif
    total = fast_sum(data, n);

/* trying the same with a comment would die here ^ at
   the FIRST */ ... because block comments do not nest */

C's /* */ comments don't nest — commenting out code that contains comments breaks at the first */. #if 0 ... #endif blocks nest with other conditionals and swallow (almost) anything, making them the standard way to disable a chunk of code temporarily. Just don't ship code full of them.

Feature-test macros: the reverse direction

Conditionals also flow the other way: you define macros to ask system headers for more. POSIX functions like getline or clock_gettime are hidden behind guards inside glibc's headers; defining _POSIX_C_SOURCE 200809L (or _GNU_SOURCE for everything) before any #include unlocks them. If a man page mentions a feature-test macro requirement, this is what it means.

Try it

This spot has an interactive editor widget — open the interactive lesson to play with it.

Conditionals decide what compiles — the next lesson covers the directives that talk back: errors, warnings, and pragmas.

▶ Practice this lesson interactively (with live gcc)