JavaRush /Курсы /C++ SELF /Заголовок vs исходник: объявления и определения

Заголовок vs исходник: объявления и определения

C++ SELF
26 уровень , 1 лекция
Открыта

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 «определение»

Чтобы это не осталось набором философских образов, зафиксируем в двух небольших таблицах. Они не заменят практику, но помогут мозгу быстро проверять себя, когда вы переносите код.

Объявление и определение

Сущность Объявление Определение
Функция сигнатура + ; сигнатура + { ... }
Переменная (глобальная)
extern int x;
int x = 10;
struct «имя типа существует» (бывает отдельно)
struct T { ... };

Мы глобальные переменные специально не развиваем — обычно в учебных проектах они не лучшая практика, а тему линковки и «сколько определений можно» мы будем разбирать позже. Здесь таблица только для интуиции.

Заголовок и исходник

Файл Роль Что чаще лежит внутри
.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, где лежат объявления.

1
Задача
C++ SELF, 26 уровень, 1 лекция
Недоступна
Кухня приветствия
Кухня приветствия
1
Задача
C++ SELF, 26 уровень, 1 лекция
Недоступна
Среднее без сюрпризов
Среднее без сюрпризов
1
Задача
C++ SELF, 26 уровень, 1 лекция
Недоступна
Счётчик плюсов
Счётчик плюсов
1
Задача
C++ SELF, 26 уровень, 1 лекция
Недоступна
Рамка и витрина
Рамка и витрина
Комментарии (1)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
kasnil Уровень 55
29 мая 2026
Готовый код создания рамки для задачи Рамка и витрина можно взять из задачи Рамка сообщения