1. Діагностика: компіляція чи лінкування
Коли збирання не вдається, мозок новачка часто переходить у режим «воно мене ненавидить», а компілятор — у режим expected…, undefined…, fatal…. І все це скидається на якесь заклинання. Тому починаємо не з виправлень, а з діагнозу: на якому саме кроці стався збій. Це економить купу часу, адже помилки компіляції виправляють змінами в коді та шляхах #include, а помилки лінкування — правильним списком .cpp/.o і збігом визначень.
Проста модель «де болить»
Уявіть збирання як конвеєр:
flowchart LR A[.cpp + #include] -->|компіляція| B[.o] B -->|лінкування| C[виконуваний файл]
Якщо ви бачите слова на кшталт fatal error: ... No such file or directory, expected, undeclared, no member named — це майже завжди компіляція (крок compile). Якщо ви бачите undefined reference, symbol(s) not found, ld: ... — це майже завжди лінкування (крок link).
І ще один важливий психологічний момент: «не знайдено символ» майже ніколи не означає «не знайдено header». Це різні класи проблем. Лікувати проблему лінкування прапорцем -I — приблизно те саме, що лікувати застуду заміною шпалер: може, настрій і покращиться, але температура нікуди не дінеться.
2. Помилка «header not found»: include paths і -I
Помилка «не знайдено header» звучить так, ніби компілятор просто не хоче шукати файл. Насправді він дуже хоче, але шукає лише в чітко визначених місцях. І якщо ваш заголовок лежить у include/, а ви компілюєте з іншої папки, компілятор справді не зобовʼязаний здогадуватися, де саме ви сховали my_header.hpp. Тут важливо зрозуміти: #include — це команда «встав текст файлу», а не «підʼєднай модуль проєкту».
Типовий симптом
Наприклад, у src/main.cpp ви написали:
#include "tracker/task.hpp"
int main() {
return 0;
}
А під час збирання отримали щось на кшталт:
fatal error: tracker/task.hpp: No such file or directory
Це означає, що компілятор намагався знайти tracker/task.hpp у стандартних місцях, а також у тих каталогах, які ви явно додали через -I..., — і не знайшов.
Прапорець -I: як дати компілятору карту місцевості
Прапорець -I<dir> — це спосіб сказати компілятору: «Коли побачиш #include "..." або інколи <...>, спробуй шукати файл також у цьому каталозі». Важливо: ви додаєте не конкретний файл, а кореневу папку пошуку, відносно якої й записуєте шлях у #include.
Саме тут новачки найчастіше плутають «куди вказувати -I» і «що писати в #include». І саме тут зʼявляється відчуття, що збирання — це ворожіння на кавовій гущі.
Зручна структура міні‑проєкту для тренувань
Уявімо, що ми продовжуємо навчальний консольний застосунок «TaskTracker» (умовний трекер завдань). Структура папок:
project/
include/
tracker/
task.hpp
src/
task.cpp
main.cpp
Логіка проста: усе, що користувач «підключає» як інтерфейс бібліотеки, лежить у include/. Усе, що є реалізацією, лежить у src/.
Правильна звʼязка: #include ↔ -I
Якщо в коді ви пишете:
#include "tracker/task.hpp"
то -I має вказувати на папку include/, щоб шлях tracker/task.hpp збігався:
g++ -std=c++23 -Iinclude src/main.cpp src/task.cpp -o tasker
Якщо ж помилково написати -Iinclude/tracker, тоді "tracker/task.hpp" уже не знайдеться (бо компілятор шукатиме include/tracker/tracker/task.hpp), і ви отримаєте ту саму помилку «No such file».
Лапки "" і кутові дужки <>
У навчальних проєктах зазвичай дотримуються простого правила: свої заголовки підключаємо через лапки, а стандартні — через кутові дужки.
#include <iostream> // стандартна бібліотека
#include "tracker/task.hpp" // наш проєкт
Можна заглиблюватися в точні пріоритети пошуку, але на цьому етапі важливіше інше: ваші заголовки мають бути доступні або «поруч» (відносно поточного файлу), або через явно вказані -I.... І краще не покладатися на варіант «поруч», а зробити проєкт таким, щоб він збирався з кореня через -Iinclude — так буде менше сюрпризів.
Міні‑пастка: команду запускають не з тієї папки
Якщо ви стоїте в терміналі всередині src/ і запускаєте:
g++ -std=c++23 -Iinclude main.cpp task.cpp -o tasker
то для компілятора -Iinclude означатиме src/include, а не project/include. І раптом — «header not found», хоча «вчора працювало».
Тому, коли ви пишете команди з відносними шляхами, майте на увазі: відлік ведеться від поточного каталогу термінала.
3. Помилка undefined reference: чого бракує лінкувальнику
Після «header not found» наступний за поширеністю біль — «не знайдено символ». Простими словами це означає: «Компілятор бачив оголошення функції (або методу), скомпілював код, який її викликає, але на етапі лінкування не знайшов визначення (реалізації), щоб створити виконуваний файл».
Тут дуже корисно памʼятати просту фразу: #include допомагає компіляції побачити оголошення, але не додає реалізацію до лінкування. Реалізації беруть участь у лінкуванні лише тоді, коли ви передали в команду збирання потрібні .cpp (або .o).
Базовий випадок: оголошення є, реалізація не бере участі в лінкуванні
include/tracker/task.hpp:
#pragma once
#include <string>
namespace tracker {
std::string make_title(int id);
}
src/task.cpp:
#include "tracker/task.hpp"
namespace tracker {
std::string make_title(int id) {
return "Task #" + std::to_string(id);
}
}
src/main.cpp:
#include <iostream>
#include "tracker/task.hpp"
int main() {
std::cout << tracker::make_title(7) << '\n'; // Task #7
}
Якщо ви зберете лише main.cpp:
g++ -std=c++23 -Iinclude src/main.cpp -o tasker
то компіляція пройде (бо оголошення видно із заголовка), але лінкування впаде з повідомленням на кшталт:
undefined reference to `tracker::make_title(int)`
Правильна команда має включати src/task.cpp:
g++ -std=c++23 -Iinclude src/main.cpp src/task.cpp -o tasker
І ось це — найчастіша причина undefined reference на цьому етапі: «забули додати файл із реалізацією».
«Файл додали, а помилка залишилася»: три часті причини
Іноді ви додали всі .cpp, але «не знайдено символ» усе одно зʼявляється. Тоді це вже не просто ситуація «забув файл», а трохи тонша історія. І гарна новина: тонша — не означає складна. Просто потрібно навчитися перевіряти гіпотези по черзі.
Незбіг сигнатури: оголосили одне, визначили інше
Класичний випадок: у заголовку написали одне, а в .cpp випадково визначили трохи інакше.
У .hpp:
#pragma once
namespace tracker {
int parse_id(const char* s);
}
А в .cpp:
namespace tracker {
int parse_id(char* s) { // інше: не const
return 0;
}
}
Компілятор це скомпілює (дві різні функції можуть існувати), але лінкувальник шукатиме parse_id(const char*) і не знайде. У повідомленні про помилку часто видно точне «імʼя» з типами. На перших порах не потрібно вміти читати манґлінг як професіонал — достатньо буквально, символ у символ, звіряти типи в оголошенні й визначенні.
Інший namespace (або його забули)
Ще один частий випадок: функцію оголосили в namespace tracker, а визначення зробили в глобальному просторі імен.
Заголовок:
#pragma once
namespace tracker {
int next_id();
}
Реалізація (помилкова):
int next_id() { // немає namespace tracker
return 1;
}
У результаті tracker::next_id() не визначений. Лінкувальник чесно скаржиться, і це знову виглядає як «магія», доки ви не звикнете до простої думки: простір імен — це частина імені символу.
Лінкуєте не той набір обʼєктних файлів
Якщо ви працюєте через обʼєктні файли, можна легко залінкувати «не той набір». Наприклад, ви зробили:
g++ -std=c++23 -Iinclude -c src/main.cpp -o main.o
g++ -std=c++23 -Iinclude -c src/task.cpp -o task.o
А потім випадково залінкували лише main.o:
g++ main.o -o tasker
І знову undefined reference. Це той самий клас проблеми — «немає реалізації під час лінкування», просто у формі .o, а не .cpp.
4. Чек‑лист і міні‑розслідування
Чек‑лист добрий тим, що вимикає паніку. Ви не намагаєтеся «згадати все», а просто послідовно перевіряєте пункти. Це схоже на пошук загублених ключів: безглуздо одразу занурюватися в квантову фізику, якщо спочатку можна перевірити кишеню куртки.
Нижче — чек‑лист, який справді працює для наших типових проблем: include paths, header not found, undefined reference. Намагайтеся йти за порядком і змінювати за раз лише одну річ: так ви розумітимете, що саме допомогло.
Таблиця «симптом → стадія → що перевірити → швидкий фікс»
| Симптом у виводі | Де стався збій | Що це зазвичай означає | Що перевірити насамперед | Типове виправлення |
|---|---|---|---|---|
|
|
заголовок не знайдено | чи існує файл; чи правильний шлях у #include; чи є -I... | додати або виправити -I, виправити #include |
|
|
компілятор не побачив оголошення | чи підключено потрібний заголовок; чи не забули std::/namespace | додати #include, виправити namespace |
|
|
оголошення є, а визначення немає в лінкуванні | чи додано потрібні .cpp/.o; чи збігається сигнатура | додати файл із реалізацією; виправити сигнатуру |
|
|
визначення продублювалося | чи не визначили ви функцію або змінну в заголовку | це випадок із теми про ODR: тримаємо визначення в .cpp |
Останній рядок про multiple definition наведено тут лише для того, щоб ви впізнали клас проблеми. Детально це ми вже розбирали раніше, і зараз важливо не змішувати: це не «не знайдено символ», а «знайдено надто багато разів».
Міні‑алгоритм у 4 запитання
Коли збирання падає, спробуйте буквально проговорити це собі.
Насамперед я дивлюся, чи є у виводі fatal error або undefined reference. Якщо це fatal error, я не чіпаю список .cpp і не думаю про лінкування. Я перевіряю #include і -I. Якщо це undefined reference, я не намагаюся «ще раз додати -I». Я перевіряю, які файли беруть участь у лінкуванні та де лежить реалізація. Далі я звіряю сигнатуру оголошення й визначення. І лише потім, якщо все збіглося, починаю підозрювати рідкісніші речі на кшталт «не той namespace» або «я лінкую не ті обʼєктні файли».
Невелике розслідування на одному прикладі
Дуже корисно побачити, як чек‑лист працює на практиці, бо тоді ви перестаєте боятися помилок: вони стають просто гілками в дереві рішень.
Крок 1. Ламаємо include (навмисно).
Нехай src/main.cpp містить:
#include <iostream>
#include "tracker/task.hpp"
int main() {
std::cout << "Hello\n"; // Hello
}
І ви запускаєте команду з кореня проєкту, але забули -Iinclude:
g++ -std=c++23 src/main.cpp src/task.cpp -o tasker
Якщо tracker/task.hpp лежить саме в include/tracker/task.hpp, компілятор закономірно повідомить, що «header not found». Чек‑лист каже: це помилка компіляції, отже виправляємо шляхи пошуку заголовків. Додаємо -Iinclude:
g++ -std=c++23 -Iinclude src/main.cpp src/task.cpp -o tasker
Крок 2. Ламаємо лінкування (навмисно).
Тепер зробімо так, щоб main.cpp викликав функцію з task.cpp:
#include <iostream>
#include "tracker/task.hpp"
int main() {
std::cout << tracker::make_title(3) << '\n'; // Task #3
}
І зберемо лише main.cpp:
g++ -std=c++23 -Iinclude src/main.cpp -o tasker
Отримаємо undefined reference. Чек‑лист каже: це помилка лінкування, отже -I уже не обговорюємо — додаємо файл реалізації:
g++ -std=c++23 -Iinclude src/main.cpp src/task.cpp -o tasker
Крок 3. Ламаємо сигнатуру (навмисно).
Якщо тепер у заголовку оголосити make_title(long long), а визначити make_title(int), ви знову отримаєте undefined reference, хоча task.cpp у команді є. І це якраз той випадок, коли порада «додай файл» не допомагає, бо лінкувальник шукає інший символ. Тому третя перевірка після «усі файли додано» — це «чи збігається сигнатура».
5. Типові помилки під час збирання з консолі
Помилка № 1: лікувати «не знайдено header» додаванням .cpp у команду.
Коли компілятор пише fatal error: ... No such file or directory, йому буквально бракує тексту заголовка на етапі компіляції. Додавання ще одного .cpp не розширює шляхи пошуку заголовків і не робить файл доступним через include. Виправляється це правильним шляхом у #include і додаванням -I до потрібного кореневого каталогу (зазвичай -Iinclude), а також запуском команди з очікуваного каталогу.
Помилка № 2: лікувати undefined reference прапорцем -I.
-I впливає лише на пошук заголовків, тобто на компіляцію. Лінкувальник не читає ваші заголовки й не розуміє, що «десь там є реалізація». Якщо ви бачите undefined reference, то майже завжди або не додали файл із визначенням до лінкування, або саме визначення не збігається за імʼям, сигнатурою чи namespace з оголошенням.
Помилка № 3: вважати, що #include «підключає реалізацію».
Це дуже природна помилка новачків: «Я ж підключив task.hpp, чому функцію не знайшло?». Бо заголовок зазвичай містить оголошення, а реалізація лежить у .cpp. Заголовок допомагає компілятору зрозуміти, що функція існує, але лінкувальнику потрібно реально побачити обʼєктний файл, де лежить код цієї функції. Тому в команду збирання (або лінкування .o) мають потрапити всі одиниці трансляції з визначеннями.
Помилка № 4: збирати проєкт «з різних місць» різними відносними шляхами.
Сьогодні ви запускаєте команду з кореня проєкту — усе гаразд. Завтра запускаєте її з src/ — і -Iinclude раптом вказує не туди. Такі проблеми виглядають так, ніби «воно зламалося саме». Насправді це питання точки відліку для відносних шляхів. Рішення просте: або завжди збирайте з кореня проєкту, або використовуйте акуратні шляхи (а з часом — систему збирання, але це вже не тема цієї лекції).
Помилка № 5: випускати з уваги namespace як частину імені символу.
Якщо в заголовку функцію оголошено як tracker::next_id(), а в .cpp ви визначили просто next_id(), то з погляду лінкувальника це дві різні функції, і потрібна так і залишиться «не знайденою». Це особливо часто трапляється, коли ви копіюєте код, швидко «перевіряєте», що компілюється, і не помічаєте, що блок namespace tracker { ... } в одному місці є, а в іншому — немає.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ