JavaRush /Курси /C++ SELF /Не можна визначити одне й те саме двічі

Не можна визначити одне й те саме двічі

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

1. Проблема «подвійного визначення»

Коли ви пишете багатофайловий проєкт, мозок — особливо на початку — дуже хоче мислити так: «Я ж написав функцію. Отже, вона існує». Та компілятор і лінкер мислять інакше. Компілятор бачить лише один .cpp за раз і вірить заголовкам на слово. А лінкер — це той суворий перевіряльник, який наприкінці питає: «Гаразд. А де справжня функція? І чому вона трапляється у вас двічі?»

Щоб відчути проблему, уявіть, що ви збираєте шафу з двох коробок. В одній коробці — інструкція (заголовок), в іншій — деталі (реалізація). Якщо ви помилково поклали однакову «деталь» в обидві коробки, то під час фінального складання виявите, що у вас дві однакові ліві стінки, а правої — жодної. І це вже не питання «як гарно оформити код», а питання «що взагалі робити з цією шафою».

ODR — це правило, яке якраз і пояснює, скільки «деталей» одного виду має бути в підсумковій програмі і що саме вважається «одним і тим самим» з погляду компонування.

Нагадування: оголошення і визначення

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

Нижче — коротка табличка, яку корисно тримати в голові, коли ви дивитеся на помилку лінкера:

Сутність Оголошення Визначення
Функція
int add(int, int);
int add(int a, int b) { return a + b; }
Глобальна змінна
extern int g_counter;
(не створює обʼєкт)
int g_counter = 0;
(створює обʼєкт)
struct
/
enum class
майже завжди оголошення = визначення типу (але це окрема тема) тип «створюється» в місці оголошення

Важливо: повторювати оголошення зазвичай можна, якщо робити це в розумних межах, а от повторювати визначення майже завжди не можна. Саме тут і вмикається ODR.

Повторні оголошення — це зазвичай нормально

Щоб не почати боятися будь-якого повторення, давайте зафіксуємо контраст: повторювати оголошення можна, а повторювати визначення — не можна.

Ось приклад, який зазвичай не створює проблем:

// math.hpp
#pragma once
int add(int a, int b);

А ось десь у .cpp ви могли випадково ще раз оголосити:

int add(int a, int b); // повторне оголошення — зазвичай ок

Поки у вас є одне визначення десь в одному .cpp, проблем не буде. Оголошення — це «інформація для компілятора», а визначення — «реальні сутності для лінкера».

3. ODR «на пальцях»: що заборонено і чому лінкер не обирає

ODR (One Definition Rule) у спрощеному, зручному для навчання формулюванні звучить так: у програмі має бути рівно одна «реальна версія» кожної функції та кожної глобальної змінної (якщо не використовується спеціальний дозволений механізм, про який ми поговоримо в наступних лекціях).

Чому не можна мати дві? Тому що лінкер не «порівнює якість коду» і не обирає: «візьмемо ту функцію, де коментарі смішніші». Лінкер бачить два однакові імені (символи), наприклад parseCommand(std::string) з commands.cpp і такий самий parseCommand(std::string) з ui.cpp, і отримує логічну катастрофу: яке визначення вважати справжнім? Будь-який вибір був би свавіллям, а свавілля під час збирання — це дуже погана ідея.

Якщо пояснювати зовсім просто, ODR — це вимога, щоб на одну «бирку» (імʼя символу) припадала одна «деталь». Інакше підсумкова програма стала б непередбачуваною: різні компілятори й різні режими збирання могли б «обирати» різні визначення — і ви отримували б баги рівня «у мене на ноутбуці працює, а в друга на ПК падає».

4. Найчастіша пастка новачка: визначення функції в заголовку

Зараз буде ситуація, через яку проходили буквально всі: ви написали маленьку корисну функцію, вирішили «покласти її в .hpp, щоб була під рукою», підключили цей заголовок у два .cpp — і раптом лінкування повідомляє щось на кшталт multiple definition.

Давайте вбудуємо це в наш навчальний міні‑проєкт. Уявімо, що ми робимо консольний застосунок TaskBox: він зберігає список задач, уміє додавати їх і друкувати. У нас є кілька .cpp: main.cpp (CLI), storage.cpp (робота зі сховищем), format.cpp (друк).

І ось ми захотіли маленьку функцію «обрізати пробіли по краях» і поклали її в заголовок:

// utils.hpp
#pragma once
#include <string>

std::string trim(const std::string& s) {      // <-- визначення в .hpp
    if (s.empty()) return s;
    return s;                                 // поки "заглушка"
}

А тепер два файли включають utils.hpp:

// storage.cpp
#include "utils.hpp"
#include <string>

std::string normalizeTitle(const std::string& t) {
    return trim(t);
}
// format.cpp
#include "utils.hpp"
#include <string>

std::string formatTitle(const std::string& t) {
    return trim(t);
}

Що станеться «під капотом»? Тут важливо зрозуміти одну річ: кожен .cpp отримає власний текст функції trim, тому що #include просто вставляє код. Тобто фактично у вас буде:

  • у storage.cpp всередині одиниці трансляції є std::string trim(...);
  • у format.cpp всередині іншої одиниці трансляції є ще одне std::string trim(...).

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

5. Чому include guards і #pragma once не рятують

У цьому місці люди часто намагаються «лікувати» проблему за принципом «давайте додамо ще один #pragma once, раптом допоможе». Не допоможе — і це нормально. Просто #pragma once та include guards розвʼязують іншу задачу: вони захищають від повторного включення заголовка усередині одного .cpp, коли заголовки включають одне одного «по колу» або «ланцюжком».

Тобто #pragma once каже: «у межах цього .cpp встав цей заголовок лише один раз». Але він не може сказати: «у всій програмі встав цей заголовок лише в один .cpp». Заголовок за задумом — спільний інтерфейс, і саме тому його зазвичай включають у багато файлів.

Корисно уявити таку схему:

flowchart TD
    H["utils.hpp: містить trim()"] --> A[storage.cpp включає utils.hpp]
    H --> B[format.cpp включає utils.hpp]
    A --> OA[storage.o: містить trim]
    B --> OB[format.o: містить trim]
    OA --> L[лінкер]
    OB --> L
    L --> E[помилка: два trim]

Тобто захист від повторного включення працює усередині A, але ніяк не заважає B також включити той самий заголовок і отримати ще одну копію визначення.

6. Ще більш підступна пастка: глобальна змінна в заголовку

Якщо у випадку з функцією ви бодай бачите «тіло», то глобальна змінна в заголовку іноді виглядає як цілком невинний рядок із «налаштуваннями проєкту». Саме тому вона регулярно перетворюється на катастрофу під час лінкування.

Припустімо, ми хочемо спільний «наступний id задачі»:

// ids.hpp
#pragma once

int g_nextTaskId = 1;  // <-- визначення в заголовку

І цей ids.hpp включено в storage.cpp і main.cpp. Результат той самий: ви отримали два різні екземпляри глобальної змінної з одним і тим самим іменем символу (у термінах лінкера), і лінкер відмовиться збирати програму.

На відміну від функції, тут психологічно навіть гірше: ви думали, що це «одна змінна на всю програму», а фактично написали: «створюй змінну щоразу, коли заголовок включили». ODR саме про це: «створювати» треба рівно один раз.

7. Як лагодити порушення ODR без майбутніх тем

Далі в курсі на нас чекають окремі лекції про inline і про внутрішнє лінкування (static/анонімні простори імен). Поки що ми туди не заглиблюємося — не тому, що це якась секретна магія, а тому, що спершу важливо засвоїти «стандартний безпечний шлях новачка».

Цей шлях простий: у заголовку лишаємо тільки оголошення, а визначення переносимо в один .cpp.

Повернімося до нашої проблемної trim. Виправімо її «за підручником».

Заголовок тепер містить лише оголошення:

// utils.hpp
#pragma once
#include <string>

std::string trim(const std::string& s); // оголошення

А реалізація — в одному .cpp:

// utils.cpp
#include "utils.hpp"

std::string trim(const std::string& s) { // визначення
    if (s.empty()) return s;
    return s; // поки "заглушка", але вже в одному місці
}

Тепер storage.cpp і format.cpp можуть спокійно включати utils.hpp (оголошення), викликати trim, компіляція пройде, і лінкер знайде рівно одне визначення в utils.cpp.

Те саме і з глобальними змінними: якщо вам справді потрібна одна спільна змінна на всю програму, її визначають в одному .cpp, а в заголовку лишають лише оголошення «вона десь існує». Але конкретні ключові слова й нюанси запису такого оголошення ми розберемо в наступних лекціях, щоб не намагатися вмістити все в один день.

8. Тонкий момент: ODR і слово inline

Справедливе запитання: «А як тоді взагалі існують маленькі функції в заголовках реальних бібліотек?» Існують — тому що в C++ є спеціальні дозволені механізми, які дають змогу мати визначення в заголовках без порушення ODR. Один із них повʼязаний зі специфікатором inline.

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

Але це ми обережно й докладно розберемо далі за планом. Зараз вам потрібна залізобетонна звичка: якщо ви не впевнені — не визначайте звичайні функції та змінювані глобальні змінні в заголовках.

Модель мислення: скільки разів код зʼявиться в програмі

Щоб рідше натрапляти на «multiple definition», корисно привчити себе до простого запитання перед тим, як щось писати в .hpp: «Якщо я покладу це сюди, скільки разів цей код опиниться в підсумковій програмі?»

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

Для нашого TaskBox це означає таке: усі «звичайні» функції форматування, парсингу та роботи із задачами оголошуємо в .hpp, а реалізуємо в .cpp. Тоді додавання нового модуля (наприклад, commands.cpp) не перетворюється на лотерею: «збереться чи ні».

9. Типові помилки

Помилка № 1: визначати функцію в .hpp «бо так зручніше».
Ця зручність короткочасна: поки у вас один .cpp, ви не бачите проблеми. Щойно їх стає два, ви отримуєте дублікати визначень. Правильна дисципліна для новачка: у заголовку — оголошення, у вихідному файлі — визначення. А «винятки з правила» вивчаємо окремо, коли ви вже впевнено читаєте помилки лінкування.

Помилка № 2: думати, що #pragma once розвʼязує конфлікт між різними .cpp.
#pragma once та include guards обмежують повторне включення заголовка лише в межах однієї одиниці трансляції. Вони рятують від повторного тексту в одному .cpp, але ніяк не заважають іншому .cpp включити той самий заголовок і отримати друге визначення.

Помилка № 3: розміщувати глобальні змінні в заголовках як «спільні налаштування».
Глобальна змінна з ініціалізацією в .hpp майже гарантовано розмножиться під час #include. У результаті ви отримуєте «multiple definition» або — що ще гірше — несподівану логіку: ви думаєте, що стан спільний, а він виявляється не таким, як ви уявляли (залежно від того, як саме ви написали код).

Помилка № 4: плутати «оголошення» і «визначення» через схожий синтаксис.
int f(); і int f() { ... } виглядають схоже, але для збирання це дві різні планети. Якщо ви тримаєте в голові просте правило «тіло функції = визначення», то помітно швидше діагностуєте проблеми лінкування.

Помилка № 5: «виправити» проблему копіюванням коду в різні .cpp.
Іноді новачок бачить помилку й вирішує: «ну гаразд, нехай у кожному .cpp буде своя версія функції». Це не розвʼязання, а початок хаосу: логіка розходиться, баги стають невловимими, а проєкт перетворюється на набір неузгоджених шматочків. Якщо функція спільна — у неї має бути одна реалізація в одному місці.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ