JavaRush /Курси /C++ SELF /Як працює #include: порядок пошуку шляхів, <> чи ""...

Як працює #include: порядок пошуку шляхів, <> чи ""

C++ SELF
Рівень 28 , Лекція 0
Відкрита

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-директоріях проєкту
#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 тут не є «замінником системи збирання».

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