1. Вступ
Коли проєкт виростає за межі одного файла, #include стає тим, що тримає код разом… або розвалює його на частини, якщо користуватися ним навмання. У невеликих задачах у Web‑IDE ви підключали <iostream> і жили спокійно. Але в IDE‑проєкті зʼявляються папки src/, include/, власні заголовки, а іноді й сторонні. І раптом зʼясовується, що один і той самий рядок #include може означати різне — просто тому, що препроцесор шукає файл за певними правилами.
Практична суть лекції проста: ви маєте вміти відповісти, де саме препроцесор шукатиме цей заголовок і чому він знайшов «не той» або не знайшов його взагалі. Це заощаджує години часу й істотно зменшує кількість «містичних» помилок.
Що насправді робить #include
Важливо почати з правильної ментальної моделі. Директива #include обробляється препроцесором і, в найпростішому вигляді, робить дуже «низькорівневу» річ: вставляє текст одного файла в інший. На цьому етапі препроцесор ще не «розуміє» ні типів, ні перевантаження, ні просторів імен — він просто формує новий текст, який потім компілюватиметься.
Подивімося на невеликий приклад. Припустімо, у нас є заголовок math.hpp:
// math.hpp
#pragma once
int add(int a, int b);
А в main.cpp ми робимо так:
#include <iostream>
#include "math.hpp"
int main() {
std::cout << add(2, 3) << '\n'; // 5
}
З погляду ідеї препроцесора це виглядає так, ніби в main.cpp на місці #include "math.hpp" справді опинився текст із math.hpp. Звісно, у реальності є нюанси: захист від повторного включення, різні форми include, макроси. Але як перша картинка в голові це правильна модель.
2. Форми #include і порядок пошуку
Дві форми: <...> і "..." — це не про стиль
На практиці найчастіше ви побачите дві форми:
#include <iostream>
#include "todo/task.hpp"
Може здатися, що це просто два різні стилі. Але ні: це сигнал препроцесору, як саме шукати файл. Різні компілятори та IDE можуть мати тонкі відмінності в деталях, але загальна ідея стабільна: лапки — це «передусім мій проєкт», а кутові дужки — «передусім системне, стандартне або зовнішнє».
Зафіксуймо це практично, не заглиблюючись у формулювання стандарту до останньої коми:
| Форма | Типовий зміст | Де зазвичай лежить файл |
|---|---|---|
|
«Це мій заголовок (або заголовок проєкту)» | поруч із поточним файлом або в include-директоріях проєкту |
|
«Це заголовок зі стандартної, системної або зовнішньої бібліотеки» | системні include-директорії, SDK, toolchain |
У навчальних проєктах тримаємося простого правила: стандартні заголовки пишемо через <...>, а власні — через "...".
Приклад із нашого міні‑застосунку — умовного CLI‑проєкту «TodoList», який зберігає завдання й виводить їх у консоль:
// include/todo/task.hpp
#pragma once
#include <string>
namespace todo {
struct Task {
int id{};
std::string title;
bool done{false};
};
} // namespace todo
Зверніть увагу: <string> — через кутові дужки, бо це стандартна бібліотека. А наш task.hpp підключатиметься в лапках.
Де і в якому порядку шукається заголовок
Тепер найважливіше: як саме відбувається пошук файла.
Уявіть, що препроцесор — це курʼєр, якому ви дали записку: «Принеси "todo/task.hpp"». Якщо ви написали "todo/task.hpp", курʼєр спершу перевірить «поруч» — тобто каталог поточного файла. А якщо не знайде, піде за списком відомих йому місць: include-директорій проєкту та системних директорій. Якщо ж ви написали <todo/task.hpp>, він, як правило, «поруч» навіть не дивиться, а одразу йде за списком системних або налаштованих шляхів.
Важливо: точні деталі — які папки вважаються системними і в якому порядку перевіряються шляхи — залежать від збирання та налаштувань toolchain. Але на рівні ідеї варто тримати в голові ось таку схему:
flowchart TD
A["Натрапили на #include ..."] --> B{"Яка форма include?"}
B -->|лапки| C["Перевіряємо каталог поточного файла"]
C -->|знайшли| OK["Вставляємо вміст"]
C -->|не знайшли| D["Шукаємо в шляхах проєкту / include-директоріях"]
B -->|<...>| D
D -->|знайшли| OK
D -->|не знайшли| E["Шукаємо в системних шляхах"]
E -->|знайшли| OK
E -->|не знайшли| F["Помилка: файл не знайдено"]
Ця діаграма передає думку, яка справді допомагає під час налагодження: справа не в тому, «чому компілятор тупий», а в тому, «у якій папці він шукав і чому не там».
Невеликий, але дуже життєвий факт: інколи приклад із документації компілюється в автора, а у вас — ні, бо автор «випадково» покладався на транзитивне підключення заголовка. Наприклад, код використовує std::string, але в прикладі забули написати #include <string>. Це ще раз підкреслює принцип «include what you use».
3. Стабільні include‑шляхи в проєкті
Структура проєкту include/ і src/
Застосуймо все це до типової структури проєкту: умовно, include/ для заголовків і src/ для реалізацій. Нехай TodoList‑проєкт має такий вигляд:
TodoApp/
include/
todo/
task.hpp
task_store.hpp
src/
task_store.cpp
main.cpp
Сенс такої структури в тому, що include/ — це «публічна вітрина» заголовків проєкту. Тоді у .cpp-файлах ми хочемо писати include так, щоб шлях був стабільним і не залежав від того, де лежить конкретний .cpp.
Наприклад, у task_store.hpp ми цілком логічно підключаємо модель Task:
// include/todo/task_store.hpp
#pragma once
#include <vector>
#include "todo/task.hpp"
namespace todo {
class TaskStore {
public:
void add(std::string title);
private:
std::vector<Task> tasks_;
};
} // namespace todo
Тут важливі одразу два моменти.
Перший: #include "todo/task.hpp" — це наш заголовок, тому лапки.
Другий: шлях починається з todo/..., а не з ../... Це робить include незалежним від того, де розташований файл, у якому виконують підключення.
Тепер main.cpp може виглядати так:
// src/main.cpp
#include <iostream>
#include "todo/task_store.hpp"
int main() {
todo::TaskStore store;
std::cout << "TodoApp started\n"; // TodoApp started
}
Якщо в IDE або проєкті папку include/ додано до шляхів пошуку заголовків, то "todo/task_store.hpp" знаходитиметься стабільно. Ми зараз не заглиблюємося в те, як саме це налаштовують, — це інфраструктура збирання, і про неї буде окрема розмова. Поки що користуємося результатом: include-шлях виходить охайним і передбачуваним.
Чому #include "../something.hpp" майже завжди погана ідея
Відносні шляхи в include виглядають заманливо: «Файл же поруч — чому б не написати ../include/...?». У невеликому проєкті це навіть може працювати. Але проблема в тому, що ви фактично «зашиваєте» в код знання про структуру папок — і часто в найнезручнішому вигляді.
Уявіть, що ви написали:
#include "../include/todo/task.hpp"
Сьогодні це працює. Завтра ви перенесли main.cpp в іншу папку, або зробили src/app/main.cpp, або додали тести, які лежать інакше. І все — include став неправильним, хоча логічно ви, як і раніше, підключаєте той самий заголовок.
Тому хороша практика така: нехай include-шлях відображає логічну структуру API, а не фізичний маршрут «як дійти від src/ до include/ через хащі й яр».
Якщо хочеться аналогії: ../ в include — це як давати людині адресу «від третього стовпа ліворуч, потім за гаражі». Працює лише доти, доки гаражі на місці.
4. Налагодження include: конфлікти й file not found
Як можна випадково підключити «не той» файл
Є особливо підступна категорія помилок: проєкт компілюється, але ви підключили не той заголовок, який мали на увазі.
Сценарій такий. Припустімо, у вас у проєкті є файл include/log.hpp, і в системі або сторонній бібліотеці теж є log.hpp. Тоді:
#include "log.hpp"
найімовірніше візьме ваш, бо пошук спершу йде «поруч» або шляхами проєкту.
А от:
#include <log.hpp>
може спробувати взяти «системний» заголовок або файл із зовнішнього SDK, якщо такий є в шляхах пошуку для <...>. У підсумку код раптом компілюється, але типи й функції виявляються іншими, помилки — дивними, а інколи все «майже працює».
Щоб зменшити ймовірність таких історій, у проєктах майже завжди використовують «префікс» в include, як ми зробили з todo/.... Тобто замість "task.hpp" ми пишемо "todo/task.hpp". Це різко зменшує шанс, що десь є інший task.hpp, який виявиться «ближчим».
Як швидко полагодити помилку file not found
Рано чи пізно ви побачите щось на кшталт: «No such file or directory» або «cannot open source file». Важливо не панікувати й не намагатися виправити це методом «додам ще один include навмання».
Правильний ланцюжок міркувань такий:
Спершу перевірте, чи правильно записано імʼя файла в #include: регістр літер важливий на багатьох системах і в репозиторії. Потім зіставте форму include зі змістом: якщо це ваш заголовок, а ви написали <...>, то спрямували препроцесор шукати в «системних місцях», де вашого файла, звісно, немає.
Далі згадайте, що пошук залежить від того, де лежить файл, який виконує підключення, і які include-директорії налаштовано. Якщо заголовок лежить у include/todo/task.hpp, а ви пишете "task.hpp", то це спрацює лише тоді, коли десь у шляхах пошуку є папка, у якій task.hpp лежить просто в корені. Зазвичай це не так.
Тому в межах нашого курсу ми дотримуватимемося формату "todo/task.hpp" і "todo/task_store.hpp". Такий include читається як «імпорт із модуля todo», а не як «вгадай, де лежить файл».
5. Типові помилки під час роботи з #include
Помилка № 1: підключати свої заголовки через <...> «бо так гарніше».
Ця помилка здається невинною, доки проєкт маленький. Потім ви переносите файли, додаєте бібліотеку, змінюєте toolchain — і раптом <my_header.hpp> починає шукати не там, де ви очікували. Для заголовків проєкту використовуйте "...", щоб підключення працювало саме так, як ви інтуїтивно очікуєте: «спочатку своє».
Помилка № 2: писати include без префікса й натрапляти на конфлікти імен.
#include "task.hpp" здається зручним, але в реальному проєкті надто легко отримати другий task.hpp — наприклад, у тестах, в іншому модулі або в сторонній залежності. Коли починаються конфлікти, налагоджувати це неприємно: файл знаходиться, але «не той». Звичка писати "todo/task.hpp" рятує від цієї категорії сюрпризів.
Помилка № 3: використовувати ../ у шляхах include й привʼязувати код до поточної структури папок.
Відносні переходи на кшталт #include "../include/..." виглядають як швидке рішення, але роблять проєкт крихким. Будь‑яке переставляння файлів ламає include. Набагато надійніше мати одну «кореневу» include-директорію проєкту й писати include відносно неї, без стрибків по папках.
Помилка № 4: сподіватися на транзитивні підключення заголовків («воно ж уже десь підключено»).
Іноді код компілюється, бо заголовок A включає заголовок B, а ви використовуєте тип із B, не підключаючи B безпосередньо. Потім хтось «оптимізує» A і прибере зайвий include — і ваш код раптом перестане збиратися. Це лікується принципом «include what you use»: використовуєте std::string — підключіть #include <string>, використовуєте todo::Task — підключіть #include "todo/task.hpp".
Помилка № 5: підключати .cpp-файли через #include.
Зазвичай це спроба «швидко все склеїти», але вона руйнує модель роздільної компіляції та призводить до дивних помилок — аж до множинних визначень. .cpp мають компілюватися окремо, а для звʼязку між ними існують заголовки й лінкування — #include тут не є «замінником системи збирання».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ