TRT
Sign in

Foundations · Module 2 · Lesson 10 of 10

Splitting a Program Across Files: Headers and Include Guards

Split a program into headers and source files, build it as separate translation units, understand what an include guard does, and learn what belongs in a header and what causes multiple-definition errors.

FreeAbout 9 minutes

By the end you can

  • Divide a small program into a header, an implementation file and a main file, and build it
  • Explain what an include guard does and why a header can reach the compiler twice
  • Decide what belongs in a header and what causes a multiple-definition error
  • Diagnose an undefined reference caused by a source file missing from the build

Builds on From Source Text to a Running Program, Functions: Parameters, Return Values and Scope.

01 · A question first

Two files, one function: the example calls square, but square lives in another file that the compiler never sees while it compiles the example. How does the call get connected, and why does including the same header twice sometimes break the build?

Make a guess, then step through the trace below to check it.

02 · Watch it run

A header reached twice in one translation unitstep 1 of 7
#ifndef CONFIG_H#define CONFIG_Hconst int maxSize = 100;#endif
  1. state 0CONFIG_H: undefined
  2. state 1copies pasted: 0
  3. state 2maxSize seen: 0 times

Imagine that a.h and b.h each include config.h, and the example includes a.h and then b.h. The preprocessor therefore meets config.h twice while it builds one translation unit. The code shown is config.h, with an include guard. The cells track the guard macro, the copies pasted, and what the compiler sees.

Machine work: preprocessor: start of the example, no macros defined yet

03 · The idea

Start with the problem

A program in one file works until it grows. Then every small change recompiles everything, several people cannot work on it at once, and nothing can be reused in another program. The solution is to split it across files. But the compiler compiles one file at a time, and while it compiles the example, it cannot see the body of a function that lives in another file.

So how can the example call square at all? You already know the ingredients from lesson 1, which separated a declaration, a promise that something exists, from a definition, which supplies its body, and which gave the linker the job of joining the pieces. This lesson turns them into a working project layout.

How the idea works

Three files. The header holds declarations: what exists. The source file holds the definitions: how it works. The user of the code includes the header and never sees the bodies.

cpp
// math_utils.h
int square(int n);
int cube(int n);
cpp
// math_utils.cpp
#include "math_utils.h"
 
int square(int n) { return n * n; }
int cube(int n) { return n * n * n; }
cpp
// main.cpp
#include <iostream>
#include "math_utils.h"
 
int main() {
    std::cout << square(4) << " " << cube(2) << "\n";
}

Separate translation units. Each .cpp file is preprocessed and compiled on its own, producing its own object file. This is lesson 1 again, with two files:

bash
g++ -std=c++20 -Wall -Wextra -c main.cpp          # main.o has a gap where square is called
g++ -std=c++20 -Wall -Wextra -c math_utils.cpp    # math_utils.o defines square and cube
g++ main.o math_utils.o -o app                    # the linker fills the gap

The usual shortcut is g++ -std=c++20 -Wall -Wextra main.cpp math_utils.cpp -o app. Forgetting a .cpp file in this command is a common multi-file mistake, and it shows up as an undefined reference from the linker.

Quotes and angle brackets. As lesson 2 noted, #include <iostream> searches the standard locations, and #include "math_utils.h" searches your own project first.

Why a source file includes its own header. It lets the compiler check the definitions against the declarations. A different return type is reported at once. A different parameter type is not an error, though: it quietly becomes a new overload, as in lesson 8, and the original declaration stays undefined, which the linker reports later.

Include guards. One header can reach the compiler more than once inside a single translation unit, for example when two headers both include it. Repeated declarations of functions are harmless, but repeated definitions, such as the constant in the trace, are an error. An include guard prevents that. The trace follows it: the first visit finds the macro undefined, defines it and lets the text through (steps 2 to 4), and the second visit finds the macro defined and skips to the end (step 6). The macro name is conventionally the file name in capital letters:

cpp
#ifndef CONFIG_H
#define CONFIG_H
// declarations go here
#endif

Most compilers also accept the shorter #pragma once on the first line, which is not part of the standard but works everywhere in practice.

What belongs in a header. Declarations, constants, types, which module 4 introduces, and functions marked inline. Ordinary function bodies and non-const global variables do not belong there. If two source files include a header with a function body, both object files contain a definition, and the linker reports a multiple definition, as in lesson 1.

Why it is correct

Separate compilation works because the header is a shared contract. Both the code that uses a function and the code that defines it include the same text, so both sides are checked against the same declaration. The compiler verifies every call, and the linker verifies that exactly one definition exists.

The guard works because the preprocessor processes a translation unit from top to bottom and remembers the macros it has defined, so a test for a macro is a reliable record of whether this header has been seen already. Notice what the guard does not do. It resets for every translation unit, since each .cpp file is compiled separately. That is why guards cannot fix a function body in a header: each translation unit still gets its own copy, and the problem appears at link time.

What it costs

Reparsing. Every translation unit that includes a header parses its text again. Lesson 1 showed <iostream> growing a short file to tens of thousands of lines, and in a project of hundreds of files that cost multiplies. Keep headers small, and include only what is needed.

Rebuilds. This is the benefit. If you change the example, only that file is recompiled, and the program is relinked. If you change math_utils.h, every file that includes it, directly or through another header, must be recompiled.

Lost inlining. While compiling the example, the compiler sees only the declaration of square, so it cannot paste the body into the call. For tiny functions in hot code, that can matter. Defining them inline in the header, or building with link-time optimization using -flto, lets the compiler optimize across files.

Guards are nearly free. The preprocessor skips the guarded text quickly, and many compilers remember which files are guarded and do not open them again.

Pitfalls and variations

No guard. The second inclusion of a header with definitions fails with a redefinition error, as step 7 showed.

Function bodies in headers. The link error multiple definition of 'square(int)' means a definition ended up in a header. Move the body to a .cpp file or make it inline.

Forgotten source file. undefined reference to 'square(int)' often means the .cpp file that defines it is not in the build, as in lesson 6.

Colliding guard names. If two headers use the same macro, such as UTILS_H, the second one is skipped silently, and the symptom is confusing errors about missing declarations. Make macro names unique, or use #pragma once.

using namespace std in a header. The directive leaks into every file that includes the header. Never put it in a header.

Circular includes. If a.h includes b.h and b.h includes a.h, the guards stop the infinite loop, but one of the two will be missing declarations. A later module introduces forward declarations as the cure.

Variations. Headers are also written with the extensions .hpp and .hh. A good habit is to include a source file's own header first, which proves the header does not depend on anything included before it. The compiler flag -I adds a folder to the header search path. C++20 modules replace textual inclusion with compiled interfaces, but tool support is still uneven.

Where it is used

Every real C++ project is organized this way. The standard library works the same way on a large scale: <iostream> is a header of declarations, and the definitions live in precompiled library code that the linker adds, as lesson 1 described. Libraries from other people ship a folder of headers and a compiled file, and build systems such as CMake generate the compile and link commands for every source file.

That completes the second module. You can now make decisions, repeat work, write functions, and organize a program across files. The next module introduces arrays, strings and pointers, which put the memory model of lesson 3 to work.

04 · Prove it

Question 1 of 4 · answer from memory

`main.cpp` calls `square`, which is declared in `math_utils.h` and defined in `math_utils.cpp`. You run `g++ main.cpp -o app`. What happens?

05 · Practice

Something wrong with this lesson? Report a problem

Not started

Sign in to save your learning progress.

Sign in to save