Version 4: Splitting into a Real Project¶
Version 3 is complete, but it all lives in one file that keeps growing. That is fine for a sketch and wrong for anything you maintain. Real projects split the code into headers (declarations — what exists) and source files (definitions — how it works), organised by component and built with CMake. This version reorganises the exact same code into that shape.
The layout¶
One file per component, headers separated from sources — the convention from CMake:
tank-control/
├── CMakeLists.txt
├── include/
│ ├── tank.hpp
│ ├── valve.hpp
│ ├── sensor.hpp # Sensor interface + FixedSensor (header-only)
│ ├── level_sensor.hpp
│ ├── controller.hpp # Controller interface (header-only)
│ ├── pid_controller.hpp
│ └── plant.hpp
└── src/
├── main.cpp
├── tank.cpp
├── valve.cpp
├── plant.cpp
└── pid_controller.cpp
Splitting a class¶
Tank shows the pattern. The header declares it; short functions (the getter) stay inline, the longer one moves to the source file:
// include/tank.hpp
#pragma once
class Tank {
double level_;
double area_;
public:
Tank(double initialLevel, double area);
void update(double inflow, double outflow, double dt);
double level() const { return level_; } // one-liner: fine to leave here
};
// src/tank.cpp
#include "tank.hpp"
Tank::Tank(double initialLevel, double area)
: level_(initialLevel), area_(area) {}
void Tank::update(double inflow, double outflow, double dt) {
level_ += (inflow - outflow) / area_ * dt;
if (level_ < 0.0) {
level_ = 0.0;
}
}
The #pragma once at the top of the header stops it being pasted in twice. Why the getter stays in the header but update moves out — and what it does to compile times — is covered in Classes.
The interfaces (Sensor, Controller) are pure declarations with no bodies to compile, so they live entirely in headers — no .cpp needed. Each remaining class follows the Tank pattern:
| Class | Header | Source |
|---|---|---|
Tank |
tank.hpp |
tank.cpp |
Valve |
valve.hpp |
valve.cpp |
Sensor, FixedSensor |
sensor.hpp |
— (header-only) |
LevelSensor |
level_sensor.hpp |
— (one-liner) |
Controller |
controller.hpp |
— (interface) |
PIDController |
pid_controller.hpp |
pid_controller.cpp |
Plant |
plant.hpp |
plant.cpp |
main.cpp includes the headers it needs and is otherwise the loop from Version 3:
// src/main.cpp
#include <iostream>
#include "plant.hpp"
#include "level_sensor.hpp"
#include "pid_controller.hpp"
int main() {
Plant plant(2.0, 1.0, 0.10, 0.03);
LevelSensor sensor(plant);
PIDController pid(0.8, 0.05, 0.0, 5.0);
Controller& controller = pid;
const double dt = 1.0;
std::cout << "time,level,setpoint\n";
for (int step = 0; step < 80; ++step) {
double measurement = sensor.read();
double opening = controller.compute(measurement, dt);
plant.step(opening, dt);
std::cout << step << "," << measurement << ",5\n";
}
}
The build file¶
CMakeLists.txt lists the sources, points the compiler at include/, fixes the standard at C++20, and turns warnings on for both compiler families — the cross-compiler pattern from CMake:
cmake_minimum_required(VERSION 3.16)
project(tank_control)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(tank_control
src/main.cpp
src/tank.cpp
src/valve.cpp
src/plant.cpp
src/pid_controller.cpp
)
target_include_directories(tank_control PRIVATE include)
if(MSVC)
target_compile_options(tank_control PRIVATE /W4)
else()
target_compile_options(tank_control PRIVATE -Wall -Wextra)
endif()
Header-only classes are not listed in add_executable — only .cpp files are compiled; headers come along through #include. Build it the usual way (cmake -B build && cmake --build build, or just open the folder in CLion), and the same program runs — now as a project that builds cleanly on Windows, Linux, and macOS. See Portability.
What this version shows¶
- Declarations vs definitions — headers say what exists, sources say how. See Classes.
- One component per file, headers in
include/, sources insrc/. - A real CMake project — the layout an industrial codebase uses, scaled down. See CMake.
What's still missing → Version 5¶
The project builds and runs, but nothing checks that it is correct: flip a sign in Tank::update and you would only notice by squinting at the output. Real projects guard their components with tests. Version 5 adds a Catch2 test suite — and, now that there are two things to build (the program and its tests, sharing the same components), splits the build across folders the grown-up way.