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, потому что компилятор не может собрать единицу трансляции: ему не хватает текста, который вы просите подключить.
4. Ошибка линковки: «я собрал части, но не смог связать»
Ошибка линковки — очень характерная: компиляция, казалось бы, прошла, но на финальном шаге сборки программа не создаётся. И у новичков это вызывает особенно сильное недоумение, потому что “ну компилятор же уже всё проверил”. А он проверил не всё — он проверил только то, что мог увидеть в рамках каждого .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).
5. Как компиляция «верит», а линковка «проверяет»
Здесь важно остановиться и спокойно проговорить ключевую мысль, чтобы дальше не было ощущения магии. Компилятор, когда видит вызов 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-аналоги). Это линковка: определения уже “приехали” на сборку, и линковщик обнаружил, что приехали два одинаковых человека с одним паспортом.
6. Практика на 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. Тогда компилятор сам поймает несовпадение сигнатуры ещё до линковки.
7. Мини-алгоритм чтения лога сборки
Хочется дать вам не философию, а рабочую механику. Представьте, что вы нажали Build и получили 200 строк лога. Не надо читать их как роман (хотя иногда по драматургии они достойны Оскара). Нужно действовать одинаково каждый раз.
Сначала вы определяете, это компиляция или линковка, по ключевым словам и по стилю сообщения. Потом вы находите первую содержательную ошибку, потому что остальное часто “сыпется каскадом”. Потом вы решаете, где искать причину: в коде конкретного .cpp, в подключениях заголовков, или в том, какие файлы участвуют в сборке и где лежат определения.
Ниже — простая блок-схема, которую можно держать в голове:
flowchart TD
A["Сборка упала"] --> B{"В сообщении есть undefined reference / multiple definition / LNK... ?"}
B -- Да --> L["Это link error Ищем: где определение? нет ли дублей? все ли .cpp участвуют в сборке?"]
B -- Нет --> C{"В сообщении есть file:line:col и 'error:' ?"}
C -- Да --> D["Это compile error Ищем: синтаксис/типы/имена #include/видимость объявлений"]
C -- Нет --> E["Смотрим выше по логу: иногда IDE прячет реальный этап"]
8. Типичные ошибки: почему они повторяются
Ошибка №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 влияет на компиляцию, а не на наличие определения в сборке. Заголовок даёт объявление, а линковка требует реализацию. Можно подключить заголовок десять раз — определение от этого не появится (а вот проблем, наоборот, может стать больше).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ