JavaRush /Курси /C++ SELF /Типові помилки лінкера

Типові помилки лінкера

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

1. Іноді після компіляції «свариться» лінкер

Уявіть, що компілятор — це викладач, який перевіряє кожну контрольну роботу окремо, а лінкер — методист, який потім намагається зібрати з цих робіт один спільний підручник. Компілятор може сказати: «Так, у цьому зошиті все граматично правильно». Але коли методист збирає спільний підручник, зʼясовується, що розділ 5 написано двічі, а розділ 7 взагалі забуто. Приблизно так і виникають помилки лінкування.

У багатофайлових проєктах C++ «зламатися» може не лише код — синтаксис або типи, — а й звʼязки між файлами: де і що визначено, хто кого бачить і чи існує символ рівно в одному місці, де він і має існувати.

Щоб не боротися з лінкером, як із міфічним босом у грі («я ж усе зробив правильно!»), важливо навчитися читати ці помилки як звичайні інженерні повідомлення. Лінкер майже завжди чесно каже, що він намагався знайти і чому не зміг цього зробити.

Дві категорії помилок лінкування

Перш ніж розбирати приклади, корисно зафіксувати просту таблицю. Вона часто економить 30 хвилин «танців із #include», які насправді тут узагалі ні до чого.

Повідомлення лінкера Людський переклад Що це означає в термінах програми
multiple definition of ...
«Одне й те саме визначено більше ніж один раз» Лінкер знайшов два або більше визначень одного символу
undefined reference to ...
«Є використання, але реалізації немає» Лінкер не знайшов жодного визначення, хоча код десь на нього посилається

Зверніть увагу на важливий момент: обидві помилки — про визначення (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, і це нормально, якщо воно однакове».

3. 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() {  // внутрішнє звʼязування: тільки всередині 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.

4. Діагностика: як швидко знайти правильне виправлення

Тепер перейдемо до найкориснішої частини лекції: алгоритму дій. Тут важливо не «запамʼятати всі можливі причини», а навчитися діяти механічно — майже як лікар за протоколом.

Ключовий принцип такий: помилки лінкування — це не «хаос», а зазвичай один із двох діагнозів. А отже, і спосіб виправлення можна вибрати за зрозумілою розвилкою.

Нижче — схема. Так, це та сама блок-схема, яку ви, можливо, хотіли забути після школи, але вона повернулася в образі лінкера:

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
Внутрішня допоміжна функція лише для одного .cpp static або namespace {} у .cpp
Маленька функція, яку зручно тримати в заголовку inline у .hpp
Константа в заголовку, одна на всю програму часто inline constexpr

І ще раз коротко про inline: у сучасних формулюваннях стандартних матеріалів окремо підкреслюють, що inline важливий насамперед як засіб задовольнити ODR за множинних визначень, а не як «прохання прискоритися».

5. Практичний мініприклад: 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 дозволяє однакові визначення в кількох одиницях трансляції — за дотримання вимог, звісно.

6. Типові помилки під час роботи з помилками лінкування

Помилка № 1: лікувати multiple definition через include guards.
Коли ви бачите multiple definition, рука так і тягнеться додати #pragma once або переписати guards. Це корисно, але розвʼязує іншу проблему. multiple definition — це майже завжди ситуація, коли «визначення розмножилося між .cpp», а guards працюють усередині одного .cpp. Тому ви витратите час, а діагноз залишиться тим самим.

Помилка № 2: «полагодити» все через static, не думаючи про зміст.
Зробити змінну static у заголовку — швидкий спосіб прибрати multiple definition. Але ви отримаєте по окремій копії на кожен .cpp. Іноді це нормально — наприклад, для константних таблиць із внутрішнім звʼязуванням у старому стилі, — але для налаштувань і спільного стану застосунку це майже завжди логічна помилка, яка згодом проявиться дивною поведінкою.

Помилка № 3: бачити undefined reference і починати навмання додавати #include.
undefined reference рідко лікується додатковим #include. Зазвичай оголошення у вас і так є — інакше код просто не скомпілювався б. Проблема майже завжди в тому, що визначення немає, воно не бере участі у збиранні, або оголошення й визначення мають різні повні імена, сигнатури чи тип звʼязування.

Помилка № 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». Тут важливо памʼятати: ініціалізація зазвичай створює обʼєкт.

1
Опитування
ODR, звʼязування, рівень 29, лекція 4
Недоступний
ODR, звʼязування
ODR, звʼязування
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ