Lesson 1 of 10
Everything so far has been one file. Real programs are hundreds, and the rules that govern how they fit together are not obvious from writing single files.
Start with the thing that surprises people most: #include is not an import. It is a textual
paste, performed before the compiler has parsed a single token.
What the compiler actually sees
// hello.cpp
#include <iostream>
int main() {
std::cout << "hello\n";
return 0;
}
Seven lines, one of which is a comment. Run just the preprocessor on it and count what comes out:
echo "source lines: $(wc -l < hello.cpp)"
g++ -std=c++20 -E hello.cpp -o hello.i
echo "after preprocessing: $(wc -l < hello.i) lines"
echo "distinct headers: $(grep -o '^# [0-9]* "[^"]*"' hello.i | sed 's/.*"\(.*\)"/\1/' | sort -u | grep -c .)"
source lines: 7
after preprocessing: 43173 lines
distinct headers: 196
Seven lines became 43,173. One #include <iostream> pulled in 196 distinct files, and the
compiler parses all of them, every time you compile this file.
That number explains several things at once: why C++ builds are slow, why putting #include in a
header is a decision rather than a convenience, and why C++20 modules exist.
The unit of compilation
A .cpp file plus everything it includes is a translation unit. It is the unit the compiler
processes, and — this is the part that matters — it is processed completely alone.
// math.cpp
int triple(int x) {
return x * 3;
}
// main.cpp
#include <iostream>
int main() {
std::cout << triple(7) << "\n";
return 0;
}
Two files in the same directory, one calling the other. It does not compile:
g++ -std=c++20 -c main.cpp -o main.o
main.cpp: In function ‘int main()’:
main.cpp:5:18: error: ‘triple’ was not declared in this scope
5 | std::cout << triple(7) << "\n";
| ^~~~~~
The compiler never looked at math.cpp. It has no idea it exists. When compiling main.cpp it sees
main.cpp and its includes, and nothing else in the universe.
Declarations tell the compiler; definitions tell the linker
To use triple, main.cpp needs a declaration — a promise that such a function exists
somewhere, with this exact signature:
// main.cpp
#include <iostream>
int triple(int x); // declaration: "this exists, somewhere"
int main() {
std::cout << triple(7) << "\n";
return 0;
}
echo "--- compiling main.cpp alone now works:"
g++ -std=c++20 -c main.cpp -o main.o && echo "ok"
echo "--- but linking it alone does not:"
g++ main.o -o prog
echo "--- with math.o as well:"
g++ -std=c++20 -c math.cpp -o math.o && g++ main.o math.o -o prog && ./prog
--- compiling main.cpp alone now works:
ok
--- but linking it alone does not:
/usr/bin/ld: main.o: in function `main':
main.cpp:(.text+0xe): undefined reference to `triple(int)'
collect2: error: ld returned 1 exit status
--- with math.o as well:
21
Three different outcomes, and the distinction between them is the foundation of everything in this module:
- No declaration → the compiler fails. It does not know the name.
- Declaration but no definition → the compiler is satisfied, the linker fails. It knows the name but cannot find the code.
- Declaration and definition → it works.
A declaration says what something looks like. A definition says what it is. The compiler needs the first; the linker needs the second.
Writing that declaration by hand in every file that needs it is obviously not viable. That is what headers are for, and lesson 17.2 covers the rule that governs them.
The four stages
g++ main.cpp looks like one step. It is four, and each can be run alone:
// add.cpp
int add(int a, int b) {
return a + b;
}
g++ -std=c++20 -E add.cpp -o add.i && echo "1. preprocess -> add.i $(wc -l < add.i) lines"
g++ -std=c++20 -S add.cpp -o add.s && echo "2. compile -> add.s $(wc -l < add.s) lines of assembly"
g++ -std=c++20 -c add.cpp -o add.o && echo "3. assemble -> add.o $(stat -c%s add.o) bytes"
echo "4. link -> needs a main(), so not yet"
echo
echo "the function the compiler produced:"
sed -n '/^_Z3addii:/,/ret/p' add.s
1. preprocess -> add.i 10 lines
2. compile -> add.s 42 lines of assembly
3. assemble -> add.o 1240 bytes
4. link -> needs a main(), so not yet
the function the compiler produced:
_Z3addii:
.LFB0:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl -4(%rbp), %edx
movl -8(%rbp), %eax
addl %edx, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
- Preprocess (
-E) — handles#include,#define,#if. Output is still C++. - Compile (
-S) — C++ to assembly. This is where type checking and optimisation happen. - Assemble (
-c) — assembly to an object file: machine code plus a symbol table. - Link — combine object files, resolve every symbol, produce an executable.
Name mangling
Look at the label the compiler emitted: not add, but _Z3addii.
C++ has overloading, so add(int, int) and add(double, double) are different functions that need
different symbol names. The compiler encodes the signature into the name — _Z, then 3add (a
3-character name), then ii for two int parameters.
g++ -std=c++20 -c add.cpp -o add.o
echo "raw symbol:"
nm add.o | grep add
echo "demangled:"
nm -C add.o | grep add
raw symbol:
0000000000000000 T _Z3addii
demangled:
0000000000000000 T add(int, int)
nm -C demangles. Keep that command — every linker error you will ever read is about a mangled
name, and -C turns it into something legible. It is also why the linker error above said
undefined reference to 'triple(int)' and not just triple: the signature is part of the identity,
so a declaration that disagrees with the definition produces an undefined symbol rather than a type
error.
Try it
Split a small program into three translation units — a main, a computation, and a formatter — and
compile each separately. Then deliberately give one file a declaration that disagrees with the
definition (change a parameter from int to double) and read the resulting error. Finally,
measure how many lines each of <vector>, <string>, <iostream> and <algorithm> adds.
Checkpoint
#includeis a textual paste, done before any parsing.- Measured: a 6-line
hello.cppbecomes 43,172 lines and pulls in 196 headers. - A
.cppplus its includes is a translation unit, compiled entirely on its own. - The compiler never sees your other
.cppfiles. - A declaration satisfies the compiler; a definition satisfies the linker.
- Missing declaration → compiler error. Missing definition → linker error. Learn to tell them apart.
- The four stages: preprocess (
-E), compile (-S), assemble (-c), link. - C++ mangles names to encode signatures:
add(int, int)becomes_Z3addii. nm -Cdemangles an object file's symbols — the single most useful command for link errors.- A declaration that disagrees with its definition compiles cleanly and fails at link time, because the mangled names differ.
- Measured:
<iostream>alone costs 43,155 preprocessed lines.
Questions about this lesson
Log in to ask a question.