Skip to content

10. Headers, Namespaces & Multi-File Programs

What & why

Real programs don't fit in one file. C++ splits code into source files (.cpp) that are compiled separately and header files (.hpp or .h) that share declarations between them. Understanding how the compiler and linker cooperate explains a whole family of confusing errors — and it's where C++ differs most from languages with a module system.

How a build actually works

math_utils.cpp ──compile──> math_utils.o ─┐
                                           ├──link──> app
main.cpp       ──compile──> main.o       ─┘
  1. Preprocessing — #include literally pastes the contents of a file in place. #define and #if are handled here.
  2. Compiling — each .cpp (a translation unit) is compiled on its own into an object file. The compiler only needs declarations of things defined elsewhere.
  3. Linking — the linker glues object files together and resolves every call to its definition.

So:

  • "'foo' was not declared in this scope" → a compiler error: you forgot to #include the header.
  • "undefined reference to 'foo'" → a linker error: it's declared, but you didn't compile/link the .cpp that defines it.

Splitting a program

math_utils.hpp — the interface

#pragma once           // include this file at most once per translation unit

#include <vector>

namespace mathx {

double mean(const std::vector<double>& values);
double median(std::vector<double> values);

constexpr double square(double x) { return x * x; }   // small inline definitions are OK

}  // namespace mathx

math_utils.cpp — the implementation

#include "math_utils.hpp"   // quotes: your files; angle brackets: system/library files

#include <algorithm>
#include <numeric>

namespace mathx {

double mean(const std::vector<double>& values) {
    if (values.empty()) return 0.0;
    return std::accumulate(values.begin(), values.end(), 0.0) / values.size();
}

double median(std::vector<double> values) {
    if (values.empty()) return 0.0;
    std::ranges::sort(values);
    auto n = values.size();
    return n % 2 == 1 ? values[n / 2] : (values[n / 2 - 1] + values[n / 2]) / 2.0;
}

}  // namespace mathx

main.cpp — the user

#include <iostream>

#include "math_utils.hpp"

int main() {
    std::vector<double> data{3, 1, 4, 1, 5, 9, 2, 6};
    std::cout << "mean:   " << mathx::mean(data) << '\n';
    std::cout << "median: " << mathx::median(data) << '\n';
}

Build it

# all at once
g++ -std=c++20 -Wall -Wextra main.cpp math_utils.cpp -o app

# or separately — only recompile what changed
g++ -std=c++20 -Wall -Wextra -c math_utils.cpp     # → math_utils.o
g++ -std=c++20 -Wall -Wextra -c main.cpp           # → main.o
g++ main.o math_utils.o -o app                     # link

Once you have more than a few files, a build system does this for you — CMake in the intermediate track.

What goes where

Header (.hpp) Source (.cpp)
function declarations function definitions
class/struct definitions member function definitions
constexpr / inline functions, templates everything else
#includes the header itself needs #includes only the .cpp needs

The One Definition Rule (ODR)

Each non-inline function or global variable must be defined exactly once across the whole program. Put a normal function body in a header that two .cpp files include, and you get:

multiple definition of `mathx::mean(...)'

Fix: move the body to a .cpp, or mark it inline (the linker then accepts identical copies).

Include guards

If a header is included twice in one translation unit, its contents appear twice → redefinition errors. Every header needs a guard:

#pragma once          // supported by every mainstream compiler

or the portable classic:

#ifndef MATH_UTILS_HPP
#define MATH_UTILS_HPP
// ... contents ...
#endif

Namespaces

Namespaces prevent name collisions between libraries:

namespace geometry {
    double area(double r);
}
namespace finance {
    double area(double w, double h);   // no clash
}

geometry::area(2.0);

Pulling names in:

using std::cout;                   // one name — fine in a .cpp
using namespace std::chrono;       // everything — OK inside a function or .cpp
namespace fs = std::filesystem;    // alias — very common

Never write using namespace ...; at the top level of a header — it leaks into every file that includes it.

Things only one .cpp needs should be hidden from the linker using an anonymous namespace:

namespace {
int helper(int x) { return x * 2; }   // visible only inside this .cpp
}

A tour of useful standard headers

Header Gives you
<iostream> <fstream> <sstream> streams: console, files, strings
<string> <string_view> <format> text
<vector> <array> <map> <unordered_map> <set> containers
<algorithm> <numeric> <ranges> algorithms
<cmath> <numbers> <random> math
<chrono> time, durations, clocks, calendars
<filesystem> paths, directories, file metadata
<optional> <variant> <utility> <tuple> vocabulary types
<memory> smart pointers
<thread> <mutex> <atomic> concurrency

A quick taste of <chrono>:

#include <chrono>

auto start = std::chrono::steady_clock::now();
// ... work ...
auto elapsed = std::chrono::steady_clock::now() - start;
std::cout << std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count() << " ms\n";

Command-line arguments

main can receive the arguments passed to your program:

#include <iostream>
#include <string_view>
#include <vector>

int main(int argc, char* argv[]) {
    std::vector<std::string_view> args(argv + 1, argv + argc);   // skip program name
    for (auto arg : args) {
        std::cout << "arg: " << arg << '\n';
    }
}
./app add "Buy milk" --priority high

argc is the count (including the program name at argv[0]).

Common mistakes

  • undefined reference — you forgot to compile/link a .cpp, or the signatures in the header and source don't match exactly.
  • multiple definition — a non-inline function or global defined in a header.
  • Missing include guard.
  • using namespace std; in a header.
  • Circular includes — a.hpp includes b.hpp includes a.hpp. Use forward declarations (class B;) to break the cycle.
  • Relying on transitive includes — include what you use, even if it "happens to work" through another header.

Exercises

  1. Split the functions from lesson 9's exercises into stats.hpp / stats.cpp and a main.cpp. Build it with separate -c steps.
  2. Deliberately cause each error: delete an #include (compiler error), leave stats.cpp off the command line (linker error), put a function body in the header and include it from two .cpp files (ODR error). Read each message.
  3. Put your functions in a namespace and create a namespace alias for it in main.cpp.
  4. Write a program args that prints each command-line argument with its index, and says whether it looks like a flag (starts_with("--")).
  5. Time how long it takes to sort a vector of 1,000,000 random ints using <chrono>. Compare -O0 and -O2.

Recap

  • Headers declare, sources define; the preprocessor pastes, the compiler compiles each .cpp alone, the linker connects them.
  • "not declared" = missing include; "undefined reference" = missing definition at link time.
  • Every header needs #pragma once.
  • One Definition Rule: define non-inline functions exactly once.
  • Namespaces group names; never using namespace in a header; anonymous namespaces hide helpers.
  • main(int argc, char* argv[]) receives command-line arguments.

Next: Files & error handling →