The Preprocessor
A separate language that runs before the compiler and understands nothing about C++. It copies text, substitutes text and deletes text — and every problem it causes traces back to that one property.
Why does #define cause such strange bugs, and why does including a header in a different order change what compiles?
A token sequence being rewritten by a second, much smaller language whose only data type is a token sequence. The preprocessor has no notion of expression, type, scope or declaration; it sees a stream of preprocessing tokens and a set of macro definitions, and it produces another stream. The question this representation exists to answer is "what text does the compiler receive", and the reason it is a separate representation at all is that the answer can differ per translation unit and per build configuration.
Substitution is textual and essentially unconditional: a macro is replaced wherever its name appears as a whole token, with the sole restriction that a macro is not re-expanded recursively within its own expansion. There is no precondition about types, side effects or scope, because the preprocessor cannot express any of those. The absence of a legality condition is the point — it is the only stage in this domain that transforms the program without being able to reason about it, and every hazard below follows from that.
Key points
- The preprocessor is a separate language operating on token sequences; it knows nothing about types, scopes or declarations.
- Macro arguments are substituted, not evaluated, so an argument used twice is evaluated twice — and no parenthesising fixes that.
- Macros are not scoped or namespaced: one defined in a header claims that identifier for the rest of every translation unit that includes it.
- Include order can change meaning, so a header that is not self-contained is a latent build break.
- Code inside a false
#ifdefis never parsed or type-checked, sonflags produce2^nprograms and a CI matrix that samples a few of them. - A header is text pasted into every including unit; there is no compiled form of one, which is the direct cause of C++ build times and the motivation for modules.
It is text, and that is the whole story
"" versus <>, and precompiled-header behaviour are implementation-defined and differ between GCC, Clang and MSVC.The preprocessor is a macro language layered over C++ that shares only the tokenizer. #include copies a file in. #define establishes a substitution. #if and #ifdef delete regions. None of these operations knows what a type is, what a scope is, or whether the result will parse.
That is what makes it so effective for the job it was built for — a mechanism to share declarations that needs no cooperation from the compiler, the build system or the language — and it is what makes every one of its failure modes the same failure mode wearing different clothes. Each hazard below is "the substitution was textual and I reasoned about it as though it were semantic".
The preprocessed output is a real artifact you can read. g++ -E prints it, and doing so once for a header-heavy file changes how most engineers think about C++ builds.
- Physical sourceyou write itCharacters, with line splices and trigraphs still present.
- Preprocessing tokensbuild timeA token stream — but a coarser one than the compiler's: header names and pp-numbers are tokens the language proper does not have.Token boundaries, so that substitution operates on whole tokens rather than on characters.Comments, which are replaced by a single space — which is why a macro cannot be commented out from inside.
- Conditional inclusionbuild timeThe same stream with
#if/#ifdefregions deleted.Configuration. From here the translation unit depends on the command line as much as on the file.The deleted regions entirely — the compiler never sees them, so they are never checked, never type-checked and never compiled. - Inclusionbuild timeThe stream with each
#includereplaced by the preprocessed contents of the named file.The declarations of everything the unit uses.File identity, retained only as#linemarkers so diagnostics can point somewhere useful. - Macro expansionbuild timeThe stream with every macro invocation replaced by its replacement list, arguments substituted.Whatever the macro author intended.The name. After expansion nothing records that this text came from a macro, which is why the compiler's error points at the expansion — see
[[diagnostic-quality]]. - Translation unitbuild timeOne self-contained token sequence handed to the compiler proper.Nothing further; this is the compiler's input.
Read it asRead loses on the conditional-inclusion row: code inside a false #ifdef is not compiled, not checked and not seen. A codebase with fifteen independent feature flags has thirty-two thousand possible translation units, and continuous integration builds perhaps three of them.
Macro hygiene: the substitution does not know what it is inside
A function-like macro is not a function. Its arguments are token sequences, substituted as written, and the result is re-parsed in whatever context it landed in. Two consequences follow immediately, and both bite constantly.
First, precedence. #define SQUARE(x) x * x expands SQUARE(a + b) to a + b * a + b, which is not the square of anything. Parenthesising the parameters and the whole replacement list fixes this specific case and is the reason macro definitions are full of parentheses.
Second, evaluation. Arguments are substituted, not evaluated, so a macro that mentions a parameter twice evaluates its argument twice. MAX(i++, j) increments i once or twice depending on which branch wins. No amount of parenthesising fixes that, because the problem is not precedence but that the macro is not a function call.
Third — the one that gives "hygiene" its name — a macro that declares a temporary can capture a name from the call site. If the macro uses tmp and the caller passes an expression mentioning their own tmp, the two collide silently. Scheme's syntax-rules and Rust's macro_rules! are hygienic precisely because they rename such bindings automatically; the C preprocessor cannot, because it does not know that tmp is a binding.
#define MAX(a, b) ((a) > (b) ? (a) : (b)) int m = MAX(i++, limit());
int m = ((i++) > (limit()) ? (i++) : (limit()));
Textual substitution of the argument tokens is always performed — the preprocessor has no precondition to check. The substitution preserves the programmer's intent only if every argument is free of side effects and is not expensive to evaluate, because an argument mentioned twice in the replacement list is evaluated twice.
Whenever an argument has a side effect or a cost, as here: i is incremented twice on one path and once on the other, and limit() may be called twice. An inline function taking the same arguments evaluates each exactly once, because arguments are evaluated before the call rather than pasted into the body. This is not a case the preprocessor rejects — it expands happily and the program is wrong.
Include order dependence
Because inclusion is textual and macros persist for the rest of the translation unit, a header can change the meaning of every header included after it. A macro named min, max, ERROR or interface defined by one header silently rewrites identifiers in the next one. The Windows headers' min/max macros breaking std::min is the canonical example, and NOMINMAX exists as an entire configuration flag to work around it.
This is why "include what you use", "headers must be self-contained" and "include your own header first in the corresponding .cpp" are standard rules. A header that only compiles when something else was included before it is a landmine: it works everywhere it is currently used and breaks the first time someone reorders an include list or writes a new file.
It is also why macro leakage is a real design concern for library authors. A macro is not namespaced, not scoped and not overridable; defining one in a public header means claiming that identifier in every translation unit of every consumer, forever. Prefixing macro names with a library-specific tag is the only available mitigation, and #undef at the end of a header is the only way to unclaim one.
1// a.h2#define ERROR 13 4// b.h5enum class Status { OK, ERROR }; // fine on its own6 7// unit.cpp8#include "a.h"9#include "b.h" // error: expected identifier, got '1'10 11// swap the two includes and the file compilesNothing is wrong with either header. The bug exists only in the pair, only in that order, and the diagnostic points at b.h — a file that is entirely correct. This is the standard shape of a preprocessor bug: the error is reported far from the cause.
Conditional compilation and the configuration explosion
The #ifdef family selects which code exists in this translation unit. It is how one source tree targets several platforms, several compilers, several feature sets and several build types. It is also the mechanism by which a project acquires a combinatorial number of programs that share a name.
Code inside a false branch is not compiled, so it is not parsed for anything beyond matching #if/#endif nesting, not type-checked, and not covered by any test. A configuration nobody builds is a configuration that has already rotted. With n independent boolean flags there are 2^n translation units and a CI matrix that samples a handful, so the failure mode is a build that has been broken for months for a platform nobody compiles nightly.
The preferred modern alternative is to make the branch a *language-level* condition wherever possible — if constexpr, a trait, a template specialisation — because those are type-checked even when not taken. That converts a text problem into a semantic one, which is the general direction the whole language has been moving in for twenty years.
| Concern | `#ifdef` / macro | Language-level equivalent |
|---|---|---|
| Constant | #define BUF 4096 | constexpr size_t BUF = 4096; — has a type, a scope and shows in the debugger |
| Small function | #define MIN(a,b) ... | inline or constexpr function template — evaluates arguments once, respects overloading |
| Compile-time branch | #if FEATURE_X | if constexpr (feature_x) — the untaken branch is still parsed and type-checked |
| Platform selectiontypical | #ifdef _WIN32 | Still #ifdef in practice: the untaken code may not even name valid types on this platform |
| Include guardimplementation | #ifndef X_H / #define X_H | #pragma once, or a module interface unit — see [[modules]] |
| Debug-only code | #ifdef NDEBUG | Partly: assert is itself a macro, and removing code entirely is a job only the preprocessor can do |
Why a header is copied into every unit that includes it
There is no artifact representing "the compiled form of a header". #include names a file and the preprocessor pastes its text; the compiler then parses that text as part of this translation unit, and again as part of the next one. A project with two hundred .cpp files that all include the same five-thousand-line header parses a million lines of it.
Precompiled headers are the traditional mitigation: parse one designated header set once, serialise the compiler's internal state, and reload it per unit. They work, and they are fragile in exactly the way you would expect — the serialised state is compiler-, version- and flag-specific, and a mismatch is a rebuild at best.
C++20 modules remove the mechanism rather than optimizing it. A module interface is compiled once into a binary artifact; importing it reads that artifact instead of re-parsing text, and macros defined inside the module do not leak to importers. That last property is the one that matters most for this lesson: it converts inclusion from a textual operation into a semantic one, which retires include-order dependence and macro leakage together. See [[modules]] and [[interface-files]].
How it works
The steps, in the order the compiler takes them.
- The source is tokenized into preprocessing tokens, coarser than the compiler's tokens, with comments replaced by a single space.
- Conditional directives are evaluated using only macro definitions and integer constant expressions; false regions are discarded after only bracket-matching.
#includelocates a file by an implementation-defined search of the include path and splices its preprocessed token stream in place of the directive, recursively.- Object-like macros are replaced by their replacement list; function-like macros substitute argument token sequences into it, applying
#(stringize) and##(token paste) before rescanning. - The result is rescanned for further macros, with a rule preventing a macro from expanding recursively inside its own expansion — the "blue paint" rule.
#linemarkers are emitted so the compiler can attribute diagnostics to original files and lines despite the splicing.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A macro argument with a side effect is evaluated twice and a counter advances by two, in a build where the macro happened to expand its parameter twice — with no warning and no visible call.
- Adding an include to a file breaks a completely different file that included it transitively, because a macro from the new header renamed an identifier there.
- A perfectly correct header produces a syntax error because a macro defined earlier in the unit rewrote one of its identifiers; the diagnostic points at the innocent file.
- A platform that nobody builds nightly has not compiled for six months, and the breakage is discovered at release time inside an
#ifdefbranch no test ever reached. - A template error message runs to hundreds of lines because a macro expanded into it, and the expansion — not the macro name — is what the compiler prints.
- Two libraries each define a macro named
ERRORorinterface, and the program compiles or not depending on include order in each file.
When it helps
- Include guards and platform selection, where genuinely no code can be written that is valid on both branches.
- Emitting code that mentions its own source location —
__FILE__,__LINE__, and stringizing an expression for an assertion message — which nothing else in the language can do. - Removing code entirely from a build, as
NDEBUGdoes forassert, where anif constexprwould still require the code to be valid. - Bootstrapping and portability shims in code that must build on compilers predating whatever language feature would replace the macro.
When it hurts
- Anywhere a
constexprvariable, aninlinefunction or a template would do the same job, since all three have types, scopes and debugger visibility that a macro has none of. - In a public header, where every macro is a permanent claim on an identifier in every consumer's translation units.
- As a configuration mechanism at scale, where the number of untested combinations grows exponentially and the untaken branches are not even parsed.
What it costs
Every one of these is paid by something.
- Textual substitution buys a mechanism that needs no cooperation from the type system, the build system or the language, and works identically for C, C++ and Objective-C — and pays with a complete absence of hygiene, scoping, typing and debugger visibility.
- Conditional compilation buys one source tree targeting many configurations, and pays with an exponential number of untested translation units and code that no compiler has ever parsed.
- Copying headers into every unit buys a trivially simple model and a compiler that needs no persistent state between units, and pays with reparse costs that dominate build times in large projects — see
[[compile-time-vs-runtime]]. - Precompiled headers buy back much of that parse time and pay with brittleness: the cached state is tied to a compiler version and a flag set, and a mismatch silently disables it or forces a full rebuild.
What else you could do
What a different compiler or language does instead, and when that is better.
- C++20 modules replace inclusion with import of a compiled interface: no reparse, no macro leakage, no order dependence — at the cost of build-system support and a migration that must proceed as a dependency graph. See
[[modules]]. - Hygienic macro systems — Scheme's
syntax-rules, Rust'smacro_rules!— operate on syntax trees with automatic renaming, so capture is impossible and the expansion is guaranteed to parse. They cannot, however, delete code before parsing. constexpr,constevalandif constexprmove compile-time computation and branching into the language proper, where it is typed and checked even on untaken paths — see[[compile-time-evaluation]].- Code generation from an external tool produces ordinary source that the compiler checks normally; more machinery in the build, but the generated code is real code rather than an expansion.
See it for yourself
The flag, dump or tool that shows you this directly.
g++ -E file.cpporclang -Eprints the translation unit exactly as the compiler will see it.-Ckeeps comments;-dDalso prints the macro definitions.g++ -dM -E - < /dev/nulllists every predefined macro for the current target and flags — the fastest way to find the right#ifdeffor a platform.clang -E -frewrite-includespreserves the include structure while expanding, which makes a preprocessed file readable rather than a flat wall.g++ -Hprints the include tree as it is traversed, with nesting depth. This is the tool for finding out why a header you never mentioned is in your build.include-what-you-useandclang-include-fixeranalyse and repair include lists mechanically.
Plausible wrong readings
Stated the way a confident engineer states them.
- "A macro is just an inline function." An inline function evaluates its arguments once, has types, participates in overload resolution and appears in the debugger. A macro has none of those properties.
- "Parenthesising the macro makes it safe." Parentheses fix precedence. They do nothing about double evaluation, name capture or the fact that the expansion is re-parsed in an unknown context.
- "The
#ifdefcode compiles; it is just disabled." It is discarded before parsing. Nothing has checked it, and it may not even be valid C++. - "Include guards mean the header is only processed once." Once per translation unit. The compiler still parses it for every unit that includes it.
- "Macros are a legacy feature nobody uses."
assert, every include guard, every platform check and most logging in C++ is a macro. The argument is about where they are appropriate, not whether they exist.
Misconceptions
The claim, and what is actually true.
#define PI 3.14159 is how you write a constant in C++.constexpr double pi = 3.14159; has a type, obeys scope, can be found by a debugger, and cannot be accidentally substituted into an unrelated identifier.#ifdef for feature flags keeps the disabled code maintained.Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Before your C++ is compiled, a simpler program rewrites the text: it pastes in the files you #include, replaces each #define name with its text, and deletes the parts inside a false #if. It does not understand C++ at all — it is copying text. Almost every confusing thing macros do comes from that.
practical
Use constexpr for constants, inline/constexpr functions for small functions, and if constexpr for compile-time branches. Reserve macros for the three jobs nothing else does: include guards, platform selection, and anything needing __FILE__/__LINE__ or stringizing. In headers you publish, prefix every macro you must define and #undef what you can. When a build breaks mysteriously after an include is added, run -E and -H before theorising.
advanced
The deep point is that the preprocessor is a *second language with no shared representation*. Every other stage in this domain hands the next one a structured artifact — tokens with spans, a tree with symbols, IR with types. The preprocessor hands the compiler a flat token stream with the provenance stripped and only #line markers to reconstruct it. That is why its diagnostics are bad, why tooling struggles (a refactoring engine must decide whether a rename applies inside a macro body it cannot expand without a configuration), and why [[concrete-syntax-tree]]-based tools in C++ carry so much machinery. Modules are, at bottom, the proposal to stop having two languages.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
"" and <>, #pragma once support and precompiled headers are implementation-defined and differ between GCC, Clang and MSVC — so portable expansion does not imply a portable build.#pragma once is supported by GCC, Clang and MSVC but is not in any standard, and it behaves differently from include guards when the same header is reachable through two paths, symlinks or a network share. Traditional guards are still what a header intended for arbitrary build systems should use.If you were asked this in an interview
- Why is
#define MAX(a,b) ((a)>(b)?(a):(b))still wrong even with all those parentheses? - A header compiles in one file and fails in another with no changes to either. Name the mechanism.
- What do C++20 modules actually remove from this picture, and what do they not?
Connections
- DevOps / Production Engineering — Build configuration matrices and which combinations CI actually exercisesConditional compilation turns a build configuration into a set of distinct programs, and only the combinations CI builds have ever been parsed. Deciding which slice of an exponential matrix to test is a pipeline design question owned there, and this lesson is why it cannot be skipped.