Preprocessor
What the preprocessor is and why it exists
When you hit "compile," your .cpp file does not go directly to the C++ compiler. First, a separate tool called the preprocessor runs over it. Its job is specific and limited: text transformation of the source file according to directives that begin with #.
The preprocessor knows nothing about C++ as a language — not scopes, not types, not overload resolution. It works with tokens: it finds directives, inserts files, expands macros, and removes inactive conditional branches. The result of this work is a translation unit — what the compiler actually sees.
The preprocessor's place in the build pipeline
#if, macros
C++ semantics
final binary
# is handled here and disappears before the compiler ever sees the code.
This matters because preprocessor errors — wrong include, broken macro, unclosed #if — often turn into confusing compiler errors far from the real cause.
For more on how preprocessing fits into the overall build, see Program Building.
Terminology
- Directive — a line starting with
#:#include,#define,#if,#pragma. - Macro — a name replaced by another sequence of tokens.
- Object-like macro — a macro without parameters:
#define BUFFER_SIZE 4096. - Function-like macro — a macro with parameters:
#define MAX(a, b) .... - Translation unit — what exists after preprocessing; this is what the compiler actually compiles.
- Include guard — a construction that prevents a header from being included more than once.
- Conditional compilation — selecting code via
#if,#ifdef,#ifndef,#elif,#else,#endif.
#include: physical text insertion
#include literally pastes the contents of another file into the current translation unit:
#include <vector> // standard or system header
#include "player.hpp" // project header
The difference between angle brackets and quotes is in how the file is searched:
#include "file.hpp"usually searches near the current file first, then include paths;#include <file.hpp>searches system and configured include paths.
The exact search order depends on the compiler and build flags. The key point is that every #include increases the volume of text the compiler must parse. Heavy headers, redundant includes, and circular dependencies directly slow down the build.
Include guards and #pragma once
When a header is included multiple times through a chain of includes, class and function definitions get duplicated, and the compiler produces an error.
Classic include guard:
#ifndef PROJECT_ENGINE_PLAYER_HPP
#define PROJECT_ENGINE_PLAYER_HPP
struct Player {
int health = 100;
};
#endif
On the first include, PROJECT_ENGINE_PLAYER_HPP is not defined — the contents pass through. On subsequent includes, the macro is already defined — the entire block up to #endif is cut out.
The guard name must be unique for each file. Two different headers with the same guard name can silently suppress one of them — compiler errors in that case may look completely unrelated to the real cause.
#pragma once:
#pragma once
struct Player {
int health = 100;
};
Shorter and more convenient, but not part of the C++ standard. In practice, GCC, Clang, and MSVC all support it. Rare issues can arise with non-standard file systems, symlinks, and generated headers where the same physical file is accessible under different paths.
Most modern projects use #pragma once. For maximum portability to exotic toolchains, the classic include guard is safer.
#define: object-like and function-like macros
#define creates a name that the preprocessor replaces with specified text every time it appears.
Object-like macro — a simple name substitution:
#define BUFFER_SIZE 4096
char buffer[BUFFER_SIZE]; // preprocessor replaces: char buffer[4096];
Function-like macro — with parameters:
#define SQUARE(x) x * x
int value = SQUARE(2 + 3);
// After substitution: int value = 2 + 3 * 2 + 3; — that is 11, not 25
The classic trap: the macro does not evaluate, it substitutes tokens. Operator precedence then applies to the substituted text, often breaking the result. The fix is parentheses around each argument and around the whole expression:
#define SQUARE(x) ((x) * (x))
int value = SQUARE(2 + 3); // ((2 + 3) * (2 + 3)) = 25
But parentheses do not solve all problems. See the repeated evaluation section below.
#undef removes a previously defined macro:
#undef BUFFER_SIZE
Why #define is a poor substitute for constants and functions
Old C code used #define for constants and functions. In C++ this is obsolete: macros have no types, no scope, no compiler checks.
#define vs constexpr: key differences
— no namespace / scope
— no type checking
— repeated argument evaluation
— names can collide
— invisible to debugger
— respects namespace and scope
— compiler type checking
— no repeated evaluation
— no name collisions
— visible to debugger
Modern replacements:
// Instead of #define BUFFER_SIZE 4096
inline constexpr std::size_t buffer_size = 4096;
// Instead of #define SQUARE(x) ((x) * (x))
template <typename T>
constexpr T square(T x) {
return x * x;
}
A constexpr function is evaluated at compile time where possible, has types and scope, works correctly with templates, and is visible in the debugger.
Macro pitfalls: a detailed breakdown
Repeated argument evaluation
Even correctly placed parentheses cannot help when an argument has side effects:
// DANGEROUS: ++i may be evaluated twice
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int i = 0;
int x = MAX(++i, 10);
// Expands to: ((++i) > (10) ? (++i) : (10))
// ++i can be evaluated twice — behavior is unpredictable
The correct solution is a template function:
template <typename T>
constexpr const T& max_value(const T& a, const T& b) {
return a < b ? b : a;
}
Name collisions with the standard library
A well-known problem: Windows headers (<windows.h>) define MAX and MIN as macros. This breaks std::max and std::min:
// DANGEROUS: do not include windows.h before defining NOMINMAX
#include <windows.h>
#include <algorithm>
int x = std::max(1, 2); // may fail to compile or produce wrong results
// Fix: disable the macros
#define NOMINMAX
#include <windows.h>
This is one of the most common sources of subtle bugs when porting code to Windows.
Macro without parentheses
// DANGEROUS: operator precedence will break the result
#define ADD(a, b) a + b
int x = ADD(2, 3) * 4;
// Expands to: 2 + 3 * 4 = 14, not 20
// Correct:
#define ADD(a, b) ((a) + (b))
Macros ignore namespace and scope
A macro name belongs to no namespace. Writing #define SIZE 100 in one header globally "infects" every file that includes that header. The only way to undo it is #undef.
Multi-statement macros and do { } while (0)
When a macro expands to several statements, wrap it in do { ... } while (0) so it behaves like a single statement in if/else contexts:
#define LOG_AND_RETURN(msg) \
do { \
log(msg); \
return; \
} while (0)
if (failed)
LOG_AND_RETURN("failed"); // works correctly even without braces
else
continue_work();
Without the wrapper, if (failed) log(msg); return; — return would always execute.
Advanced features: # and ##, variadic macros
Stringification (#)
# turns a macro argument into a string literal:
#define TRACE_EXPR(expr) log(#expr, (expr))
int x = 5, y = 3;
TRACE_EXPR(x + y);
// Expands to: log("x + y", (x + y))
// Prints: x + y = 8
This is one of the few places where a macro is truly irreplaceable: an ordinary function cannot turn an expression into a string.
Token pasting (##)
## concatenates two tokens into one:
#define MAKE_ID(prefix, num) prefix##num
int MAKE_ID(user_, 42) = 100; // int user_42 = 100;
Used for generating unique names in X-macro systems and code generation.
Variadic macros (__VA_ARGS__)
#define LOG(fmt, ...) log_impl(__FILE__, __LINE__, fmt, __VA_ARGS__)
LOG("user %d connected", user_id);
// Expands to: log_impl("main.cpp", 42, "user %d connected", user_id)
Useful for logging wrappers, assert macros, and APIs that need to automatically capture __FILE__/__LINE__. If a macro starts growing into a mini-language, that is a signal to reconsider the design.
X-macro: code generation without duplication
The X-macro pattern uses a single "table" of data to generate multiple constructs: enums, string arrays, switch cases, and more.
// Define the table once
#define ERROR_CODES \
X(OK, "Success") \
X(NOT_FOUND, "Not found") \
X(TIMEOUT, "Timeout")
// Generate the enum
enum ErrorCode {
#define X(code, msg) code,
ERROR_CODES
#undef X
};
// Generate the string array
const char* error_messages[] = {
#define X(code, msg) msg,
ERROR_CODES
#undef X
};
X-macros eliminate duplication when a list of items must be reflected synchronously in several places. In modern C++ a similar effect can be achieved with constexpr arrays and templates, but X-macros still appear in low-level and embedded code.
Conditional compilation
#if, #ifdef, #ifndef, #elif, #else, #endif select which code enters the translation unit:
#if defined(_WIN32)
// Windows-specific code
#elif defined(__linux__)
// Linux-specific code
#else
#error Unsupported platform
#endif
#ifdef DEBUG
std::cerr << "debug mode\n";
#endif
Practical advice: keep conditional compilation localized. Scattered #ifdef blocks across business logic make code hard to read, test, and maintain. Isolate platform-specific code behind a clean C++ interface:
// Bad: #ifdef spread through the code
void open_file(const char* path) {
#if defined(_WIN32)
// Windows API
#else
// POSIX API
#endif
}
// Better: one platform layer, the rest of the code stays clean
FileHandle open_native_file(std::string_view path); // implemented in platform.win.cpp / platform.posix.cpp
Predefined macros and feature detection
The standard defines several built-in macros:
__FILE__ // path to the current file (string literal)
__LINE__ // current line number (integer)
__func__ // name of the current function (string literal, C99/C++11)
__cplusplus // standard version: 201703L (C++17), 202002L (C++20), 202302L (C++23)
For feature detection, prefer standard feature-test macros when available:
#ifdef __cpp_consteval
// consteval is supported
#endif
#ifdef __cpp_modules
// C++20 modules are available
#endif
#error stops the build with a clear message — useful for verifying configuration:
#ifndef PROJECT_CONFIGURED
#error "PROJECT_CONFIGURED must be defined by the build system"
#endif
#line changes file and line information in diagnostics. It appears in generated code — for example, yacc/bison emits #line directives so that error messages point to the original grammar, not the generated C++ file.
Inspecting preprocessor output
You can stop the compiler after preprocessing and see exactly what the compiler will receive:
g++ -E main.cpp -o main.i
clang++ -E main.cpp -o main.i
cl /P main.cpp # MSVC: creates main.i
This is invaluable when debugging complex macros and non-obvious include errors. You see the exact text that the compiler will work with.
List all predefined macros in GCC/Clang:
g++ -dM -E -x c++ /dev/null
Generate dependency files for a build system (Makefile, Ninja):
g++ -MMD -MP -c main.cpp
When a macro is still justified
The preprocessor is not obsolete — it has simply settled into its own niche. In real projects it is still needed for:
- Include guards — until C++20 modules are everywhere.
- Platform branches — when code literally must not compile on another platform.
- Feature flags and compile-time configuration — the build system passes
-DENABLE_LOGGING; code reacts via#ifdef. - Library symbol export —
MY_LIB_APIexpands to__declspec(dllexport)or__attribute__((visibility("default")))depending on the platform. - Logging/assert with
__FILE__and__LINE__— a regular function does not know the call site (std::source_locationin C++20 partially solves this, but a macro is simpler). - X-macro patterns in low-level code.
- Generated code — yacc, protobuf, and other generators use directives.
Good C++ code keeps the preprocessor at system boundaries. Inside ordinary logic, prefer language features: types, functions, templates, constexpr, enum class, namespaces, and modules.
Common mistakes
- Macro without parentheses around arguments:
#define ADD(a, b) a + b. - Passing an expression with side effects to a function-like macro:
MAX(++i, j). - Using
#definefor a constant instead ofconstexpr, or for a function instead ofinline/a template. - Non-unique include guard: two different files with the same guard name silently suppress one of them.
- Wide
#ifdefblocks throughout business code — replace with a platform abstraction layer. - Expecting a macro to respect namespace, overload resolution, or access modifiers.
MAX/MINfrom<windows.h>breakingstd::max/std::min— fix:#define NOMINMAXbefore including Windows headers.- Defining functions or variables in a header without
inline— when included in multiple translation units this causes a linker error "multiple definitions." - Depending on non-standard
#pragmabehavior without checking compiler support.
Interview relevance
The preprocessor is a regular part of C++ technical interviews, especially in the context of the build pipeline and understanding the language below standard-library level.
What the interviewer is checking: not syntax knowledge of #define, but understanding of why macros are dangerous, what happens before compilation, and which language features replace them.
Three main question directions:
#definevsconst/constexpr/inline— why a macro is not equivalent to a constant; absence of type, scope, and compiler checks; repeated argument evaluation.
- Include guards vs
#pragma once— what the ODR (One Definition Rule) is, how a guard prevents duplication, why#pragma onceis not standard, and when that matters.
- Macro pitfalls — operator precedence without parentheses, multiple evaluation, name collisions (
MIN/MAXfromwindows.h), no type safety.
Additional directions: conditional compilation (why and how to isolate it), __FILE__/__LINE__ and when a macro is irreplaceable, the X-macro pattern, g++ -E for diagnostics.
Typical candidate mistake: "a macro and const are the same thing, just different syntax." A strong answer explains that const is a C++ object with type, scope, and debugger visibility, while a macro disappears before compilation and obeys none of the language's rules.
Common trick questions:
- "Why can
SQUARE(1 + 2)without parentheses fail to produce 9?" - "What happens if two headers use the same include guard?"
- "Why is
MAX(++i, j)dangerous?" - "What is the difference between
#include <...>and#include "..."?" - "When is a macro still justified in modern C++?"
- "How do you inspect what the compiler sees after preprocessing?"
Summary
The preprocessor is the first build step: it inserts headers, expands macros, and removes inactive branches, turning a source file into a translation unit. It works with text, not C++ semantics — which is exactly why macros know nothing about types, scopes, or overload resolution. In modern C++, they are replaced by constexpr, inline functions, templates, and modules wherever possible. The preprocessor remains necessary at build boundaries, platform configuration, symbol export, and in cases where __FILE__/__LINE__ must be captured automatically.