C++ • Intermediate
Header File Best Practices
By the end of this lesson you'll be able to write headers that compile cleanly anywhere: you'll guard them against double-inclusion, put the right things in the header versus the source file, slim dependencies with forward declarations, and avoid the linker errors that break the One Definition Rule.
What You'll Learn
- Guard every header against double-inclusion with #pragma once (or include guards)
- Tell a declaration from a definition, and put each in the right file
- Keep inline functions, templates, and constexpr in the header on purpose
- Cut dependencies and compile time with forward declarations
- Make headers self-contained by including what you use
- Avoid One Definition Rule linker errors from headers
💡 Real-World Analogy
Think of a header (.h) as the menu in a restaurant and the source file (.cpp) as the kitchen. The menu declares what's available — "Pasta, £9" — so customers (other files) know what they can order, without showing the recipe. The kitchen holds the definition: the actual cooking steps. Many tables can read the same menu, but there's only ever one recipe for each dish — print the full recipe on every menu and the restaurant ends up with conflicting copies. That "one recipe" rule is exactly the One Definition Rule in C++: a thing may be declared in many files, but defined only once.
📊 Header vs Source — What Goes Where
| Goes in the header (.h) | Goes in the source (.cpp) |
|---|---|
| Function declarations (prototypes) | Function definitions (the bodies) |
| Class / struct definitions | Out-of-class member function bodies |
inline functions & templates | Non-inline helper functions |
constexpr / const constants | Static / global variable definitions |
#pragma once at the very top | Its own header included first |
Rule of thumb: the header says what exists; the source says how it works. Templates and inline functions are the exception — they must live in the header because the compiler needs the full body everywhere they're used.
1. Stop Double-Inclusion with #pragma once
A header often gets #included through several paths — main.cpp includes app.h, which includes config.h, and main.cpp includes config.h too. Without protection, config.h gets pasted into the same file twice and you get redefinition errors. The fix is an include guard: put #pragma once on the first line and the compiler reads each header at most once per file. The classic portable form is #ifndef NAME / #define NAME / #endif, but #pragma once is one line, can't be mistyped, and every modern compiler supports it.
🔎 The two forms of include guard
Both do the same job — they stop the body being included twice:
// Modern, preferred: #pragma once // ... header contents ... // Classic, fully portable: #ifndef CONFIG_H #define CONFIG_H // ... header contents ... #endif // CONFIG_H
Pick one per header — you don't need both. Use a unique macro name (often the file path in CAPS) if you go with the #ifndef form.
2. Declarations in the Header, Definitions in the Source
A declaration says a name exists and what its type is (a function prototype, ending in ;). A definition provides the body. Headers should hold declarations; the matching .cpp holds the definitions. The exceptions that do belong in the header are things the compiler must see everywhere they're used: inline functions, templates, constexpr values, and member functions written inside the class body. Read this self-contained header, run it, and check the output against the comments.
Worked example: a good, self-contained header
Note #pragma once, include-what-you-use, and declaration vs definition.
// ===== point.h (a GOOD header) =====
#pragma once // 1) include guard: parse this file at most once
#include <string> // 2) include-what-you-use: we name std::string below
// Only DECLARATIONS live here — no function bodies (except inline/templates).
struct Point {
double x;
double y;
std::string label; // we USE std::string -> we INCLUDE <string>
};
// DECLARATION only: the body lives in point.cpp.
double distance(const Point& a, const Point& b);
// An in
...Now compare it with a header that breaks the rules. The version below has no guard, defines a normal function in the header, and drags in a heavy include — each one a real-world bug. The comments show exactly how to fix each mistake.
Worked example: a bad header (and how to fix it)
Spot the missing guard, the in-header definition, and the heavy include.
// ===== maths.h (a BAD header — three mistakes) =====
// MISTAKE 1: no #pragma once / include guard.
// If two files include this, it is parsed TWICE -> "redefinition" errors.
// MISTAKE 2: a non-inline DEFINITION (a body) in a header.
// If two .cpp files include maths.h, the linker sees TWO copies of
// square() and reports a multiple-definition error (One Definition Rule).
int square(int n) { // <-- body in a header, and NOT marked inline
return n * n;
}
// MISTAKE 3: pul
...Your turn. The header below should be guarded and should only declare its function. Fill in the three blanks marked ___ using the hints, then run it.
🎯 Your turn: fix the header
Add the guard and turn the definition into a declaration.
// ===== greeter.h =====
// 🎯 YOUR TURN — make this header correct. Replace each ___.
___ // 👉 add the one-line include guard for this header
#include <string> // we name std::string below -> include it
// This should be a DECLARATION only (no { ... } body in the header).
std::string greet(const std::string& name)___ // 👉 end the line with a ;
// ===== greeter.cpp (DEFINITION lives here) =====
#include <iostream>
std::string greet(const std::string& name)
...3. Forward Declarations Cut Dependencies
Every #include in a header is a dependency: change the included file and everything that includes your header recompiles too. You can avoid many of these. If your header only needs a pointer or reference to a type — Car* or Car& — you don't need its full definition; a forward declaration class Car; is enough. That breaks the include chain and speeds up builds. You only need the full #include where you store the type by value, call its members, take sizeof, or inherit from it.
Worked example: forward declaration vs include
A pointer member needs only a forward declaration, not the full header.
// ===== engine.h =====
#pragma once
// FORWARD DECLARATION: "a class named Car exists somewhere."
// We only store a POINTER to Car here, so we do NOT need <car.h>.
// That keeps engine.h light and stops a Car change recompiling everyone.
class Car; // <-- no #include "car.h" needed!
class Engine {
Car* owner = nullptr; // a pointer -> forward declaration is enough
public:
void attachTo(Car* c) { owner = c; }
bool isAttached() const { return owner != nullptr; }
};
...Now you try. The header below stores only a pointer to Vehicle, so it shouldn't include the heavy vehicle.h — a forward declaration is enough. Fill in the blank:
🎯 Your turn: forward-declare instead of include
Replace ___ with a forward declaration so the header stays light.
// ===== garage.h =====
#pragma once
// 🎯 YOUR TURN — garage.h only stores a POINTER to Vehicle.
// Replace ___ so the header does NOT include heavy "vehicle.h".
___ // 👉 forward-declare the class: class Vehicle;
class Garage {
Vehicle* parked = nullptr; // just a pointer -> no full type needed here
public:
void park(Vehicle* v) { parked = v; }
bool isEmpty() const { return parked == nullptr; }
};
// ===== main.cpp =====
#include <iostream>
struct Vehi
...🔎 Deep Dive: include what you use, and keep includes light
Include-what-you-use (IWYU): every file should directly include a header for each name it uses, and not rely on getting it "for free" through another header. If you use std::vector, write #include <vector> yourself — don't assume #include <iostream> will drag it in. Self-contained files don't break when an unrelated include is removed.
Compile speed: heavy standard headers like <iostream>, <map>, or <regex> pull in thousands of lines. Including them in a widely-used header multiplies that cost across the whole project. Prefer forward declarations and push heavy includes down into the .cpp files that actually need them.
// In a header: prefer light class Texture; // forward declare, no <texture.h> // In the .cpp: include the heavy stuff where you use it #include "texture.h" #include <vector> // because this .cpp uses std::vector
Pro Tips
- 💡 Guard first, always: the very first line of every header is
#pragma once. Make it muscle memory. - 💡 Include your own header first in each
.cpp. If it's missing an include, you find out immediately instead of by accident. - 💡 Prefer forward declarations in headers: a header with fewer
#includes recompiles far less of the project when it changes. - 💡 Mark header helpers
inline: if a small function body really must live in a header,inlinekeeps the One Definition Rule happy.
Common Errors (and the fix)
- "redefinition of 'struct Point'" (missing guard): a header without
#pragma oncegot included twice into one file. Add#pragma onceas the first line of every header. - "multiple definition of 'square(int)'" (linker, ODR): a non-
inlinefunction was defined in a header included by more than one.cpp. Move the body to a.cpp(leave only the declaration), or mark itinline. - "#include nested too deeply" / hang (circular include):
a.hincludesb.hwhich includesa.h. Break the cycle with a forward declaration — ifa.honly needs aB*, writeclass B;instead of#include "b.h". - "invalid use of incomplete type 'Car'": you forward-declared
Carbut then used it by value, called a member, or tooksizeof. Those need the full type — add#include "car.h"in that file. - "'string' was not declared" in a header: the header uses
std::stringbut doesn't include<string>— it only compiled where some other include happened to provide it. Include what you use: add#include <string>to the header itself.
📋 Quick Reference
| Goal | Do this | Why |
|---|---|---|
| Avoid double-include | #pragma once | Stops redefinition |
| Function in header | int f(int); | Declaration only |
| Function in source | int f(int n){...} | One definition (ODR) |
| Body must be in header | inline / template | Visible everywhere, safe |
| Only need a pointer | class Car; | Forward declare, no include |
| Use a name | #include <string> | Include what you use |
| First include in .cpp | #include "this.h" | Proves it's self-contained |
Frequently Asked Questions
Q: Should I use #pragma once or include guards?
For learning and almost all real projects, use #pragma once — it is one line, impossible to get wrong, and supported by every modern compiler (GCC, Clang, MSVC). Classic #ifndef/#define/#endif include guards are the portable standard fallback and still appear in older or strict cross-platform code. Both solve the same problem: they stop a header being pasted into the same file twice.
Q: What is the difference between a declaration and a definition?
A declaration tells the compiler a name exists and what its type is — like a function prototype int add(int, int);. A definition actually provides the body or storage — int add(int a, int b) { return a + b; }. Headers should mostly contain declarations; the matching .cpp file holds the definitions. The exceptions that may live in a header are things that must be visible everywhere: inline functions, templates, constexpr values, and class member function bodies written inside the class.
Q: Why does my non-inline function in a header cause a linker error?
If a header defines a normal (non-inline) function and that header is included by two .cpp files, the linker sees two identical definitions and complains about a 'multiple definition' / 'duplicate symbol' error. This is the One Definition Rule. Fix it by either moving the body to a .cpp file (keep only the declaration in the header) or marking the function inline so the linker is allowed to merge the copies.
Q: When can I forward-declare a class instead of including its header?
You can forward-declare (class Widget;) whenever you only need a pointer or reference to the type — Widget* or Widget&. That covers most member variables and function parameters/return types in a header. You must include the full header when you store the type by value, call its members, inherit from it, or take sizeof — because then the compiler needs to know the type's real size and layout.
Q: What does 'include what you use' mean?
Include-what-you-use (IWYU) means every file directly includes the header for every name it uses, and does not rely on getting that name 'for free' through some other header. It makes files self-contained: if you use std::vector, include <vector> yourself rather than hoping <iostream> drags it in. This prevents fragile builds that break the moment an unrelated include is removed.
Mini-Challenge: Design a Clean Logger Header
No blanks this time — just a brief and an outline. Apply every rule from the lesson: guard the header, include what you use, declare in the header and define in the source. Build it, run it, and check your output against the example in the comments.
🎯 Mini-Challenge: build a logger header + source
Guard it, include-what-you-use, declare vs define.
// 🎯 MINI-CHALLENGE: design a clean Logger header
//
// Imagine two files: logger.h (the header) and logger.cpp (the source).
// Write them so the project follows every rule from this lesson:
//
// 1. logger.h starts with #pragma once
// 2. logger.h #includes <string> (it names std::string) — include what you use
// 3. logger.h DECLARES: void logMessage(const std::string& msg);
// (declaration only — a ';', no body, so the One Definition Rule is safe)
// 4. logger.cpp #includes
...🎉 Lesson Complete
- ✅ Every header starts with
#pragma once(or#ifndefguards) to stop double-inclusion - ✅ Headers hold declarations;
.cppfiles hold definitions - ✅
inlinefunctions, templates, andconstexprvalues belong in the header - ✅ Forward-declare (
class Car;) when you only need a pointer or reference - ✅ Include what you use — keep every file self-contained, push heavy includes into
.cpp - ✅ The One Definition Rule: declare many times, define exactly once
- ✅ Next lesson: Unit Testing — prove your code works as you build it
Sign up for free to track which lessons you've completed and get learning reminders.