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) | Кто “страдает”, если определения нет |
|---|---|---|---|
| Функция | |
|
линкер (часто), иногда компилятор |
| Переменная | «сообщить тип/имя» | «создать объект/память» | линкер (для внешнего использования) |
| Тип (struct) | |
|
компилятор (если вы используете поля/размер) |
И вот важная мысль, ради которой мы вообще это обсуждаем: компилятор проверяет, что вы правильно используете имена, а линкер проверяет, что у имён есть реальная реализация/объект. Поэтому возможна ситуация «компиляция прошла, а сборка нет».
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 помогает писать текст, но не отменяет законов физики сборки: если определение не скомпилировалось или не попало в итоговую сборку, программа не соберётся.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ