Why this matters
One source tree often has to produce several different programs: a debug build full of checks and logging, and a lean release build; a version for Linux and one for Windows; a build with an optional feature and one without its dependency. C handles this before compilation even starts: the preprocessor can include or drop whole regions of code based on conditions. Every serious C project uses it, starting with the include guard at the top of every header.
The directives
#if expression /* keep the following lines if expression is nonzero */
#elif expression /* else if */
#else
#endif
#ifdef NAME /* same as #if defined(NAME) */
#ifndef NAME /* same as #if !defined(NAME) */
Lines in a region whose condition is false are removed by the preprocessor. The compiler never sees them: they are not compiled, not type-checked, and take no space in the program. This is the big difference from an ordinary if (debug) in C, which is compiled and checked at run time.
#if expressions are evaluated by the preprocessor, so they may only use integer constants, macros that expand to integer constants, arithmetic, comparisons, &&, ||, !, and the special operator defined(NAME). They cannot use variables, sizeof, casts or enum constants, because those do not exist yet at this stage.
source with #if preprocessor what the compiler sees
---------------- ------------ ----------------------
a(); a();
#ifdef VERBOSE VERBOSE not defined
log("x"); ----> drop these lines ---->
#endif
b(); b();
Defining macros from the command line: -D
gcc -DNAME defines NAME as 1 before the first line of the file, and gcc -DNAME=value gives it a value. -UNAME undefines it. This is how build systems switch configurations without editing code:
$ gcc -DVERBOSE -DBUFSIZE=256 main.c -o prog
A worked example
#include <stdio.h>
#ifndef BUFSIZE
#define BUFSIZE 128
#endif
#if BUFSIZE < 64
#error "BUFSIZE must be at least 64"
#endif
#ifdef VERBOSE
#define DEBUG_LOG(...) fprintf(stderr, "debug: " __VA_ARGS__)
#else
#define DEBUG_LOG(...) do { } while (0)
#endif
int main(void)
{
DEBUG_LOG("BUFSIZE is %d\n", BUFSIZE);
printf("C version: %ld\n", __STDC_VERSION__);
#if defined(__linux__)
printf("built for Linux\n");
#elif defined(_WIN32)
printf("built for Windows\n");
#else
printf("built for something else\n");
#endif
#if 0
this is not even valid C, but it is skipped entirely
#endif
return 0;
}
Three builds of the same file:
$ gcc -std=c17 -o c1 cond.c && ./c1
C version: 201710
built for Linux
$ gcc -std=c99 -DVERBOSE -DBUFSIZE=256 -o c2 cond.c && ./c2
debug: BUFSIZE is 256
C version: 199901
built for Linux
$ gcc -DBUFSIZE=16 cond.c -o c3
cond.c:8:2: error: #error "BUFSIZE must be at least 64"
8 | #error "BUFSIZE must be at least 64"
| ^~~~~
What each part does:
- A default that can be overridden.
#ifndef BUFSIZE/#define/#endifsupplies 128 unless the command line already defined it. Without the#ifndef,-DBUFSIZE=256would trigger a"BUFSIZE" redefinedwarning and the file's value would win. - A build-time sanity check.
#errorstops compilation with your message. It catches bad configurations before a program with a too-small buffer can exist. - Debug logging that vanishes. In the
VERBOSEbuild,DEBUG_LOGprints; otherwise it expands to an empty statement, and neither the call nor its string literal ends up in the program. The...and__VA_ARGS__make it a variadic macro that accepts any number of arguments and passes them all along, like printf. ("debug: " __VA_ARGS__relies on adjacent string literals being joined, so the first argument must be a literal format string.) - Predefined macros describe the compiler and platform.
__STDC_VERSION__is201710Lunder-std=c17and199901Lunder-std=c99;__linux__,_WIN32and__APPLE__identify the operating system;__x86_64__and__aarch64__the processor;__GNUC__the gcc major version. To list everything gcc predefines:$ gcc -dM -E - < /dev/null | grep -E "__linux__|__x86_64__|__STDC_VERSION__|__GNUC__ " #define __GNUC__ 14 #define __x86_64__ 1 #define __STDC_VERSION__ 201710L #define __linux__ 1 #if 0removes a block of code, even code that would not compile, and unlike/* */it nests and works on code that already contains comments. It is the standard way to disable a chunk temporarily.
Include guards
The most common conditional of all sits at the top of every header file:
#ifndef GEOMETRY_H
#define GEOMETRY_H
struct point { double x, y; };
double distance(struct point a, struct point b);
#endif
The first time the header is included, GEOMETRY_H is not defined, so the contents are kept and the macro gets defined. If the same header is included again into the same file (directly, or through another header), #ifndef is false and everything is skipped. Without the guard, the second copy of struct point causes error: redefinition of 'struct point'. The next lesson uses guards in a multi-file program. Many compilers also support #pragma once with the same effect; it is not part of standard C but is widely used.
Keeping it under control
Conditional compilation is easy to overdo. Every #ifdef doubles the number of program variants, and code in a disabled branch can rot unnoticed because it is never compiled. Good practice:
- Keep conditional regions small and at the edges (configuration headers, platform wrappers), not scattered through logic.
- Prefer one style per project:
#ifdef FEATUREwith-DFEATURE, or always-defined0/1values tested with#if. - Compile with
-Wundefso misspelled names in#ifare reported. - Build every variant in testing, or the disabled branches will stop compiling without anyone noticing.
Common mistakes
#ifdef DEBUGwhen DEBUG is defined as 0. The code is included;#ifdefdoes not look at the value.- Typos in
#ifnames, which silently evaluate to 0: (only with-Wundef;-Walldoes not enable it).u.c:1:5: warning: "FEATURE_X" is not defined, evaluates to 0 [-Wundef] - Unbalanced directives. A missing
#endifgiveserror: unterminated #ifndefat the end of the file, far from the real omission. - Using C values in
#if:#if sizeof(int) == 4is an error (missing binary operator before token "("), becausesizeofdoes not exist for the preprocessor. - Indenting directives inconsistently so nobody can see which
#endifcloses which#if. A comment helps:#endif /* VERBOSE */.
Recap
#if,#ifdef,#ifndef,#elif,#elseand#endifkeep or drop regions of source before compilation; dropped code is never compiled.-DNAMEand-DNAME=valuedefine macros from the build command;#ifndeflets a file provide a default that the command line can override.#errorstops the build with a message;#if 0disables code.- Predefined macros such as
__STDC_VERSION__,__linux__and__GNUC__describe the build environment. - Include guards (
#ifndef X_H/#define X_H/#endif) make headers safe to include twice.
Going further: look at the top of /usr/include/stdio.h in the sandbox: it starts with an include guard (_STDIO_H) and uses #if throughout to adapt to the compiler and standard version. gcc -E -dM main.c lists every macro defined at the end of preprocessing your file.