Week 28 · Phase 3 — The Architect
Organising code into .h and .cpp — the C++ compilation model, and how to live with it.
Photo · Jakub Żerdzicki / Unsplash
Every C++ project beyond a single file confronts the same question: where do I put the declarations, where do I put the bodies, and how do they find each other? C++ inherited an answer from C in 1972 that is, fifty years later, still somehow the answer — with all its rough edges. C++20 finally added modules as a real fix, but headers will be everywhere for the foreseeable future. Knowing the model is a survival skill.
.h or .hpp) — declare what exists. Class shapes. Function signatures. Types. Templates. Inline functions. The contract the rest of the program sees..cpp) — define how it works. Function bodies. Static state. The private machinery.The compiler sees one .cpp file at a time (a translation unit). It includes any headers that .cpp mentions, compiles to an object file, and the linker glues all the object files together into a final binary.
// === account.h — the contract ===
#pragma once // or use #ifndef include guards
class Account {
public:
Account(int id); // declarations only
void deposit(double amt);
double balance() const;
private:
int id_;
double balance_;
};
// === account.cpp — the bodies ===
#include "account.h"
Account::Account(int id): id_(id), balance_(0) {}
void Account::deposit(double amt) {
balance_ += amt;
}
double Account::balance() const {
return balance_;
}
The header is the public face — short, scannable, no implementation. The .cpp is the back office. Other code includes account.h and gets full access to the class's interface; the bodies live in one place, are compiled once, and are linked in.
#pragma once and include guardsHeaders can be (and routinely are) included by many files, sometimes transitively several times within one translation unit. Without protection, the compiler would re-process the header repeatedly and complain about duplicate definitions. Two ways to prevent this:
// classic include guards (works on every C++ compiler ever)
#ifndef ACCOUNT_H
#define ACCOUNT_H
// ... contents ...
#endif
// modern shorthand (every modern compiler supports it)
#pragma once
Either is fine; #pragma once is shorter and slightly faster (the compiler can short-circuit the file open). Mix-and-match is OK too.
The most useful rule of thumb:
inline functions; constexpr values; templates (full bodies, alas).The "templates must live in headers" caveat is one of the most painful parts of C++. Because the compiler instantiates a fresh copy of a template per type used, it must see the full body wherever the template is used — which means the body has to be in the header. This is a major reason heavily-templated headers (Boost, Eigen, parts of the STL) cause long compile times.
Including a header is expensive (parse cost, transitive cost) and creates a build-time dependency. If your header only needs to mention a type — as a pointer or reference — you can forward-declare it instead of including:
// in widget.h
class Renderer; // forward declaration; no include needed
class Widget {
public:
void draw(Renderer* r); // pointer is fine without full definition
};
// in widget.cpp:
#include "renderer.h" // the full include lives here
This pattern dramatically reduces compile times in big codebases. Headers stay slim; "deep" includes happen only in .cpp files.
Sometimes you want callers' code to depend only on a class's public interface, not the layout of its private fields. The "pointer to implementation" idiom hides every private detail behind a forward-declared opaque pointer:
// widget.h
class Widget {
public:
Widget();
~Widget();
void draw();
private:
struct Impl; // declared, not defined
std::unique_ptr<Impl> pimpl_;
};
Callers see only the public interface and one opaque pointer. They can be recompiled without depending on the actual layout of Impl. Used heavily in Qt, in PIMPL-friendly libraries, and anywhere ABI stability matters across versions.
For one or two files, you can call g++ by hand. For anything bigger, you'll meet a build system: CMake (the de-facto standard for cross-platform C++), Bazel (Google's, used by TensorFlow), Meson, Make. They all do the same job: figure out which .cpps have changed, recompile only those, link the result, manage dependencies. Don't try to learn build systems before you need to. When you need to, the answer is almost always CMake.
llama.cpp's source tree has roughly 30 .cpp files and 30 .h/.hpp files. PyTorch has thousands. Every one of them follows the same pattern: declarations in headers, bodies in .cpp, build-system glue elsewhere. By the end of this week the ritual should not be opaque.
Phase 3 is now closed. You have C++ — the language and the discipline. Next week we change directions: not new syntax, but a new way of thinking. How do you measure your code? How do you decide when an algorithm is too slow? Phase 4 is the toolbox of the working programmer.
Headers describe; .cpp files do. Keep that boundary clean and your build will be fast.
main.cpp. Compile with c++ account.cpp main.cpp -o app..cpp can include only what it needs..h/.cpp pairing. The pattern is universal.Phase 3 is done. You can now read, write, and reason about a real piece of C++. From next week we leave language-as-language behind and ask the question that animates the rest of the course: what makes one solution to a problem better than another?
Week 29 is Arrays & Vectors — the foundational data structure of every program ever written. Phase 4 — The Toolbox — begins.
Photo free under the Unsplash license. Contract · Jakub Żerdzicki.