CodeGym /Courses /C++ SELF /Build stages: preprocessing → compile → link

Build stages: preprocessing → compile → link

C++ SELF
Level 30, Lesson 0
Available

1. It's important to understand building in the IDE

At this point in the course you already write programs that can consist of multiple files, and this is the moment when the magic “Run” button sometimes… blinks red. It's important to understand a simple fact: the IDE run button is not a single operation, but a whole chain of steps. And when something breaks, it's useful to be able to say: “Okay, it broke at the step where files are being stitched together,” or “This happened earlier: the file couldn't be properly read through #include.”

In C++ the build almost always splits into three major stages:

  • preprocessing (preprocessing),
  • compile (compilation),
  • link (linking).

We'll talk about them without the command line (no scary g++ -std=c++23 ...), only as the internal pipeline the IDE runs for you.

Big picture: from .cpp to running the program

If you imagine the build as making a pizza (yes, I too wish linking was pizza), then the preprocessor is the stage where you unwrap ingredient packages and read the recipe, the compiler is “cooking separate parts”, and the linker is where everything is put into one box and the lid is closed. It's not a perfect analogy, but it helps keep the roles clear.

As a diagram it looks like this:

flowchart LR
    A[".cpp + #include .hpp"] -->|preprocessing| B["translation unit (text after #include/#define)"]
    B -->|compile| C["object file (.o/.obj)"]
    C -->|link| D["executable (program)"]

Below is a table "input → output" to give you visual anchors:

Stage Input Output What's happening conceptually
Preprocessing The text of a .cpp + included .hpp files + macros One big text (logically) #include inserts files, #define substitutes text, #if/#ifdef cuts out/keeps pieces
Compile The resulting text of one .cpp Object file (.o/.obj) Syntax/type checking + generation of machine code “per part”
Link A set of object files An executable Stitching all parts together, “resolving” function calls with their implementations

Key practical takeaway (for now memorize this): the compiler looks at each .cpp separately, and “putting everything together into a program” is the linker's job.

2. Preprocessing: when #include is “copy-paste, but official”

The preprocessor is a stage that beginners often underestimate because “it's just #include”. Then magic happens: you change one line in a header and suddenly “everything breaks”. Or the opposite: you are sure you wrote some code, but the compiler seems to see different text. There's no mysticism: the preprocessor literally transforms the text before the compiler starts to understand types, functions, and the language rules.

What #include actually does

#include "file.hpp" means: “take the contents of file.hpp and paste it here”. Just like Ctrl+C/Ctrl+V, but without shame and with support for industrial development.

Imagine we continue our example console app TaskBook (a basic todo list). We keep interfaces (declarations) in .hpp and implementation in .cpp.

task.hpp (header, declarations):

#pragma once
#include <string>

void addTask(std::string title);

main.cpp:

#include <iostream>
#include "task.hpp"

int main() {
    addTask("Read about linker");
    std::cout << "OK\n"; // OK
}

When the preprocessor processes main.cpp, it will effectively produce a “single text” where the #include "task.hpp" is replaced with the header contents (plus the contents of headers that header included, e.g. <string>). The compiler then analyzes that.

And here is important: the preprocessor does not understand C++ as a language. It doesn't know what a “function”, “type”, or “template” is. It works with text and directives #....

Macros: text substitution, not “small functions”

A macro is also a purely textual thing. That means it doesn't respect types, scopes, and generally behaves as if you manually replaced a piece of text.

Example:

#include <iostream>

#define SQUARE(x) ((x) * (x))

int main() {
    std::cout << SQUARE(1 + 2) << '\n'; // 9
}

Here parentheses save us from the classic precedence trap. If we had written #define SQUARE(x) x * x, then SQUARE(1 + 2) would turn into 1 + 2 * 1 + 2, and the result would be… a surprise. That kind of surprise is especially annoying because the compiler is not obliged to know you intended “square of a number”; it sees a valid expression.

Conditional compilation: code that “doesn't always exist”

Another important preprocessor tool is conditional compilation. That's when a portion of code is included or excluded before compilation.

For example, let's add a simple diagnostics switch:

config.hpp:

#pragma once
#define ENABLE_DIAGNOSTICS 1

main.cpp:

#include <iostream>
#include "config.hpp"

int main() {
#if ENABLE_DIAGNOSTICS
    std::cout << "Diagnostics ON\n"; // Diagnostics ON
#endif
    std::cout << "Run\n";            // Run
}

Here the compiler will see either both std::cout calls, or only "Run", depending on the macro value. So for the compiler, “cut-out” code effectively does not exist.

A small theoretical (but useful) fact: the C++ standard describes “phases of translation”, and it explicitly separates steps where preprocessor entities are processed and disappear from the text before further analysis; formulations and edits around this are regularly discussed in standard drafts.

4. Translation unit: what it is and why it matters

The phrase “translation unit” sounds like we are about to translate Shakespeare. In practice it's a very practical concept: a translation unit is what you get from one .cpp after the preprocessor. That is, the .cpp plus everything included into it via #include (directly and indirectly), plus expanded macros, plus removed #if sections.

Why is this important? Because compilation happens per translation unit. So if you have main.cpp, task.cpp, storage.cpp, you have three separate translation units, and the compiler will process them separately.

This explains two “oddities” you have probably already seen.

  • Oddity one: “Why did I write a function in task.cpp, but the compiler in main.cpp says such function doesn't exist?” Because the compiler compiles main.cpp separately and needs to see the declaration (usually via a .hpp) directly in main.cpp after #include.
  • Oddity two: “Why does defining a variable in a header suddenly break many files?” Because the header will be included into multiple translation units, and the same definition will be duplicated. This is exactly where the earlier lectures brought up ODR: one program, one definition of an entity (in the required sense).

5. Compile: compilation as “making parts separately”

Compilation is the stage where text (already after preprocessing) becomes an object file. And the main idea of the day is: each .cpp is compiled separately. Not “the whole project at once”, not “all files simultaneously”, but strictly one-by-one.

What an object file is and why you rarely see it

An object file (.o or .obj) is the result of compiling one .cpp. IDEs usually hide these files in build folders so they don't clutter your view, but logically you can think:

  • it already contains machine code for functions from that .cpp,
  • it contains information about which symbols (functions/global variables) this file provides,
  • and which symbols it uses but does not define (for example, calls functions from other .cpp).

Returning to our TaskBook, task.cpp after compilation becomes a “box of parts”: inside is the implementation of addTask, but the box is not yet a runnable program.

Why compilation can succeed even if the program won't link

Here's a common situation that seems paradoxical but is actually completely logical.

task.hpp:

#pragma once
#include <string>

void addTask(std::string title);

main.cpp:

#include <iostream>
#include "task.hpp"

int main() {
    addTask("Buy milk");
    std::cout << "done\n"; // done
}

This main.cpp fully compiles because the compiler only needs the declaration of addTask. It sees: “okay, there is a function that takes std::string and returns void”. The compiler can generate the call code.

But where is the body of addTask? That's not a problem for compiling main.cpp. It becomes a problem later at linking if the implementation is missing or not part of the build.

Why changing a .hpp often “recompiles half the project”

If you change a .cpp, the IDE usually recompiles only that .cpp. But if you change a header .hpp included by many .cpp, the IDE must recompile all affected translation units, because their post-preprocessing text changed.

This is one reason why we discussed “include what you use” and “don't put unnecessary things in .hpp”: headers strongly affect build times.

6. Link: stitching object files into one program

Linking is the final stage: we take several object files and produce a single executable you can run. If compilation is making parts of a model kit, linking is the instruction “how to connect everything into one model so it doesn't fall apart on the first Run”.

What “resolving symbols” means

When main.cpp calls addTask(...), the object file main.o will contain an entry roughly meaning: “I need a function addTask(std::string)”. And the object file task.o will contain an entry: “here it is, the function addTask(std::string), take it”.

The linker acts like a “wedding host” for these two: it finds where the needed symbol is defined and connects the call to the implementation.

  • If the implementation is not found — the linker cannot create the program.
  • If the implementation is found in two different object files (i.e., you defined the same thing twice) — the linker will also complain, because it's unclear which implementation is the real one.

Small example “declared but not defined”

Let's make a small example showing how this looks in a project.

math.hpp:

#pragma once
int add(int a, int b);

main.cpp:

#include <iostream>
#include "math.hpp"

int main() {
    std::cout << add(2, 3) << '\n'; // want 5
}

If the project lacks math.cpp with the implementation of add, then main.cpp may compile, but the program will not link. That's because “you can call a function knowing its declaration”, but “you can only actually execute the call if there's a body somewhere”.

We won't dive into classifying error messages right now — that's a separate lecture. For now we need only the model: linking is the step where the project becomes a single executable.

7. How to think about build stages

How to identify the stage by symptoms

After today's lecture you should develop the habit: when something goes wrong, mentally ask: “At which stage did this happen?”. This helps a lot to avoid aimless searching through code.

  • If the problem is related to #include (a file didn't include, a macro substituted oddly, a piece of code “disappeared”), it's almost always the preprocessor's area.
  • If the problem is that “the code isn't understood as C++” (syntax, types, “cannot convert”, “no such method”), that's the compilation area.
  • If the problem is that “everything was fine, but at the end something didn't match” (implementation not found, two implementations found), that's the linking area.

And yes: you don't have to remember all the names. The main thing is to keep in mind that the IDE “Run” button does not perform one magic, but three. (If it were one magic, C++ would be too forgiving a language, and that's suspicious.)

Practical example: TaskBook goes through the whole pipeline

Now let's carefully build a mini version of TaskBook from three files to see the full path without extra complexity. Note: examples are small, but the structure is already “grown-up”: interface in .hpp, implementation in .cpp, usage in main.cpp.

task.hpp:

#pragma once
#include <string>

void addTask(const std::string& title);

task.cpp:

#include "task.hpp"
#include <iostream>

void addTask(const std::string& title) {
    std::cout << "Added: " << title << '\n'; // Added: ...
}

main.cpp:

#include "task.hpp"

int main() {
    addTask("Learn preprocessing");
    addTask("Learn compilation");
}

Now we mentally run the build.

  • First preprocessing for main.cpp: it will insert the contents of task.hpp (and there, in turn, the <string> will be included). After this the compiler will see the declaration of addTask.
  • Then compile: main.cpp becomes an object file containing main() and calls to addTask whose implementation will be found elsewhere. Separately task.cpp is compiled: there appears the real function addTask.
  • Then link: the linker connects main() and addTask into one program. Only after that can it be executed.

8. Common mistakes

Mistake #1: thinking the compiler “sees the whole project at once”.
This is one of the most common mental traps. It seems logical: “well, my project has all files in one place”. But in fact compilation happens per .cpp. So if you didn't include the required .hpp, a nearby task.cpp won't save you: the compiler compiling main.cpp doesn't have to know about it at that moment.

Mistake #2: expecting the preprocessor to understand types and scopes.
The preprocessor works with text. It doesn't know what std::string is, it doesn't know what a “variable” is, and it can't “neatly substitute a macro”. It will just replace a piece of text. So macros without parentheses, clever #defines, and odd #ifs often produce effects that look like compiler bugs, while in reality it's simple textual substitution.

Mistake #3: keeping definitions in headers “because it's easier”.
Beginners want to put everything in .hpp so it's “clearly visible”. But the header gets included into many .cpps, and definitions (especially globals and non-inline functions) multiply. This can cause the final build to fail: the linker will see multiple identical definitions and refuse to create the program.

Mistake #4: confusing “file exists” with “file participates in the build”.
Even if task.cpp sits in the project folder, it doesn't guarantee the IDE added it to the build. In some environments you must explicitly include the file in the target/project. Then individual file compilations may succeed, but at the final “stitching” step it will look as if the implementation is missing.

Mistake #5: fixing link-stage problems by editing code that already compiles.
When a program “doesn't link”, the instinct is to rewrite main.cpp, add more #includes, or change signatures at random. This often becomes chaotic. It's much calmer to first ask: “is my problem really at linking? then I search for mismatched declarations/definitions or a missing .cpp in the build”. That approach saves hours and reduces desire to “switch to Python” (though Python is not to blame).

1
Task
C++ SELF, level 30, lesson 0
Locked
Greeting module
Greeting module
1
Task
C++ SELF, level 30, lesson 0
Locked
Diagnostics mode
Diagnostics mode
1
Task
C++ SELF, level 30, lesson 0
Locked
Calculator report
Calculator report
1
Task
C++ SELF, level 30, lesson 0
Locked
Shared banner
Shared banner
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION