JavaRush /Курси /C++ SELF /Заголовок і вихідник: оголошення та визначення

Заголовок і вихідник: оголошення та визначення

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 і .cpp, «оголошення» й «визначення»

Щоб усе це не залишилося набором філософських образів, зафіксуймо матеріал у двох невеликих таблицях. Вони не замінять практику, але допоможуть швидко перевіряти себе, коли ви переносите код.

Оголошення і визначення

Сутність Оголошення Визначення
Функція сигнатура + ; сигнатура + { ... }
Змінна (глобальна)
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, "Купити молоко");
    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, де лежать оголошення.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ