1. Важливо відрізняти compile error від link error
Коли ви тільки починаєте програмувати, кнопка «Build/Run» в IDE часто сприймається як «магічна кнопка, що все робить сама». І це нормально: мозок намагається заощадити сили. Але така економія має ціну: ви бачите червоний текст і починаєте лагодити все підряд — ніби у вас зламався автомобіль, а ви спершу міняєте двірники, бо «вони ближче».
У C++ помилка компіляції й помилка лінкування — це дві різні «поломки» на двох різних етапах. Якщо їх плутати, ви майже напевно шукатимете проблему не там і виправлятимете не те. Добра новина в тому, що відрізнити їх можна дуже швидко: за характерними словами і за тим, на що саме скаржиться повідомлення.
Мапа процесу: компілятор і лінковник
Якщо пояснити зовсім просто, компілятор — це суворий учитель української мови: він перевіряє, чи в кожному вашому файлі фрази побудовані граматично правильно, чи існують потрібні «слова» і чи узгоджені форми. А лінковник — це організатор весілля: він бере «частини церемонії» з різних файлів і намагається зібрати їх у єдиний захід, де «наречений справді зустрівся з нареченою», тобто де кожен виклик функції знаходить свою реалізацію.
Ключовий момент: компілятор працює з кожним .cpp окремо — з однією одиницею трансляції, translation unit. Лінковник працює з результатами компіляції всіх .cpp і намагається звʼязати їх в один виконуваний файл. Тому одні проблеми видно «відразу» — під час компіляції, а інші спливають лише «наприкінці» — під час лінкування.
Нижче — коротка таблиця, яка буде нашим орієнтиром упродовж усієї лекції.
| Етап | Що перевіряється | Типові формулювання в повідомленнях | Де зазвичай лагодити |
|---|---|---|---|
| Компіляція (compile) | синтаксис, типи, імена, доступність заголовків, видимість оголошень | error: expected ..., error: ... was not declared, no such file or directory, no matching function | у конкретному .cpp або в підключеному .hpp |
| Лінкування (link) | чи знайдено визначення для всіх використаних функцій і змінних та чи немає дублікатів визначень | undefined reference, unresolved external, multiple definition, LNK... | у тому, які .cpp беруть участь у збиранні, і де розташовані визначення |
2. Помилка компіляції: «я не розумію цей файл»
Помилка компіляції — це ситуація, коли конкретний .cpp не вдалося перетворити на обʼєктний файл. Зазвичай повідомлення компілятора містить координати: файл → рядок → колонка. Воно вказує на місце, де компілятор спіткнувся. Це схоже на перевірку диктанту: вам підкреслюють місце, де слово написано неправильно.
Щоб приклади не були надто абстрактними, продовжимо наш навчальний консольний застосунок TaskBox — найпростіший менеджер задач. Ми спеціально робимо маленькі функції й розносимо їх по файлах, щоб наочно ловити різні типи помилок.
Синтаксична помилка: «не можу це прочитати»
Синтаксична помилка — класика жанру. Це ситуація, коли у вас бракує ;, закривальної } або ви випадково написали щось «не за граматикою C++».
#include <iostream>
int main() {
std::cout << "TaskBox started\n" // <- забули ';'
return 0;
}
Тут компілятор зазвичай скаже щось на кшталт expected ‘;’ або expected .... Важливо розуміти: доки ви не виправите синтаксис, рухатися далі немає сенсу. Компілятор не може коректно розібрати файл, тож частина наступних повідомлень може бути лише «луною» першої проблеми.
Імʼя не знайдено: «я не знаю, що це таке»
Друга за поширеністю ситуація — ви використовуєте функцію, змінну або тип, про які компілятор у цьому місці нічого не знає. Таке особливо часто трапляється в багатофайлових проєктах, коли ви забули підключити потрібний заголовок.
#include <iostream>
int main() {
std::cout << addTask("buy milk") << '\n';
return 0;
}
Якщо addTask ніде не оголошено у видимій області — наприклад, ви забули #include "task.hpp", — компілятор скаже was not declared in this scope або identifier not found. Це помилка компіляції, бо компілятор не може навіть «описати» виклик: він не знає, що таке addTask.
Заголовок не знайдено: «мені не дали потрібний файл»
Іноді проблема взагалі не в самій мові C++, а в тому, що компілятор не може знайти файл.
#include "task.hpp"
#include "missing.hpp" // файла немає
int main() {}
Повідомлення буде схожим на No such file or directory. Це теж compile error, бо компілятор не може зібрати одиницю трансляції: йому бракує тексту, який ви просите підключити.
3. Помилка лінкування: «я зібрав частини, але не зміг їх звʼязати»
Помилка лінкування дуже впізнавана: компіляція, здавалося б, пройшла, але на фінальному кроці збирання програма не створюється. У новачків це часто викликає особливо сильне здивування, бо «компілятор же вже все перевірив». Але він перевірив не все — лише те, що міг побачити в межах кожного .cpp.
Якщо компілятор — це перевірка граматики, то лінковник — перевірка того, чи для кожної «згадки персонажа» у сценарії справді є актор на сцені.
Link error: undefined reference / unresolved external
Це найчастіший link error: десь ви оголосили функцію або підключили заголовок з оголошенням, а визначення, тобто реалізація, не потрапило до збирання.
Зробімо справді «правильну» структуру нашого TaskBox.
task.hpp (оголошення):
#pragma once
#include <string>
int addTask(const std::string& title);
task.cpp (реалізація):
#include "task.hpp"
int addTask(const std::string& title) {
(void)title;
return 1;
}
main.cpp:
#include <iostream>
#include "task.hpp"
int main() {
std::cout << addTask("buy milk") << '\n'; // 1
}
Тепер зверніть увагу на типову ситуацію: task.cpp існує в теці проєкту, але не бере участі у збиранні, наприклад ви забули додати файл у проєкт або в ціль збирання. Тоді main.cpp успішно скомпілюється: він побачив оголошення addTask(...) із заголовка і «повірив», що десь у світі є реалізація.
А от лінковник скаже: «не знайдено визначення» — undefined reference, unresolved external symbol. Саме в цей момент він намагався «зшити» виклик із реалізацією, але не зміг.
Link error: multiple definition
Друга класична ситуація: ви випадково зробили так, що одне й те саме визначення потрапило в збирання двічі. Особливо часто це стається, коли ви визначаєте глобальну змінну в заголовку, а сам заголовок підключаєте у два .cpp.
Поганий приклад:
// config.hpp
#pragma once
int g_nextId = 1; // <- ВИЗНАЧЕННЯ в заголовку (ризик!)
task.cpp:
#include "config.hpp"
int getNextIdForTask() {
return g_nextId++;
}
main.cpp:
#include "config.hpp"
#include <iostream>
int main() {
std::cout << g_nextId << '\n'; // 1
}
Обидва .cpp підключили config.hpp, і кожен отримав власне визначення g_nextId. На етапі лінкування це перетворюється на multiple definition. І це безпосередньо повʼязано з тим, що в програмі в ідеалі має бути «одне визначення» таких сутностей. Привіт, ODR.
4. Як компіляція «вірить», а лінкування «перевіряє»
Тут важливо на хвилину зупинитися й спокійно проговорити ключову думку, щоб далі не залишалося відчуття магії. Коли компілятор бачить виклик addTask("buy milk"), він хоче зрозуміти дві речі: чи існує така функція за формою і чи можна коректно передати їй аргументи.
Для цього йому достатньо оголошення:
int addTask(const std::string& title);
Це як табличка «у цій будівлі працює нотаріус»: ви вже можете прийти, відчинити двері й сказати «мені потрібна послуга», навіть якщо нотаріуса ще не видно. Але для лінковника це вже момент оплати й підписання документів: потрібно, щоб нотаріус справді існував, сидів у кабінеті й міг поставити печатку.
Саме тому сценарій «оголосили, але не визначили» дає link error, а не compile error. Компілятор не зобовʼязаний здогадуватися, що реалізації немає: він припускає, що ви як доросла людина не брешете. Спойлер: ми брешемо постійно, але випадково.
Практичний детектор: 5 сигналів, за якими ви впізнаєте тип помилки
Зараз буде дуже практична частина. Це той момент, коли в майбутньому ви побачите червоний лог і за 10 секунд скажете: «Ага, це лінкування. Отже, я шукаю реалізацію, участь файла у збиранні або дублікати».
Я опишу ці сигнали звʼязним текстом, без «переліків заради переліків», бо корисно поєднати в голові дві речі: «як читати повідомлення» і «куди йти лагодити».
Перший сигнал — наявність конкретного місця у вихідному коді: main.cpp:12:5: error: .... Це майже завжди компіляція. Лінковник теж іноді згадує файли, але зазвичай це вже імена обʼєктних файлів або бібліотек, а не точний рядок у вашому main.cpp.
Другий сигнал — слова про «очікував»: expected, «не можу розібрати» та будь-які скарги на синтаксис. Лінковник синтаксису не бачить: він працює з результатом компіляції. Тож expected ';' — стовідсотковий compile error.
Третій сигнал — фрази «не знайдено файл» — No such file or directory поруч із #include. Це compile error: компілятор не може зібрати одиницю трансляції, бо йому не дали текст.
Четвертий сигнал — слова undefined reference, unresolved external, LNK2019 у MSVC або ld: .... Це лінкування: йдеться про символи, які мають бути десь визначені, але їх не знайдено.
Пʼятий сигнал — multiple definition або аналоги в MSVC. Це лінкування: визначення вже «приїхали» на збирання, і лінковник виявив, що приїхали двоє однакових людей з одним паспортом.
5. Практика на TaskBox: одна проблема — два етапи
Зараз ми зробимо важливу вправу для мозку: подивімося, як схожа за змістом проблема проявляється як compile error і як link error. Це допомагає перестати плутати «не бачить» і «не знайшов».
Компілятор не бачить оголошення
main.cpp:
#include <iostream>
int main() {
std::cout << addTask("buy milk") << '\n';
}
Тут немає #include "task.hpp", тому компілятор узагалі не знає, що таке addTask. Це compile error.
Компілятор бачить оголошення, але лінковник не знаходить визначення
main.cpp:
#include <iostream>
#include "task.hpp"
int main() {
std::cout << addTask("buy milk") << '\n';
}
task.hpp є, оголошення є, тож компіляція пройде. Але якщо task.cpp не бере участі у збиранні або в ньому немає визначення addTask, то помилка виникне на етапі лінкування — з undefined reference.
Сенс тут простий: помилка не «містично переїхала». Вона просто проявилася на тому етапі, де її вже можна перевірити.
Дуже часта причина link error: «сигнатури розійшлися»
Цей момент — типова пастка: ви думаєте, що визначення є, але лінковник усе одно каже undefined reference.
На практиці причина часто банальніша: ви оголосили одну функцію, а визначили «схожу, але іншу». Лінковник шукає точний збіг імені та параметрів.
task.hpp:
#pragma once
#include <string>
int addTask(const std::string& title);
task.cpp (помилка):
#include "task.hpp"
int addTask(std::string title) { // <- немає const&, інший параметр
(void)title;
return 1;
}
Для компілятора це може пройти, особливо якщо ви випадково не підключили task.hpp у task.cpp, що саме по собі є поганою практикою. Але лінковник шукатиме addTask(const std::string&), а знайде addTask(std::string). Для нього це дві різні функції.
Практична звичка, яка помітно зменшує кількість таких випадків: .cpp завжди підключає свій .hpp. Тоді компілятор сам зловить невідповідність сигнатури ще до етапу лінкування.
6. Міні-алгоритм читання лога збирання
Хочеться дати вам не філософію, а робочий алгоритм. Уявіть, що ви натиснули Build і отримали 200 рядків логу. Не треба читати його як роман, хоча інколи за драматургією він цілком тягне на «Оскар». Треба щоразу діяти однаково.
Спочатку ви визначаєте, це компіляція чи лінкування, — за ключовими словами та стилем повідомлення. Потім знаходите першу змістовну помилку, бо решта часто «сиплеться каскадом». Далі вирішуєте, де шукати причину: у коді конкретного .cpp, у підключеннях заголовків або в тому, які файли беруть участь у збиранні і де лежать визначення.
Нижче — проста блок-схема, яку зручно тримати в голові:
flowchart TD
A["Збирання завершилося з помилкою"] --> B{"У повідомленні є undefined reference / multiple definition / LNK... ?"}
B -- Так --> L["Це помилка лінкування. Шукаємо: де визначення? чи немає дублікатів? чи всі .cpp беруть участь у збиранні?"]
B -- Ні --> C{"У повідомленні є file:line:col і 'error:' ?"}
C -- Так --> D["Це помилка компіляції. Шукаємо: синтаксис/типи/імена/#include/видимість оголошень"]
C -- Ні --> E["Дивимося вище в лог: іноді IDE приховує реальний етап"]
7. Типові помилки: чому вони повторюються
Помилка № 1: лагодити не ту стадію.
Найчастіший сценарій: ви бачите undefined reference, але лізете переписувати if/else у main, бо «раптом логіка неправильна». Лінковнику байдуже до вашої логіки: він не може знайти визначення. Лікується це звичкою спершу класифікувати помилку, а вже потім діяти.
Помилка № 2: «файл існує — значить, він бере участь у збиранні».
Це психологічно зрозуміло: якщо task.cpp лежить поруч, здається, що його «мають» врахувати. Але збирання — це список файлів, які реально компілюються й лінкуються. Якщо task.cpp не додано в проєкт або в ціль збирання, його наче не існує. У результаті ви отримуєте undefined reference і відчуття, ніби реальність з вас насміхається.
Помилка № 3: визначати змінні в заголовках.
Новачок думає: «Я ж хочу, щоб усі бачили g_nextId, отже, покладу його в .hpp». І це призводить до multiple definition, бо заголовок підключається в кілька одиниць трансляції, і кожна отримує своє визначення. Це як роздрукувати один і той самий «паспорт» у двох офісах і спробувати пройти контроль. Такі конфлікти напряму впираються в правило одного визначення — ODR.
Помилка № 4: розійшлися оголошення й визначення, але .cpp не підключає свій .hpp.
Це тиха міна. Ви змінили сигнатуру в .hpp, забули змінити її в .cpp, а потім дивуєтеся undefined reference. Якби .cpp підключав свій .hpp, компілятор спіймав би проблему одразу. Тому «підключай свій заголовок» — це не просто стиль, а практичний захист від помилок збирання.
Помилка № 5: намагатися «перемогти» лінкування додаванням #include.
Іноді людина бачить undefined reference і думає: «мабуть, я не той заголовок підключив». Але #include впливає на компіляцію, а не на наявність визначення у збиранні. Заголовок дає оголошення, а лінкування вимагає реалізацію. Можна підключити заголовок десять разів — визначення від цього не зʼявиться, а проблем, навпаки, може стати більше.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ