C++ Preprocessor Directives: #include, #define, and #ifdef
Key takeaways
The preprocessor is a text-substitution pass that runs before the compiler sees your code. This post covers #include, #define, conditional compilation and #pragma, the classic macro bugs with the exact GCC diagnostics, and when constexpr or templates should replace a macro.
What the preprocessor actually is
The C++ preprocessor is a separate, fairly dumb pass that runs before the compiler proper. It knows nothing about types, scopes, namespaces or functions. It works on tokens: it reads your source file top to bottom, executes lines that start with #, pastes the contents of included files in place, and replaces macro names with their definitions. What comes out is one long translation unit that the compiler then parses as C++.
That single fact explains almost every preprocessor surprise. A macro is not a function, so its arguments are not evaluated once. A #define is not a variable, so it has no scope and cannot be put in a namespace. An #if is not an if, so code in the dead branch never has to compile at all — which is exactly why it is useful for platform code and exactly why it rots silently.
#include <iostream> // paste the header's text here
#define MAX 100 // from now on, replace the token MAX with 100
#ifdef DEBUG // keep the following lines only if DEBUG is defined
// debug-only code
#endif
It is also worth correcting a common simplification: the preprocessor does not run “all includes first, then all macros, then all conditionals”. It processes directives in a single pass, in source order. A macro defined on line 10 is not visible on line 9, and an #include inside an #ifdef only happens if that condition is true at the point it is reached.
Looking at the output
When something odd happens, the fastest diagnostic is to look at what the compiler actually received:
# GCC / Clang: stop after preprocessing (-P drops the #line markers)
g++ -E -P file.cpp -o file.i
# Show every macro defined at the end of preprocessing
g++ -E -dM file.cpp | sort
# MSVC: write preprocessed output to file.i
cl /P file.cpp
-E output for a file that includes <iostream> is tens of thousands of lines, which is itself a useful lesson in why headers affect build times. Search for your own function near the bottom.
The main directives
// 1. #include: textual inclusion
#include <iostream> // searched in system/include paths
#include "myheader.h" // searched next to the current file first, then include paths
// 2. #define / #undef
#define PI 3.14159
#define MAX(a, b) ((a) > (b) ? (a) : (b))
#undef MAX
// 3. #ifdef / #ifndef: "is this macro defined at all?"
#ifdef DEBUG
#define LOG(x) std::cout << x << '\n'
#else
#define LOG(x)
#endif
// 4. #if / #elif / #else: integer constant expressions
#if VERSION >= 2
// version 2+
#elif VERSION == 1
// version 1
#else
// anything else
#endif
// 5. #error: stop the build with a message
#if !defined(__cplusplus) || __cplusplus < 201703L
#error "This library requires C++17"
#endif
// 6. #pragma: implementation-defined instructions
#pragma once
The difference between #ifdef X and #if X matters more than it looks. #ifdef asks only whether the name exists. #if evaluates it. Build a file with -DDEBUG=0 and #ifdef DEBUG is still true, while #if DEBUG is false. Teams that mix the two styles for the same flag end up with code that is “on” in one file and “off” in another under identical build settings. Pick one convention per macro — usually #if with macros that are always defined to 0 or 1, which also lets -Wundef catch typos.
#if defined(A) && !defined(B) is the compound form; #ifdef cannot combine conditions.
Include guards and #pragma once
Because #include is plain text pasting, including the same header twice pastes the same class definition twice, and the compiler rejects it with error: redefinition of 'class MyClass'. Guards make the second inclusion expand to nothing:
// myheader.h
#ifndef MYPROJECT_MYHEADER_H
#define MYPROJECT_MYHEADER_H
class MyClass {
// ...
};
#endif // MYPROJECT_MYHEADER_H
Or, with the non-standard but universally supported shortcut:
#pragma once
class MyClass { /* ... */ };
The trade-off: #ifndef guards are guaranteed by the standard but depend on the guard name being unique. Copy a header to start a new one, forget to rename the guard, and the second header silently disappears whenever the first was included earlier — you then get baffling “not declared in this scope” errors for types that are obviously in the file. #pragma once has no names to collide, but the compiler has to decide whether two paths are the “same file”, which can go wrong with symlinks or the same header copied into two locations. The header-guard post below goes deeper on this.
Macros: what they are good for, and why they bite
Function-like macros
#define SQUARE(x) ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int x = SQUARE(5); // 25
int m = MAX(10, 20); // 20
Every parenthesis in those definitions is load-bearing. Without them, the text substitution interacts with operator precedence:
#define SQUARE_BAD(x) x * x
int r = SQUARE_BAD(1 + 2); // expands to 1 + 2 * 1 + 2 -> 5, not 9
Parentheses do not fix the deeper problem: the argument is pasted in twice, so it is evaluated twice.
int i = 3;
int s = SQUARE(i++); // ((i++) * (i++))
This is undefined behavior (two unsequenced modifications of i), and GCC tells you so with -Wall:
warning: operation on 'i' may be undefined [-Wsequence-point]
On my g++ 10.3 build it printed 12 5, but the point of undefined behavior is that you cannot rely on any particular result. The same double evaluation makes MAX(expensive(), 0) call expensive() twice. An inline function or a template has none of these problems:
template <typename T>
constexpr T square(T x) { return x * x; } // x evaluated once, type-checked
Another well-known collision: <windows.h> defines min and max as macros, so std::max(a, b) in any file that includes it can fail with confusing parse errors, because max( is replaced before the compiler sees the std::. The standard workarounds are defining NOMINMAX before including <windows.h>, or writing (std::max)(a, b) — the extra parentheses stop the function-like macro from matching.
Multi-statement macros need do/while(0)
// Looks harmless
#define ASSERT_BAD(cond) \
if (!(cond)) { std::abort(); }
if (argc > 5)
ASSERT_BAD(argc < 10); // expands to if(...){...}; <- stray ';' ends the outer if
else
std::cout << "else branch\n";
GCC rejects this with error: 'else' without a previous 'if'. Worse variants compile and bind the else to the macro’s inner if. The idiom that makes a macro behave like one statement is:
#define ASSERT(cond) \
do { \
if (!(cond)) { \
std::cerr << "Assertion failed: " #cond \
<< " at " << __FILE__ << ':' << __LINE__ << '\n'; \
std::abort(); \
} \
} while (0)
This macro also shows why macros still exist in modern code: #cond turns the expression into a string literal, and __FILE__/__LINE__ expand at the call site. A function cannot do the first at all; since C++20, std::source_location covers the second.
Stringizing and token pasting
#define STRINGIFY(x) #x
#define XSTR(x) STRINGIFY(x)
#define CONCAT(a, b) a##b
#define VERSION 3
std::cout << STRINGIFY(VERSION); // prints VERSION
std::cout << XSTR(VERSION); // prints 3
int xy = 10;
int result = CONCAT(x, y); // becomes: int result = xy;
The two-level XSTR trick is needed because arguments to # and ## are not macro-expanded first. STRINGIFY(VERSION) stringizes the token as written; routing through XSTR forces VERSION to expand to 3 before it reaches #. This catches almost everyone the first time they try to embed a version number in a string.
Conditional compilation
// Platform
#if defined(_WIN32)
// Windows (also defined on 64-bit Windows)
#elif defined(__linux__)
// Linux
#elif defined(__APPLE__)
// macOS / iOS
#else
#error "Unsupported platform"
#endif
// Compiler (check __clang__ first: Clang also defines __GNUC__)
#if defined(__clang__)
// Clang
#elif defined(__GNUC__)
// GCC
#elif defined(_MSC_VER)
// MSVC
#endif
// assert() is disabled when NDEBUG is defined (typical release builds)
#ifdef NDEBUG
// release
#else
// debug
#endif
The ordering note on compilers is a real pitfall: Clang defines __GNUC__ for compatibility, so a #ifdef __GNUC__ branch placed first catches Clang too. Ending platform chains with #error rather than an empty #else turns “silently compiled the wrong thing” into a clear build failure on a new platform.
The quiet danger of #if is that an identifier that is not a macro evaluates to 0:
#if FEATURE_X // FEATURE_X never defined -> treated as 0, no error
enableFeature();
#endif
With -Wundef, GCC reports warning: "FEATURE_X" is not defined, evaluates to 0 [-Wundef]. I turn that warning on in any codebase with feature flags; a misspelled flag name that disables a feature without a single diagnostic is a very common way for “it works on my machine” builds to diverge.
The other cost is testing. Every independent #if doubles the number of possible programs, and a branch that no CI job compiles will eventually stop compiling. I have opened a rarely built platform branch and found it referring to a function renamed months earlier — nothing had warned because the preprocessor had been deleting that code before the compiler ever saw it. Keeping #if blocks small, and pushing platform differences behind one header with separate .cpp implementations, limits how much code can rot this way.
#pragma directives
#pragma is the escape hatch for compiler-specific instructions. Unknown pragmas are ignored (GCC warns with -Wunknown-pragmas, which -Wall enables), so they are portable in the sense of not breaking builds, but not in the sense of doing the same thing everywhere.
#pragma once // include guard (GCC, Clang, MSVC)
#pragma pack(push, 1) // no padding between members
struct Packet {
char type;
int length; // sizeof(Packet) becomes 5 instead of 8 on typical ABIs
};
#pragma pack(pop) // always restore, or every later struct is packed too
#pragma message("building with custom config") // print during compilation
#pragma omp parallel for // OpenMP; needs -fopenmp or /openmp, ignored otherwise
for (int i = 0; i < 100; ++i) { /* ... */ }
#pragma pack is useful for matching a wire format, but packed members can be misaligned, which is slower on some CPUs and can fault on architectures that do not allow unaligned access; taking a pointer to a packed int member and passing it to code expecting a normally aligned int* is a known source of such bugs. Forgetting the matching pop in a header is worse: every struct in every file that includes it changes layout, and two translation units can disagree about the same type’s size.
Real-world patterns
Platform abstraction
// platform.h
#if defined(_WIN32)
#define MYLIB_EXPORT __declspec(dllexport)
inline constexpr char kPathSep = '\\';
#else
#define MYLIB_EXPORT __attribute__((visibility("default")))
inline constexpr char kPathSep = '/';
#endif
MYLIB_EXPORT std::string joinPath(const std::string& a, const std::string& b) {
return a + kPathSep + b;
}
Note the split: the export attribute has to be a macro because it is syntax the language cannot abstract, but the separator is an ordinary constexpr value chosen by the #if. Using a macro only for the part that needs one keeps the rest type-checked and visible to the debugger.
Debug logging
// debug.h
#ifndef NDEBUG
#define LOG(level, msg) \
do { \
std::cerr << '[' << level << "] " << __FILE__ << ':' << __LINE__ \
<< ' ' << msg << '\n'; \
} while (0)
#else
#define LOG(level, msg) do { } while (0)
#endif
void processData(const int* data, std::size_t size) {
LOG("INFO", "Processing " << size << " items");
}
Two properties matter here. In release builds, the arguments are not evaluated at all, which is good for cost and bad if someone writes LOG("x", counter++) and changes behavior between builds. And the empty version is still a statement, so if (x) LOG(...); else ... compiles in both modes.
Version gates
// version.h
#define MYLIB_VERSION_MAJOR 2
#define MYLIB_VERSION_MINOR 3
#if MYLIB_VERSION_MAJOR >= 2
#define MYLIB_HAS_NEW_API 1
#else
#define MYLIB_HAS_NEW_API 0
#endif
// user code
#if MYLIB_HAS_NEW_API
newFeature();
#else
oldFeature();
#endif
Defining the flag to 0 or 1 in every branch (instead of leaving it undefined) is what makes #if plus -Wundef effective. Prefix every public macro with your library name: macros ignore namespaces, so a generic VERSION or DEBUG from one header will clash with someone else’s.
When to reach for something other than a macro
| Need | Prefer |
|---|---|
| Named constant | constexpr / inline constexpr variable |
| Small reusable computation | constexpr or inline function, template |
| Compile-time branch on a type or constant | if constexpr (C++17) |
| Call-site file/line | std::source_location (C++20) |
| Platform/compiler/build-mode selection | #if / #ifdef |
| Include guards, stringizing, token pasting | macros (no alternative) |
if constexpr does not replace #if completely: the discarded branch of an if constexpr still has to be valid syntax and, outside templates, name-checked, so it cannot hide code that only compiles on another platform. That is precisely the one job the preprocessor remains best at.