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 і .cpp, «оголошення» й «визначення»
Щоб усе це не залишилося набором філософських образів, зафіксуймо матеріал у двох невеликих таблицях. Вони не замінять практику, але допоможуть швидко перевіряти себе, коли ви переносите код.
Оголошення і визначення
| Сутність | Оголошення | Визначення |
|---|---|---|
| Функція | сигнатура + ; | сигнатура + { ... } |
| Змінна (глобальна) | |
|
| 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, "Купити молоко");
add_task(tasks, "Вивчити заголовки C++");
print_tasks(tasks);
}
Якщо запустити, буде приблизно так:
[ ] Купити молоко
[ ] Вивчити заголовки C++
І саме тут ви вперше по-справжньому відчуваєте ідею «контракт/реалізація»: 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. Звичка проста: якщо тип використовується в заголовку, то заголовок сам підключає потрібний стандартний заголовок.
Помилка № 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, де лежать оголошення.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ