1. Иногда линкер ругается после компиляции
Представьте, что компилятор — это преподаватель, который проверяет каждую контрольную работу по отдельности, а линкер — это методист, который потом пытается собрать из этих работ один общий учебник. Компилятор может сказать: «Да, в этой тетрадке всё грамматически правильно». Но когда методист собирает общий учебник, выясняется, что глава 5 написана дважды, а глава 7 вообще забыта. Вот примерно так и возникают линковочные ошибки.
В многофайловых проектах C++ «сломаться» может не только код (синтаксис/типы), но и связи между файлами: кто где определён, кто кого видит, и существует ли символ ровно один раз там, где должен.
Чтобы не бороться с линкером как с мифическим боссом из игры («я же всё сделал правильно!»), важно научиться читать эти ошибки как обычные инженерные сообщения: линкер почти всегда честно говорит, что он пытался найти и почему не смог.
Две категории линковочных ошибок
Перед тем как разбирать примеры, полезно зафиксировать простую табличку. Она часто экономит 30 минут «танцев с #include», которые вообще ни при чём.
| Сообщение линкера | Человеческий перевод | Что это означает в терминах программы |
|---|---|---|
|
«Одно и то же определено больше одного раза» | Линкер нашёл два (или больше) определения одного символа |
|
«Есть использование, но реализации нет» | Линкер не нашёл ни одного определения, хотя код где-то на него ссылается |
Заметьте важный момент: обе ошибки — про определения (definitions). С объявлениями (declarations) обычно всё проще: их можно повторять (в разумных пределах), но одно «тело функции» или один «реальный объект переменной» должны быть в корректном количестве.
2. multiple definition: что «размножилось» и почему
Эта ошибка обычно ловит новичков внезапно: вы ничего «не дублировали», вы «просто подключили заголовок»… а линкер внезапно говорит, что функция определена дважды.
Ключевая мысль здесь такая: #include — это вставка текста. Если в заголовке лежит определение, то при подключении заголовка в два .cpp вы получите два определения. А ODR говорит: «так нельзя» (если это не специальный случай вроде inline).
Давайте рассмотрим типовые сценарии, где multiple definition возникает чаще всего.
Обычная функция в .hpp без inline
Сейчас мы продолжим наше условное консольное приложение (пусть будет MiniPlanner) и допустим типичную ошибку: положим реализацию функции в заголовок.
// planner_math.hpp
#pragma once
int add_minutes(int a, int b) { // <-- ОПРЕДЕЛЕНИЕ в заголовке (опасно)
return a + b;
}
// a.cpp
#include "planner_math.hpp"
int fa() { return add_minutes(10, 5); }
// b.cpp
#include "planner_math.hpp"
int fb() { return add_minutes(20, 7); }
Каждый .cpp после препроцессора «увидит» полноценное тело add_minutes, и в итоге линкер встретит два одинаковых символа add_minutes(int,int).
Исправления тут концептуально два: либо вынести определение в один .cpp, оставив в .hpp только объявление, либо сделать эту функцию inline (если по дизайну вы действительно хотите держать определение в заголовке).
Глобальная переменная в заголовке
Эта проблема ещё более частая, чем с функциями, потому что глобальные переменные выглядят «безобидно»: ну подумаешь, int g_timeout = 1000;.
// config.hpp
#pragma once
int g_timeout_ms = 1000; // <-- ОПРЕДЕЛЕНИЕ переменной в заголовке (почти всегда ошибка)
Если config.hpp подключить в два .cpp, вы получите два «настоящих объекта» g_timeout_ms. Линкер скажет: «Стоп, так не договорились».
Правильный паттерн, который мы обсуждали в лекциях про extern, выглядит так:
// config.hpp
#pragma once
extern int g_timeout_ms; // объявление, объект НЕ создаётся
// config.cpp
#include "config.hpp"
int g_timeout_ms = 1000; // единственное определение, объект создаётся здесь
И тут важно внутренне проговорить: extern в заголовке — это не «магия связи», это буквально «обещание: где-то есть объект, поверь мне». А реальный объект создаётся ровно в одном месте.
Почему include guards не помогают
Очень популярная попытка: «Окей, у меня multiple definition, значит заголовок включился дважды, значит надо #pragma once или include guards». Но #pragma once защищает от повторного включения внутри одного .cpp. Он не запрещает двум разным .cpp включить один и тот же заголовок.
Поэтому include guards — это средство от другой болезни: от «повторной вставки текста в одном и том же .cpp». А multiple definition — болезнь «определение размножилось по нескольким единицам трансляции».
Когда определение в заголовке допустимо
Вот здесь и появляется inline. И важный акцент: в рамках нашего дня inline — это не про «ускорить» (в реальности компилятор сам решает, встраивать или нет), а про правило линковки и ODR.
В рабочих материалах по стандарту C++ прямо подчёркивается, что ключевое назначение inline — позволить нескольким объявлениям/определениям удовлетворять ODR, а не «давать подсказку оптимизатору».
То есть inline в нашем контексте — это «легальная кнопка», которая говорит: «Да, это определение может оказаться в нескольких .cpp, и это нормально, если оно одинаковое».
4. undefined reference: почему компиляция проходит, а линковка падает
Теперь разберём вторую большую категорию. undefined reference обычно выглядит так: вы написали main.cpp, подключили заголовок, всё красиво, компилятор молчит… и только на этапе сборки линкер говорит: «не могу найти реализацию».
Это логично: компилятору для вызова функции достаточно знать сигнатуру (объявление). Он умеет сгенерировать «вызов символа». А линкер уже обязан найти, где этот символ реально определён.
Реализации нет или .cpp не участвует в сборке
Самый «приземлённый» вариант: вы объявили функцию, но не написали её реализацию.
// planner_io.hpp
#pragma once
int read_int();
// main.cpp
#include "planner_io.hpp"
#include <iostream>
int main() {
std::cout << read_int() << '\n';
}
Если read_int() нигде не определён (нет planner_io.cpp или в нём нет тела), линкер не сможет завершить сборку.
Ещё один вариант того же сценария — реализация есть, но соответствующий .cpp не участвует в сборке (не добавлен в проект/таргет). Мы не уходим в детали систем сборки, но как диагноз это очень полезно помнить: «определение существует в репозитории» не всегда значит «оно реально компилируется в этом запуске».
Сигнатура отличается, а для линкера это другой символ
Это самая обидная версия undefined reference, потому что на глаз кажется, что «почти то же самое».
// planner_math.hpp
#pragma once
int sum(int a, int b);
// planner_math.cpp
#include "planner_math.hpp"
// ОШИБКА: другая сигнатура, это уже другой символ!
int sum(int a, int b, int c) {
return a + b + c;
}
// main.cpp
#include "planner_math.hpp"
int main() {
return sum(1, 2);
}
Компилятор в main.cpp честно генерирует вызов sum(int,int). Линкер ищет определение sum(int,int). А вы дали определение sum(int,int,int). Для человека «sum есть». Для линкера — это две разные сущности.
Эта же история случается, если вы случайно поменяли const, ссылку, пространство имён, или даже тип (int vs long long). В нашем курсе мы пока не углубляемся в «украшение имени» (name mangling), но интуитивно нужно принять: полное имя символа включает сигнатуру.
Реализация скрыта через static или namespace {}
Это особенно коварно, потому что часто выглядит как «я же объявил в другом файле».
// secret.cpp
static int secret_code() { // internal linkage: только внутри secret.cpp
return 42;
}
// main.cpp
int secret_code(); // внешнее объявление (но оно не делает символ «видимым»)
int main() {
return secret_code(); // undefined reference
}
Почему так? Потому что static на уровне файла сделал символ внутренним. То есть внешнего secret_code() в природе не существует. А ваше объявление в main.cpp — это просто обещание компилятору, что он существует. Линкер проверяет обещание и говорит: «неа».
В этом месте у новичка часто появляется желание «лечить #include». Но проблема вообще не в include. Проблема в том, что вы выбрали внутреннюю линковку для функции, которую пытаетесь вызывать извне.
Несовпадение namespace
С namespace происходит похожий эффект: кажется, что имя то же самое, а для линкера это другое полное имя.
// planner_math.hpp
#pragma once
namespace planner {
int add(int a, int b);
}
// planner_math.cpp
#include "planner_math.hpp"
// ОШИБКА: забыли namespace planner::
int add(int a, int b) {
return a + b;
}
// main.cpp
#include "planner_math.hpp"
int main() {
return planner::add(2, 3);
}
planner::add и ::add — разные сущности. Поэтому линкер не найдёт planner::add.
5. Диагностика: как быстро выбрать лечение
Сейчас мы подойдём к самой полезной части лекции: алгоритму действий. Тут важно не «запомнить все возможные причины», а научиться действовать механически — почти как врач по протоколу.
Ключевой принцип: линковочные ошибки — это не «хаос», а обычно один из двух диагнозов. А значит, и лечение выбирается по понятной развилке.
Ниже схема (да, та самая блок‑схема, которую вы хотели забыть после школы, но она вернулась в образе линкера):
flowchart TD
A[Сборка упала на линковке] --> B{Сообщение содержит<br/>multiple definition?}
B -- да --> C[Найти, где определён символ 2+ раза]
C --> C1[Проверить: не лежит ли определение в .hpp]
C --> C2[Проверить: нет ли двух .cpp с одинаковым определением]
C --> C3[Выбрать лечение: вынести в один .cpp / inline / extern]
B -- нет --> D{Сообщение содержит<br/>undefined reference?}
D -- да --> E[Найти, где символ используется]
E --> E1["Найти объявление (обычно .hpp)"]
E --> E2["Найти определение (должно быть ровно одно)"]
E2 --> E3[Проверить совпадение: namespace, сигнатура, const/ref]
E2 --> E4["Проверить linkage: не static ли, не namespace{} ли"]
E2 --> E5[Проверить, что .cpp реально участвует в сборке]
D -- нет --> F[Другая линковочная проблема: читаем сообщение, но логика всё равно про символы]
Теперь развернём это словами, чтобы было «что делать руками».
Выписать имя символа из ошибки
Не пытайтесь сразу «чинить». Сначала выпишите, что именно упоминает линкер: имя функции или переменной. Иногда оно выглядит страшно (особенно в MSVC), но смысл один: «вот символ, с которым проблема».
Если ошибка multiple definition, то в сообщении часто будет два места: «первое определение здесь» и «второе определение здесь». Это уже почти готовый ответ.
Если ошибка undefined reference, будет место использования (кто зовёт), но не будет места определения (потому что его нет или линкер его не видит).
Понять, что вы хотели по дизайну
Это неожиданно важный шаг. Потому что способы «починить» бывают разные, и некоторые меняют смысл программы.
Если у вас multiple definition из-за глобальной переменной в заголовке, то самый быстрый «фикс» — сделать её static в заголовке. Ошибка исчезнет, потому что будет по копии переменной на каждый .cpp. Но вы случайно превратите «общую настройку приложения» в «несколько независимых настроек», и дальше будет весело, но не в хорошем смысле.
Поэтому задайте себе вопрос: эта сущность должна быть одна на всю программу или «локальная для файла»?
Подобрать правильный инструмент
Здесь полезно держать «шпаргалку выбора»:
| Что вы хотите получить по смыслу | Правильный инструмент |
|---|---|
| Одна общая переменная на всю программу | extern в .hpp + определение в одном .cpp |
| Внутренняя helper‑функция только для одного .cpp | static или namespace {} в .cpp |
| Маленькая функция, которую удобно держать в заголовке | inline в .hpp |
| Константа в заголовке «одна на программу» | часто inline constexpr |
И ещё раз коротко про inline: в современных формулировках стандартных материалов отдельно подчёркивается, что inline важен именно как средство удовлетворить ODR при множественных определениях, а не как «просьба ускориться».
6. Практический мини‑пример: MiniPlanner
Сейчас сделаем очень учебный (и немного садистский) сценарий: возьмём маленькую структуру проекта и специально создадим обе ошибки, чтобы вы увидели разницу по ощущениям.
Пусть структура такая:
MiniPlanner/
include/
planner/config.hpp
planner/math.hpp
src/
config.cpp
math.cpp
main.cpp
Сценарий A: multiple definition на глобальной переменной
Мы ошибочно пишем в config.hpp:
// include/planner/config.hpp
#pragma once
int g_day_start_minutes = 8 * 60; // 480
А затем подключаем config.hpp и в main.cpp, и в math.cpp. Линкер скажет: «у меня два g_day_start_minutes».
Чиним правильно:
// include/planner/config.hpp
#pragma once
extern int g_day_start_minutes;
// src/config.cpp
#include "planner/config.hpp"
int g_day_start_minutes = 8 * 60;
Теперь в программе ровно один объект переменной.
Сценарий B: undefined reference из-за namespace
Сделаем объявление:
// include/planner/math.hpp
#pragma once
namespace planner {
int add(int a, int b);
}
А в math.cpp забудем namespace:
// src/math.cpp
#include "planner/math.hpp"
// ошибка: это ::add, а не planner::add
int add(int a, int b) {
return a + b;
}
Исправление — честно совпасть по полному имени:
// src/math.cpp
#include "planner/math.hpp"
namespace planner {
int add(int a, int b) {
return a + b;
}
}
(Можно написать и int planner::add(...), но вложенный namespace обычно читается проще для новичка.)
Сценарий C: multiple definition на функции в заголовке и inline
Предположим, нам правда хочется маленькую функцию держать в заголовке, рядом с объявлением. Тогда делаем так:
// include/planner/math.hpp
#pragma once
namespace planner {
inline int clamp0(int x) {
return x < 0 ? 0 : x;
}
}
Если этот заголовок подключится в 10 .cpp, линкер не будет ругаться, потому что inline разрешает одинаковые определения в нескольких единицах трансляции (при соблюдении требований).
7. Типичные ошибки при работе с линковочными ошибками
Ошибка №1: лечить multiple definition через include guards.
Когда видите multiple definition, рука тянется добавить #pragma once или переписать guards. Это полезно, но решает другую проблему. multiple definition — это почти всегда «определение размножилось между .cpp», а guards работают внутри одного .cpp. Поэтому вы потратите время, а диагноз останется прежним.
Ошибка №2: «починить» всё через static, не думая о смысле.
Сделать переменную static в заголовке — быстрый способ убрать multiple definition. Но вы получаете по отдельной копии на каждый .cpp. Иногда это нормально (например, константные таблицы с внутренним linkage в старом стиле), но для настроек и общего состояния приложения это почти всегда логическая ошибка, которая потом проявится странным поведением.
Ошибка №3: видеть undefined reference и начинать добавлять #include наугад.
undefined reference редко лечится дополнительным #include. Обычно объявление у вас и так есть (иначе код бы не скомпилировался). Проблема почти всегда в том, что определения нет, оно не участвует в сборке, или у объявления и определения разные полные имена/сигнатуры/linkage.
Ошибка №4: не проверять совпадение namespace и сигнатуры до последней запятой.
Для человека sum(int,int) и sum(int,int,int) — «почти одинаковые». Для линкера — два разных символа. То же самое с planner::add и ::add. Если вы чините undefined reference, всегда проверяйте: пространство имён, список параметров, const, ссылки, тип возвращаемого значения.
Ошибка №5: путать объявление и определение переменной при extern.
Запись extern int x; — это объявление. А extern int x = 1; во многих контекстах уже превращается в определение (потому что есть инициализация). Новички часто случайно оставляют инициализацию в заголовке и получают multiple definition, хотя «я же написал extern». Здесь важно помнить: инициализация обычно создаёт объект.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ