JavaRush /Курси /C++ SELF /Declaration vs definition: звідки виникають помилки лінку...

Declaration vs definition: звідки виникають помилки лінкування

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

1. Оголошення і визначення

Коли програма перестає бути файлом main.cpp на 20 рядків, мозок сам просить: «Треба навести лад». І тут C++ каже: «Звісно! Розділімо код на файли». Звучить як перемога… аж доки одного дня ви не побачите повідомлення на кшталт: «Я все скомпілював, але зібрати не можу». У такий момент зазвичай хочеться звинуватити всесвіт, компілятор і сусіда по парті.

Проблема майже завжди зводиться до простого: компілятору іноді досить знати, що дещо існує (це оголошення), але щоб зібрати підсумкову програму, потрібно, аби десь було реальне «тіло» (це визначення). Логіка «сказав vs зробив» у C++ трапляється постійно. Саме вона пояснює левову частку «магічних» помилок у багатофайлових проєктах.

Оголошення: «Я обіцяю, що воно існує»

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

Найзнайоміший вид оголошення — прототип функції (тобто «сигнатура без тіла»):

// math.hpp
#pragma once

int add(int a, int b); // оголошення (declaration)

В іншому файлі компілятор уже може згенерувати код виклику:

// main.cpp
#include <iostream>
#include "math.hpp"

int main() {
    std::cout << add(2, 3) << "\n"; // 5
}

Зверніть увагу на психологічний трюк: здається, що раз main.cpp «бачить» add, то все має бути добре. Але насправді #include "math.hpp" приніс лише обіцянку, а не «піцу».

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

Ще один приклад оголошення — оголошення типу через struct, enum class тощо. Але тут є важливий нюанс: визначення типу — це не те саме, що визначення функції чи змінної. До цього ми ще повернемося трохи пізніше, бо саме тут новачки найчастіше й заплутуються.

Визначення: «Ось воно, ось його тіло або памʼять»

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

У C++ визначення — це те, що створює сутність:

  • для функції — дає тіло { ... }
  • для змінної — виділяє памʼять (створює обʼєкт зберігання)
  • для типу — задає структуру (поля, варіанти тощо)

Почнімо з найпростішого: визначення функції.

// math.cpp
#include "math.hpp"

int add(int a, int b) {     // визначення (definition)
    return a + b;
}

От тепер «піца існує»: лінкер зможе знайти реалізацію add.

І ось ключова практика, на якій тримається половина індустрії: у .hpp зазвичай лежать оголошення, а у .cpp — визначення.

Тепер подивімося на змінну. Усередині функції все зазвичай прозоро: ви написали змінну — ви її визначили.

int main() {
    int x = 10;   // це визначення змінної x
    x += 5;
}

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

2. Де що живе: типи, функції, змінні й лінкер

На цьому етапі корисно розкласти все по поличках, бо в голові новачка часто живе одна небезпечна думка: «ну struct же теж визначення… значить, лінкер теж має його шукати?». Спойлер: ні. І це хороша новина.

Важливо памʼятати, що багатофайловий проєкт компілюється як набір окремих «шматків» — одиниць трансляції. Саме поняття translation unit є базовим для моделі збирання: по суті, це результат того, що вийшло з .cpp після всіх #include і роботи препроцесора. У стандарті це поняття привʼязане до фаз трансляції й слугує фундаментом усієї моделі збирання.

Тепер розділімо світ на дві категорії.

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

Типи (struct, enum class, using) — це здебільшого компіляторна математика: вони потрібні, щоб перевірити коректність коду й розкласти дані в памʼяті, але «шматочка машинного коду з іменем struct Task» зазвичай не існує як окремої сутності, яку лінкер має знайти.

Щоб закріпити, ось невелика таблиця. Вона спрощена, бо реальність завжди складніша, але для старту — просто золото:

Сутність Оголошення (declaration) Визначення (definition) У кого виникне проблема, якщо визначення немає
Функція
int f(int);
int f(int x){...}
лінкер (часто), іноді компілятор
Змінна «повідомити тип/імʼя» «створити обʼєкт/памʼять» лінкер (для зовнішнього використання)
Тип (struct)
struct Task;
struct Task { ... };
компілятор (якщо ви використовуєте поля/розмір)

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

3. Розділяємо інтерфейс і реалізацію в TaskBook

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

Уявімо мінімальну структуру, як ви вже робили в попередніх лекціях:

TaskBook/
  include/
    task.hpp
    printer.hpp
  src/
    task.cpp
    printer.cpp
    main.cpp

task.hpp: оголошуємо модель і функції

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

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

namespace taskbook {

struct Task {
    std::string title;
    bool done = false;
};

Task make_task(std::string title); // оголошення

} // namespace taskbook

Тут struct Task { ... }; — це визначення типу. І його нормально тримати в заголовку: іншим файлам потрібно знати, які поля є у Task, щоб із ним працювати.

А от Task make_task(std::string title); — це оголошення функції. Ми обіцяємо, що така функція буде, і показуємо, як нею користуватися.

task.cpp: даємо визначення функції

Тепер реалізуймо цю обіцянку.

// task.cpp
#include "task.hpp"

namespace taskbook {

Task make_task(std::string title) {     // визначення
    Task t;
    t.title = std::move(title);
    t.done = false;
    return t;
}

} // namespace taskbook

Зверніть увагу: імʼя і простір імен мають збігатися. Якщо в заголовку taskbook::make_task, а в .cpp ви випадково напишете все без namespace taskbook, то формально визначите іншу функцію. А потім сумуватимете.

printer.hpp: оголошуємо друк

Зробімо модуль друку. У заголовку оголошуємо функції, але без реалізації.

// printer.hpp
#pragma once
#include <vector>
#include "task.hpp"

namespace taskbook {

void print_tasks(const std::vector<Task>& tasks); // оголошення

} // namespace taskbook

printer.cpp: визначаємо друк

// printer.cpp
#include <iostream>
#include "printer.hpp"

namespace taskbook {

void print_tasks(const std::vector<Task>& tasks) {     // визначення
    for (const Task& t : tasks) {
        std::cout << (t.done ? "[x] " : "[ ] ") << t.title << "\n";
    }
}

} // namespace taskbook

main.cpp: використовуємо лише оголошення

І ось момент істини: main.cpp використовує функції, бачачи лише їхні оголошення з .hpp.

// main.cpp
#include <vector>
#include "task.hpp"
#include "printer.hpp"

int main() {
    std::vector<taskbook::Task> tasks;
    tasks.push_back(taskbook::make_task("Прочитати про declaration/definition"));
    taskbook::print_tasks(tasks); // [ ] Прочитати про declaration/definition
}

main.cpp не зобовʼязаний знати, як саме make_task створює задачу і як print_tasks друкує список. Йому досить оголошень. Але збирання проєкту загалом зобовʼязане мати визначення цих функцій десь у .cpp.

4. Мінідіагностика: як зрозуміти, що бракує визначення

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

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

Розгляньмо класичний сценарій помилки, повʼязаної саме з нашою темою: виклик є, оголошення є, а визначення немає.

Оголосили, але забули визначити

Припустімо, ви написали в printer.hpp:

#pragma once
#include <vector>
#include "task.hpp"

namespace taskbook {
void print_tasks(const std::vector<Task>& tasks); // оголошення
}

А printer.cpp забули створити або створили, але не додали до проєкту збирання. Тоді main.cpp цілком може скомпілюватися: компілятору цього достатньо, бо він бачить оголошення.

Але на етапі збирання підсумкового застосунку виникне помилка на кшталт «не знайдено визначення функції taskbook::print_tasks(...)». Сенс помилки завжди один: «Ти пообіцяв, що функція існує, я навіть повірив і згенерував виклик, але де реалізація?».

Визначення є, але не те

Друга популярна історія: оголошення і визначення «схожі», але відрізняються. Іноді — одним параметром, іноді — namespace, іноді — просто опискою в імені.

Оголосили так:

// api.hpp
#pragma once
namespace taskbook {
int count_done(); // оголошення
}

А визначили так:

// api.cpp
#include "api.hpp"
namespace taskbook {
int count_done(int total) {   // інше імʼя/сигнатура -> інша функція
    return total;
}
}

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

Чому це взагалі називають проблемою лінкування

У побутовому сенсі лінкер займається тим, що знаходить визначення для використаних сутностей і збирає все в один виконуваний файл. Правила «що вважається однією й тією самою сутністю» і «коли використання потребує визначення» — велика тема, якій навіть присвячено окремі розділи стандарту, наприклад блоки, повʼязані з ODR та odr-use.

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

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

Помилка № 1: плутати «підключив заголовок» із «підключив реалізацію».
Коли ви пишете #include "printer.hpp", ви підключаєте текст заголовка, тобто оголошення. Але .cpp з реалізацією через це не підтягується автоматично. У результаті main.cpp може виглядати коректно, а збирання впаде, бо визначення функцій лежать в іншому .cpp, якого немає в проєкті або який не компілюється.

Помилка № 2: оголосити функцію і забути зробити визначення або зробити його в іншому namespace.
Дуже підступний випадок: ви чесно написали int f(); у .hpp, почали використовувати f() у різних місцях, а потім «колись» вирішили дописати .cpp… і забули. Або визначили f() без потрібного namespace, і вийшла інша функція. Компілятор перевірить виклик за оголошенням, але підсумкове збирання вимагатиме реального визначення саме ns::f().

Помилка № 3: невідповідність сигнатури між оголошенням і визначенням.
Якщо в заголовку int sum(int, int);, а в .cpp ви випадково написали int sum(int, int, int), то це два різні символи, тобто в практичному сенсі — дві різні функції. По-людськи вони «схожі», але для компілятора й лінкера це різні сутності. Підсумок — «не знайдено те, що було обіцяно».

Помилка № 4: очікувати, що «тип теж треба лінкувати».
Новачки іноді намагаються винести struct у .cpp, залишивши в .hpp лише «щось на кшталт оголошення», а потім дивуються, чому не можна створити змінну цього типу в іншому файлі. Типи — це компіляторна інформація: щоб використовувати поля, розмір, конструктори за замовчуванням тощо, компілятору потрібно бачити визначення типу, зазвичай у заголовку. Лінкер тут узагалі не головний герой.

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

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