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;
}

Но на уровне файлов (глобально) ситуация интереснее: там «простая строка» может внезапно стать причиной проблем при сборке большого проекта. Сегодня мы не будем углубляться в тонкие случаи и «исправляющие ключевые слова» (они появятся в следующих лекциях дня), но зафиксируем базовую идею: определение создаёт сущность, объявление — только описывает её.

3. Где живёт что: типы, функции, переменные и линкер

На этом этапе полезно разложить сущности по полочкам, потому что в голове новичка часто живёт одна опасная мысль: «ну 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 { ... };
компилятор (если вы используете поля/размер)

И вот важная мысль, ради которой мы вообще это обсуждаем: компилятор проверяет, что вы правильно используете имена, а линкер проверяет, что у имён есть реальная реализация/объект. Поэтому возможна ситуация «компиляция прошла, а сборка нет».

4. Разделяем интерфейс и реализацию в 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.

5. Мини-диагностика: как понять, что не хватает определения

Очень легко попасть в ловушку: «Раз компилятор не ругается — значит всё хорошо». На самом деле компилятор может быть «слишком оптимистичным»: он делает свою часть работы на основании того, что вы ему пообещали через объявления.

Когда в проекте много файлов, появляется отдельная стадия, которая «сшивает» результат: она опирается на то, что каждый .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).

Но сегодня нам достаточно практического критерия: если у вас «всё написано правильно» на уровне синтаксиса, но проект всё равно не собирается, очень вероятно, что где-то нет определения того, что вы используете, или определение «уехало» и стало другим.

6. Типичные ошибки

Ошибка №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 помогает писать текст, но не отменяет законов физики сборки: если определение не скомпилировалось или не попало в итоговую сборку, программа не соберётся.

1
Задача
C++ SELF, 29 уровень, 0 лекция
Недоступна
Модуль калькулятора
Модуль калькулятора
1
Задача
C++ SELF, 29 уровень, 0 лекция
Недоступна
Привет сервис
Привет сервис
1
Задача
C++ SELF, 29 уровень, 0 лекция
Недоступна
Профиль игрока
Профиль игрока
1
Задача
C++ SELF, 29 уровень, 0 лекция
Недоступна
Список заметок
Список заметок
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ