1. Базова схема папок проєкту
Коли проєкт маленький, здається, що структура папок — суцільна естетика: мовляв, «ну лежить усе поруч — і гаразд». Але щойно файлів стає більше ніж три, мозок починає працювати, як компілятор без оптимізацій: повільно, нервово й підозріло. Хороша структура репозиторію потрібна не для краси. Вона потрібна, щоб ви швидко розуміли, де що шукати, і щоб проєкт не розвалився, коли ви додасте десятий модуль.
Уявіть, що ви відкрили чужий проєкт. Телепатії у вас немає, зате є файловий менеджер. Якщо структура адекватна, ви буквально за хвилину здогадаєтеся, де точка входу, де заголовки, де реалізація, а де перевірка. Якщо структури немає, ви починаєте шукати main() по всьому проєкту, як голку в сіні. Причому цю голку ще й перейменували на main_final_final2.cpp.
У навчальному курсі структура особливо важлива: ви поступово додаватимете функціональність до одного застосунку. Якщо папки й назви узгоджені, нові шматки коду природно стають на свої місця.
Зараз ми домовимося про дуже просту, але цілком життєздатну «географію» проєкту. Вона не єдина у світі, але для навчання й більшості невеликих застосунків підходить чудово. І головне: вона добре поєднується з нашою моделлю «модуль = .hpp + .cpp» та з ідеєю «інтерфейс окремо від реалізації».
Нижче — невелика таблиця-орієнтир. Її не потрібно завчати, як таблицю множення. Краще зрозуміти логіку: що підключають інші файли і що є результатом збирання.
| Папка | Що там лежить | Як це читати по‑людськи |
|---|---|---|
|
Заголовки .hpp | «Вітрина проєкту»: оголошення типів і функцій, які можна підключати з різних .cpp |
|
Вихідні файли .cpp (і зазвичай main.cpp) | «Кухня проєкту»: реалізації функцій, логіка, деталі |
|
Невеликі перевірочні програми (часто окремі main) | «Перевіримо модуль окремо»: швидко запустити й переконатися, що все базово працює |
|
Артефакти збирання (те, що створює компілятор або IDE) | «Майстерня»: потрібне для збирання, але не є вихідними файлами |
Чому саме так? Бо такий підхід розділяє ролі: вихідні файли — окремо, інтерфейс — окремо, експерименти й перевірки — окремо, результати збирання — окремо. І так, це прибирає вічний хаос у стилі «а що з цього взагалі треба надсилати другові, викладачеві чи в репозиторій».
include/: заголовки й публічний інтерфейс
Коли ви кладете заголовки в include/, ви ніби кажете: «Ось цим можна користуватися». Це особливо зручно, коли у вас кілька модулів і вони підключають заголовки один одного. Тоді include/ стає єдиним джерелом істини, і проєкт менше залежить від випадкового порядку файлів або від того, «у якій папці я зараз відкрив IDE».
Важлива думка: заголовок — не місце, де ви «ховаєте побільше коду». Тут ви пояснюєте решті програми, які типи й функції існують та як ними користуватися. Ми вже обговорювали межу «інтерфейс/реалізація»: у заголовку зазвичай містяться struct, enum class та оголошення функцій, а реалізація — у .cpp.
Ще одна практична причина тримати заголовки в include/: коли у вас зʼявляться окремі перевірочні програми в tests/, вони підключатимуть той самий інтерфейс, що й основний застосунок. Це різко зменшує ризик того, що тести «перевіряють не те».
src/: реалізації та точка входу
Папка src/ — це місце, де міститься все, що реально виконується: тіла функцій, алгоритми, обробка введення та виведення, робота з контейнерами тощо. І майже завжди саме тут лежить main.cpp, бо main — це частина застосунку, тобто реалізації, а не інтерфейсу.
Якщо ви покладете main.cpp куди завгодно, проєкт фізично не зламається — компілятор не образиться. Але морально — так: ви щоразу заново згадуватимете, де вхід у програму. У нормальній структурі точка входу має бути очевидною: відкрили src/, знайшли main.cpp, зрозуміли, звідки стартуєте.
Практичне правило, яке корисно тримати в голові: у src/ можна підключати що завгодно, а в include/ краще бути стриманішими. Ми не заглиблюватимемося зараз у тонкощі того, що саме підключати, — це окрема тема далі в курсі. Але загальний принцип такий: заголовки мають бути максимально простими й стабільними, а реалізація може дозволити собі більше деталей.
tests/: швидкі перевірки модулів
Папка tests/ у нашому курсі — це не про складні тестові фреймворки й не про промисловий CI. Ідея значно простіша: іноді зручно перевірити модуль окремо від усього застосунку. Наприклад, ви написали функцію форматування або друку моделі й хочете переконатися, що вона працює, не запускаючи весь сценарій застосунку.
Зазвичай такі перевірки — це окремий файл з int main(), який підключає заголовок модуля й робить кілька викликів. По суті, це міні‑програма «для себе», але збережена поруч із проєктом. І це різко пришвидшує розробку: ви не витрачаєте час на введення даних, меню чи складний сценарій — просто запускаєте крихітний тестовий main і дивитеся на результат.
Найважливіша перевага папки tests/ в тому, що ви заздалегідь привчаєте проєкт до ідеї «модуль можна використовувати ззовні». А отже, ви майже автоматично починаєте писати акуратніші інтерфейси в заголовках.
build/: артефакти збирання і чому їх не комітять
Папка build/ — це місце, куди потрапляють результати збирання: тимчасові файли, обʼєктні файли, кеш збирання, інколи згенеровані файли проєкту і, зрештою, зібраний виконуваний файл. Вона зʼявляється тому, що збирання — окремий процес із купою технічних кроків.
Чому build/ не зберігають у репозиторії? Бо це не вихідні файли, а «продукти виробництва». По‑перше, вони часто залежать від вашої машини й налаштувань: інша ОС, інший компілятор, інший шлях до проєкту — і вміст уже буде іншим. По‑друге, вони можуть важити чимало. По‑третє, їх легко відтворити: вихідні файли важливіші.
Дуже корисна звичка — ставитися до build/ як до папки, яку не шкода видалити. Якщо вихідні файли на місці, ви будь‑коли зможете зібрати все заново. Це зменшує страх «я щось зламав», бо ви розумієте: ви не видаляєте код, а прибираєте результат збирання.
І ще один момент: build/ краще тримати окремою папкою, щоб вихідні файли та «виробниче сміття» не змішувалися. Тоді ви не побачите в src/ загадкових файлів із розширеннями, яких самі не створювали, і не почнете підозрювати компʼютер в одержимості.
Угода «одна сутність — одна пара файлів»
Коли ви вибудовуєте структуру include/ і src/, дуже зручно мислити так: кожен модуль — це пара файлів. Наприклад, є модуль task — отже, є task.hpp і task.cpp. Це не закон фізики, але хороша угода, яка допомагає тримати проєкт у порядку.
Усередині include/ ви тримаєте заголовок, а всередині src/ — реалізацію. Тоді проєкт читається майже як словник: «шукаю інтерфейс — іду в include/; шукаю, як це зроблено, — іду в src/». А ще це зменшує ризик того, що ви випадково почнете підключати .cpp замість .hpp (так, люди справді так роблять — зазвичай у паніці, і зазвичай це закінчується дивними помилками).
Ідея «одна сутність — одна пара» особливо корисна для новачків, бо прибирає зайвий вибір. Не потрібно щоразу вирішувати: «куди б мені покласти цю функцію?» Ви просто питаєте себе: «вона належить до модуля task?» Якщо так, то в task.hpp буде оголошення, а в task.cpp — реалізація.
2. Практичний приклад: міні‑проєкт Tasky
Зараз зберемо невеликий «скелет» проєкту, який далі можна буде розширювати в курсі. Застосунок буде простим: зберігати завдання (Task) і друкувати його. Наша мета — не крута функціональність, а розуміння того, як файли й папки працюють разом.
Дерево проєкту
Спочатку — структура. Її зручно тримати в голові як мапу:
tasky/
include/
app/
task.hpp
task_print.hpp
src/
task_print.cpp
main.cpp
tests/
task_print_smoke.cpp
build/
(створюється збиранням, у репозиторій не кладемо)
Зверніть увагу на include/app/...: це не обовʼязково, але зручно. Так ви одразу бачите, що заголовки належать вашому застосунку, а не чужій бібліотеці, і назви менше конфліктують.
Модель даних: Task
include/app/task.hpp
#pragma once // (поки що просто як позначка; деталі розберемо іншим разом)
#include <string>
namespace app {
struct Task {
int id = 0;
std::string title;
bool done = false;
};
} // namespace app
Тут ми описали просту модель. Вона лежить у include/, бо Task потрібен «усім»: і застосунку, і тестам, і будь‑яким майбутнім модулям.
Якщо ви помітили #pragma once і думаєте: «А ми це не проходили», — ви маєте рацію. У межах цієї лекції можете сприймати це як технічну позначку, щоб файл випадково не підключали двічі. Детально гігієну заголовків ми розбиратимемо окремо, пізніше.
Інтерфейс друку: окремий заголовок
include/app/task_print.hpp
#pragma once
#include <iosfwd>
#include "app/task.hpp"
namespace app {
void print_task(std::ostream& out, const Task& t);
} // namespace app
Зверніть увагу: заголовок сам нічого не друкує. Він лише повідомляє: «Є функція print_task, ось її сигнатура». Це і є «вітрина».
Реалізація друку в src/
src/task_print.cpp
#include "app/task_print.hpp"
#include <ostream>
namespace app {
void print_task(std::ostream& out, const Task& t) {
out << "#" << t.id << " [" << (t.done ? 'x' : ' ') << "] " << t.title;
}
} // namespace app
Ключова думка: реалізація живе в src/. Ззовні користувачам модуля не важливо, як саме ви друкуєте. Їм важливо знати, що функція існує і як її викликати.
Лаконічний main.cpp у src/
src/main.cpp
#include <iostream>
#include "app/task_print.hpp"
int main() {
app::Task t{.id = 1, .title = "Здати домашнє завдання", .done = false};
app::print_task(std::cout, t);
std::cout << '\n'; // #1 [ ] Здати домашнє завдання
return 0;
}
Цей main справді «тонкий»: він створює приклад завдання й викликає функцію друку. Пізніше ви додасте введення, зберігання списку завдань тощо, але структура залишиться тією самою.
Міні‑перевірка в tests/
tests/task_print_smoke.cpp
#include <iostream>
#include "app/task_print.hpp"
int main() {
app::Task t{.id = 42, .title = "Перевірка друку", .done = true};
app::print_task(std::cout, t);
std::cout << '\n'; // #42 [x] Перевірка друку
return 0;
}
Це не «серйозний юніт‑тест», але корисний «димовий» запуск: ви швидко переконуєтеся, що друк працює, і вам не доводиться запускати весь майбутній інтерфейс застосунку.
3. Типові помилки
Помилка № 1: складати все в src/ і вважати, що заголовки не потрібні.
На перших кроках так справді простіше: один файл, усе поруч. Але щойно вам потрібно повторно використати тип або функцію з іншого .cpp, ви починаєте копіювати оголошення вручну, а потім отримуєте неузгодженість: в одному місці сигнатура змінилася, в іншому — ні. include/ дисциплінує: «оголошення живуть в одному місці».
Помилка № 2: тримати заголовки поруч із .cpp без зрозумілої межі.
Фізично це можливо, але орієнтуватися стає складніше: ви відкриваєте папку й бачите 30 файлів упереміш. Око чіпляється за випадкові речі, і пошук перетворюється на квест. Розділення include/ і src/ — це спосіб зробити межу між «що можна підключати» і «що є реалізацією» очевидною без зайвих пояснень.
Помилка № 3: підключати .cpp через #include, щоб «воно побачилося».
Це майже завжди симптом того, що порушено модель роздільної компіляції, яку ми проходили раніше. .cpp призначені бути окремими одиницями компіляції; підключати їх як текст — означає влаштувати собі дублювання коду й дуже дивні помилки. Якщо вам «не видно функцію», виправляють це заголовком з оголошенням, а не підключенням .cpp.
Помилка № 4: зберігати build/ поруч із вихідними файлами та ще й додавати її до репозиторію або надсилати на перевірку.
Це виглядає спокусливо: «ну там же все вже зібрано!» Але зазвичай такий підхід призводить до того, що проєкт обростає сміттям, а в іншої людини ці файли все одно не підійдуть до її середовища. Нам важливо зберігати вихідні файли, а build/ — відтворювати. Перші кілька разів це психологічно непросто, але потім перетворюється на звичку, яка економить години життя.
Помилка № 5: робити тести, які залазять у «нутрощі» модуля замість того, щоб працювати через його інтерфейс.
Коли перевірки підключають якісь випадкові .cpp або починають залежати від деталей реалізації, ви втрачаєте головний сенс tests/: перевіряти модуль як чорну скриньку. Набагато корисніше, коли в tests/ підключають заголовок із include/ — той самий інтерфейс, яким користується і застосунок.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ