1. Зачем разделяют код на .hpp и .cpp
Когда программа растёт, даже очень дисциплинированный main.cpp начинает напоминать холодильник студента перед сессией: вроде всё нужное есть, но найти там что-то конкретное становится приключением. Многофайловость — это не «прихоть больших проектов», а обычный способ сохранить читаемость: отделить что можно использовать от того, как это сделано. И именно тут появляется пара: заголовочный файл (.hpp) и исходный файл (.cpp).
Представьте, что ваш модуль — это кафе. Заголовок — это меню: по нему посетитель понимает, что можно заказать и в каком виде это подадут. Исходник — это кухня: там повар делает магию, но посетителю не обязательно знать, что у вас там три кастрюли, один чайник и паника.
В терминах C++ это означает следующее: в .hpp обычно лежат объявления (declarations), а в .cpp — определения (definitions). И если вы сейчас думаете «звучит похоже, наверняка я перепутаю» — да, перепутаете. Все перепутают. Но сегодня мы это вылечим.
Объявление и определение: в чём разница на уровне кода
Снаружи кажется, что «объявление» — это просто «что-то написал про функцию», а «определение» — «ну это когда написал тело». И это почти правда, но нам важна точная мысль: объявление сообщает компилятору форму имени, а определение сообщает реализацию.
Компилятору, чтобы проверить вызов функции, нужно знать её сигнатуру: имя, тип результата, типы параметров. А вот чтобы реально выполнить программу, нужно тело (реализация). Поэтому на этапе компиляции чаще всего достаточно объявления, а реализация может находиться в другом .cpp и «подцепиться» позже (мы уже обсуждали, что файлы компилируются раздельно).
Давайте посмотрим минимальную разницу:
// Объявление (declaration)
int add(int a, int b);
// Определение (definition)
int add(int a, int b) {
return a + b;
}
Главный визуальный маркер для новичка: объявление почти всегда заканчивается ;, а определение функции имеет тело в { ... }.
Теперь чуть интереснее: struct обычно и объявляется, и определяется там, где вы описали его поля:
// Это определение типа (и одновременно объявление имени Task)
struct Task {
std::string title;
bool done;
};
То есть не все сущности делятся строго «объявление здесь, определение там» одинаково. Функции делятся идеально, а типы чаще всего «целиком» лежат в заголовке, потому что другим файлам нужно знать, как этот тип устроен (поля, размеры и т. п.).
Что именно считается объявлением: примеры
Пока мы не закрепим это на примерах, мозг будет пытаться упростить всё до «в .hpp пишем что угодно, в .cpp тоже что угодно». А потом наступает момент, когда компилятор говорит: «я не знаю, что такое Task», и вы говорите: «как не знаешь, я же вчера его писал». Поэтому давайте разобьём на понятные случаи.
Объявление функции
Сначала самое базовое. Объявление функции — это её сигнатура без тела:
int count_done_tasks(const std::vector<int>& flags);
Здесь компилятор узнаёт, что существует функция с таким именем и параметрами. Но «как она считает» — не знает.
Определение функции
Определение — это объявление плюс тело:
int count_done_tasks(const std::vector<int>& flags) {
int count = 0;
for (int x : flags) {
if (x != 0) count += 1;
}
return count;
}
Объявление типа и определение типа
Со struct проще: как только вы написали тело struct, вы определили тип.
#include <string>
struct User {
std::string name;
};
Если другой .cpp хочет создать User u;, ему нужно видеть определение struct User { ... }, иначе он не поймёт, сколько памяти нужно под u и какие поля у объекта.
2. Что кладут в .hpp и .cpp
Заголовок .hpp: что в нём должно быть и почему это «контракт»
Когда люди впервые начинают делать .hpp, они часто воспринимают его как «ещё один файл, куда можно перекинуть кусок кода». Но правильнее думать иначе: заголовок — это контракт модуля. Он описывает, чем модуль полезен внешнему миру: какие типы предоставляет, какие функции можно вызвать и какие данные нужно передать.
Важно помнить, что заголовок обычно подключают (#include) в нескольких местах. Поэтому у заголовка появляется особое требование: он должен быть понятным и самодостаточным. Иными словами, если в заголовке фигурирует std::string, то заголовок должен подключить <string>, а не надеяться, что «где-то там раньше кто-то уже подключил». Иначе вы получите проект, который компилируется только при «правильном порядке подключений» — это как код, который работает только по вторникам.
Кстати, хорошая привычка — в заголовках явно писать std::string, std::vector и не «размазывать» using namespace std;. Даже в черновиках стандарта встречаются правки про дисциплину std::-префикса в интерфейсах, то есть идея «писать std:: явно» — не просто придирка преподавателя, а реальная инженерная гигиена.
Давайте зафиксируем «что обычно кладут в .hpp» через практичный пример — мы начнём собирать мини-приложение TaskPad (консольный список задач). Раньше оно могло жить в одном файле, а теперь мы начинаем раскладывать его по модулям.
Исходник .cpp: зачем он нужен, если всё можно написать в .hpp
Логичный вопрос новичка: «Если можно в заголовке написать и объявление, и тело — зачем вообще .cpp?» Вопрос отличный, потому что он показывает здоровое недоверие к лишним сущностям. На практике .cpp нужен, чтобы спрятать детали реализации, уменьшить «шум» в интерфейсе и не заставлять каждый файл проекта «переваривать» все реализации.
В .cpp обычно лежат определения функций. Там же удобно держать вспомогательные функции, которые не должны быть доступны «снаружи», а также тяжёлые подключения стандартных заголовков, которые нужны только для реализации. Например, для печати можно подключить <iostream> в .cpp, а в .hpp не тащить его, если в интерфейсе потоков нет.
Ещё одна практичная причина — скорость сборки больших проектов: когда вы меняете реализацию в .cpp, чаще всего перекомпилируется только этот .cpp. Если же вы меняете код в заголовке, он может «зацепить» перекомпиляцию многих файлов, которые этот заголовок подключают. Сейчас мы не углубляемся в сборочные системы, но интуитивно идея простая: заголовок — это публичная поверхность, её лучше менять реже.
Памятка: .hpp vs .cpp и «объявление» vs «определение»
Чтобы это не осталось набором философских образов, зафиксируем в двух небольших таблицах. Они не заменят практику, но помогут мозгу быстро проверять себя, когда вы переносите код.
Объявление и определение
| Сущность | Объявление | Определение |
|---|---|---|
| Функция | сигнатура + ; | сигнатура + { ... } |
| Переменная (глобальная) | |
|
| struct | «имя типа существует» (бывает отдельно) | |
Мы глобальные переменные специально не развиваем — обычно в учебных проектах они не лучшая практика, а тему линковки и «сколько определений можно» мы будем разбирать позже. Здесь таблица только для интуиции.
Заголовок и исходник
| Файл | Роль | Что чаще лежит внутри |
|---|---|---|
| .hpp | интерфейс (контракт) | объявления функций, определения struct/enum, нужные #include для этих объявлений |
| .cpp | реализация | тела функций, детали и вспомогательные штуки, дополнительные #include для реализации |
3. Практика: выносим TaskPad в модуль tasks
Выносим логику TaskPad в .hpp/.cpp
Сейчас мы сделаем маленький, но очень показательный рефакторинг. Представим, что раньше у нас был один main.cpp, где есть и модель Task, и функции добавления/печати. Мы хотим получить модуль tasks, который можно подключать в разных местах.
Шаг 1: делаем tasks.hpp — «меню» модуля
Начнём с интерфейса. Здесь мы описываем, что такое задача и какие операции мы предоставляем.
// tasks.hpp
#pragma once // смысл разберём позже; пока просто используем как привычку
#include <string>
#include <vector>
struct Task {
std::string title;
bool done;
};
void add_task(std::vector<Task>& tasks, const std::string& title);
void print_tasks(const std::vector<Task>& tasks);
Пара важных мыслей прямо по этому коду.
Во-первых, в заголовке мы подключили <string> и <vector>, потому что они используются в сигнатурах и в полях Task. Заголовок должен быть самодостаточным: если кто-то подключит tasks.hpp, он должен получить все нужные определения типов. Тонкости #pragma once и альтернативы мы разберём позже; сейчас просто примем это как привычный «защитный жест».
Во-вторых, мы используем const std::string& и const std::vector<Task>& в параметрах, потому что копировать строки и вектора без необходимости — удовольствие сомнительное.
Шаг 2: делаем tasks.cpp — «кухня» модуля
Теперь реализуем функции. Здесь уже можно подключать то, что нужно для работы, например <iostream>.
// tasks.cpp
#include "tasks.hpp"
#include <iostream>
void add_task(std::vector<Task>& tasks, const std::string& title) {
Task t{title, false};
tasks.push_back(t);
}
void print_tasks(const std::vector<Task>& tasks) {
for (std::size_t i = 0; i < tasks.size(); ++i) {
std::cout << (tasks[i].done ? "[x] " : "[ ] ")
<< tasks[i].title << '\n';
}
}
Обратите внимание на полезную дисциплину: .cpp подключает свой заголовок "tasks.hpp" — и делает это в самом начале. Иначе легко допустить ситуацию «в .cpp всё компилируется, потому что случайно кто-то подключил <vector> раньше, а заголовок сам по себе сломан». Когда tasks.cpp включает tasks.hpp первым, любые проблемы заголовка вскрываются быстро и честно.
Шаг 3: main.cpp становится тоньше
Теперь main.cpp не обязан знать, как именно печатаются задачи. Он только использует интерфейс.
// main.cpp
#include "tasks.hpp"
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<Task> tasks;
add_task(tasks, "Buy milk");
add_task(tasks, "Learn C++ headers");
print_tasks(tasks);
}
Если запустить, будет примерно так:
[ ] Buy milk
[ ] Learn C++ headers
И вот тут вы впервые реально чувствуете идею «контракт/реализация»: main.cpp знает, что есть add_task и print_tasks, но не обязан знать, как они устроены внутри.
Важная тонкость: заголовок — это не место для «случайных удобств»
На этом моменте у новичка появляется желание «сделать красиво»: написать в заголовке using namespace std;, чтобы везде дальше писать просто string и vector. В .cpp иногда так делают (и то осторожно), но в .hpp это почти всегда плохая идея, потому что вы влияете на каждый файл, который подключит этот заголовок. Это как если бы вы подарили человеку кружку, а она внезапно меняет вкус кофе во всём доме.
Поэтому в заголовках мы придерживаемся дисциплины: пишем std::string, std::vector явно. Такой подход повышает читаемость и снижает шанс конфликтов имён. И это не только «стиль преподавателя»: идея консистентности std::-префикса регулярно всплывает и в документах вокруг стандарта.
4. Типичные ошибки
Ошибка №1: в .hpp написали объявление, а в .cpp определили «почти то же самое».
Очень частая ситуация: в заголовке void print_tasks(const std::vector<Task>&), а в исходнике внезапно void print_tasks(std::vector<Task>&) (без const). На глаз разница маленькая, а для компилятора это разные функции. Итог — либо ошибка «не найдено определение», либо странное поведение, когда вы случайно создали перегрузку. Лечится дисциплиной: копировать сигнатуру из заголовка, а ещё лучше — всегда подключать свой .hpp первым в .cpp, чтобы несоответствие проявлялось сразу.
Ошибка №2: заголовок не самодостаточен и «требует удачного порядка подключений».
Если в tasks.hpp есть std::string, но нет #include <string>, то всё может «случайно работать» в одном файле, где <string> подключён раньше, и внезапно падать в другом. Это особенно коварно: баг появляется не из-за логики, а из-за порядка #include. Привычка простая: если тип используется в заголовке — заголовок сам подключает нужный стандартный header.
Ошибка №3: определения функций кладут в .hpp просто потому что «так быстрее».
В маленьком учебном проекте может показаться, что проще всё написать в заголовке, и оно даже будет работать. Но при подключении такого заголовка в несколько .cpp вы рискуете получить неприятности на этапе сборки (не углубляемся сейчас в механику, но вы можете увидеть ошибки компоновки). Практическое правило на сегодня: в .hpp кладём объявления, а тела обычных функций — в .cpp.
Ошибка №4: в .hpp тянут лишние подключения, которые нужны только реализации.
Например, вы печатаете задачи через std::cout и подключаете <iostream> в tasks.hpp. Так вы заставляете любой файл, который подключит tasks.hpp, тоже «тащить» <iostream>, даже если печати нигде нет. В маленьких проектах это просто шум, в больших — ощутимая нагрузка на сборку. Правило простое: если что-то нужно только для реализации — это почти всегда кандидат в #include внутри .cpp.
Ошибка №5: пытаются подключать .cpp через #include, чтобы «склеить файлы».
Иногда новичок видит, что main.cpp «не видит» функцию из tasks.cpp, и делает #include "tasks.cpp". Это технически может привести к странным последствиям и ломает модель раздельной компиляции. .cpp предназначены для отдельной компиляции, а подключаем мы обычно .hpp, где лежат объявления.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ