1. Раздельная компиляция
Если вы только начинаете программировать, раздельная компиляция кажется странной церемонией: «Почему нельзя просто всегда делать g++ main.cpp -o app и жить спокойно?» На маленьких примерах можно. Но как только в проекте появляется 5–20 файлов, вы начинаете замечать, что сборка становится похожа на приготовление борща: даже если вы добавили одну щепотку соли, вам почему-то предлагают заново вырастить свёклу.
Раздельная компиляция решает ровно эту проблему. Идея очень простая: компилировать отдельно каждый .cpp в промежуточный артефакт (объектный файл), а затем «сшивать» их линковщиком. Тогда при изменении одного .cpp вам не нужно пересобирать всё: вы пересобираете только один объектник и заново линкуете результат.
Это экономит время, а ещё помогает лучше понимать, где именно произошла ошибка: на этапе компиляции конкретного файла или на этапе линковки всех частей в одну программу.
2. Объектный файл и флаг -c
Что такое объектный файл .o
Объектный файл (.o) — это результат компиляции одного .cpp файла. Внутри лежит машинный код (или близко к нему), плюс таблицы символов: какие функции и переменные этот файл определяет, и какие он хочет получить извне.
Представьте, что .cpp — это отдельная деталь конструктора LEGO. Компилятор делает из неё «готовую деталь» (объектник), но она может иметь дырочки, в которые должны вставиться другие детали. Линковщик — это тот, кто берёт все детали и собирает из них один большой корабль, проверяя, что все дырочки совпали и ничего не потерялось под диваном.
Важно помнить: объектник ещё не программа. Его нельзя «запустить». Это промежуточный результат.
Флаг -c: «компилируй, но не линкуй»
Флаг -c буквально говорит компилятору: «Сделай всё, что относится к компиляции, но остановись до этапа линковки». То есть компилятор возьмёт ваш .cpp, превратит его в объектный файл .o — и на этом закончит.
Типовая форма команды выглядит так:
g++ -std=c++23 -c file.cpp
Если не указать -o, компилятор обычно сделает объектник с предсказуемым именем file.o. Но когда вы собираете проект из нескольких файлов, лучше быстро приучить себя явно называть выходные артефакты:
g++ -std=c++23 -c file.cpp -o file.o
3. Мини‑проект TextStats
Чтобы не обсуждать раздельную компиляцию в вакууме, соберём маленькое приложение из нескольких файлов. Оно будет читать строку и печатать: количество буквенных символов и количество пробелов. Логика простая, но её удобно вынести в отдельный модуль.
Структура файлов
flowchart LR A[main.cpp] -->|#include 'text_stats.hpp'| B[text_stats.hpp] C[text_stats.cpp] -->|#include 'text_stats.hpp'| B A -->|вызывает функции| C
text_stats.hpp: объявления функций
Когда мы начинаем делить проект на файлы, очень важно психологически разделить две роли: заголовок .hpp сообщает «что существует» (объявления), а .cpp хранит «как именно это работает» (определения). Это похоже на меню в кафе: меню обещает, что «пицца существует», но саму пиццу вы получаете не из меню, а с кухни.
Создадим заголовок text_stats.hpp:
#pragma once
#include <string>
int count_letters(const std::string& s);
int count_spaces(const std::string& s);
Обратите внимание: здесь нет реализации, только сигнатуры. И это нормально.
text_stats.cpp: определения функций
Теперь сделаем файл text_stats.cpp, который реализует функции. Тут важно, чтобы сигнатуры в точности совпадали с тем, что было объявлено в .hpp.
#include "text_stats.hpp"
#include <cctype>
int count_letters(const std::string& s) {
int cnt = 0;
for (char c : s) {
if (std::isalpha(static_cast<unsigned char>(c))) ++cnt;
}
return cnt;
}
И вторая функция (мы специально держим примеры маленькими):
#include "text_stats.hpp"
int count_spaces(const std::string& s) {
int cnt = 0;
for (char c : s) if (c == ' ') ++cnt;
return cnt;
}
Да, тут два раза #include "text_stats.hpp". В реальном проекте вы бы держали обе функции в одном text_stats.cpp, но для обучения полезно видеть, что каждый .cpp сам по себе — компилируемая сущность.
main.cpp: используем функции
В main.cpp мы читаем строку и печатаем статистику:
#include <iostream>
#include <string>
#include "text_stats.hpp"
int main() {
std::string s;
std::getline(std::cin, s);
std::cout << "letters=" << count_letters(s) << '\n';
std::cout << "spaces=" << count_spaces(s) << '\n';
}
Пример ввода/вывода (для ощущения результата):
input: Hello world
output: letters=10
spaces=1
4. Сборка руками: .cpp → .o → executable
Теперь самое вкусное: как это собрать руками.
Шаг 1: компилируем каждый .cpp в объектник
Сначала компилируем каждый исходник отдельно:
g++ -std=c++23 -c text_stats.cpp -o text_stats.o
g++ -std=c++23 -c main.cpp -o main.o
На этом этапе ничего не запускается. У вас просто появляются файлы text_stats.o и main.o.
Шаг 2: линкуем объектники в программу
Теперь нужно «сшить» объектники в исполняемый файл. Это делается обычным вызовом g++, но уже без -c, и входами будут .o:
g++ main.o text_stats.o -o textstats
После этого появляется textstats (или textstats.exe на Windows-подобной среде). И вот его уже можно запускать:
./textstats
5. Инкрементальная пересборка
Теперь представьте типичную ситуацию: вы решили улучшить count_letters и добавить обработку каких-то символов. Вы меняете только text_stats.cpp.
С раздельной компиляцией вы делаете так:
-
пересобираете только один объектник:
g++ -std=c++23 -c text_stats.cpp -o text_stats.o -
заново линкуете:
g++ main.o text_stats.o -o textstats
main.cpp при этом не трогается, main.o остаётся прежним. Это и есть практический выигрыш: проект может быть большим, а вы меняете маленький кусочек — и пересобираете только его.
6. Ошибка undefined reference: что она значит
Ошибки компиляции обычно более-менее понятны: показали строку, колонку, что ожидали. Ошибки линковки сначала выглядят как «проклятие на древнем языке». Но у них есть очень чёткая логика.
Фраза undefined reference to ... означает: где-то есть использование функции/переменной, но среди того, что вы дали линковщику, нет подходящего определения.
Самая частая причина на нашем уровне — вы забыли добавить нужный объектник (или соответствующий .cpp) на шаге линковки.
Давайте специально соберём неправильно. Вы сделали:
g++ -std=c++23 -c main.cpp -o main.o
g++ main.o -o textstats
То есть text_stats.o вы не линковали. Что произойдёт? Компиляция main.cpp пройдёт, потому что заголовок text_stats.hpp дал компилятору объявления. Но линковщик, когда будет «сшивать» программу, скажет: «Окей, в main.o есть вызов count_letters, но где реализация? Я не вижу».
И вот тут возникает суперважное правило:
#include помогает компиляции (видимость объявлений), но не помогает линковке (подключению реализаций).
Психологически это ловушка новичка: «Но я же подключил header!» Да, подключили. Но header — это не .cpp и не .o.
7. Как разбирать undefined reference по списку файлов
Когда вы видите undefined reference, очень хочется начать шаманить: переставлять файлы, дописывать #include куда попало, переименовывать всё подряд. Это увлекательно, но обычно неэффективно.
Вместо этого держите в голове спокойный алгоритм.
Сначала определяем, что это линковка
Линковочные ошибки обычно выглядят так: в сообщении встречаются слова вроде undefined reference, ld, linker, collect2. Компилятор при этом мог уже успешно создать .o.
Если это линковка, то проблема почти всегда в одном из трёх:
- не добавили нужный .o (или .cpp) в линковку;
- объявили одно, а определили другое (сигнатуры не совпали);
- определили «слишком много раз» (это уже multiple definition, сегодня упомянем краем глаза).
Если не хватает объектника, задаём себе один вопрос
Вопрос звучит так: в каком .cpp должен был жить код этой функции?
Например, если ошибка про count_spaces, то очевидный кандидат — text_stats.cpp. Значит, на линковке должен быть text_stats.o.
То есть команда линковки должна перечислять объектники так, чтобы среди них нашёлся тот, где лежит определение.
Если объектник есть, но символ всё равно не найден
Это более хитрый случай: вы вроде бы линкуете text_stats.o, но ошибка не исчезает. Тогда очень вероятно, что компилятор и линковщик видят две разные функции:
- в main.cpp вы зовёте int count_letters(const std::string&),
- а в text_stats.cpp случайно написали int count_letters(std::string) (по значению) или забыли const.
Для человека это «почти одно и то же». Для линковщика это два разных символа. Он не обязан угадывать ваши намерения (и слава богу, иначе он бы начал угадывать вообще всё подряд).
Связь с ODR и inline
Сегодня мы не уходим в теорию глубоко, но полезно знать, что многие линковочные проблемы завязаны на правило «одно определение» (ODR). Даже в рабочих заметках к стандарту подчёркивают, что inline — это в первую очередь инструмент, позволяющий нескольким объявлениям удовлетворять ODR, а не «совет оптимизатору».
Переводя на человеческий: если вы начнёте пихать определения функций в заголовки без понимания, вы легко получите multiple definition. Поэтому на текущем уровне держим простое правило: объявления — в .hpp, определения — в .cpp.
Шпаргалка команд
Иногда полезно иметь короткую карту, чтобы не держать всё в голове.
| Цель | Команда | Результат |
|---|---|---|
| Скомпилировать один файл, но не линковать | |
объектник |
| Слинковать программу из объектников | |
исполняемый |
| Пересобрать только изменённый модуль | |
обновлённый |
| Быстро понять undefined reference | проверить, что на линковке есть нужный |
исчезновение ошибки |
8. Типичные ошибки
Ошибка №1: ожидать исполняемый файл после команды с -c.
-c выключает линковку. Компилятор честно делает объектник и останавливается. Если после g++ -c main.cpp вы пытаетесь запустить результат — вы пытаетесь запустить «полуфабрикат». Это как пытаться съесть тесто, потому что «я же уже смешал муку и воду».
Ошибка №2: думать, что #include «подключает реализацию».
#include подставляет текст заголовка в ваш .cpp на этапе препроцессора. Он помогает компиляции увидеть объявления, но не добавляет в линковку никакого кода. Поэтому undefined reference лечится не добавлением #include, а добавлением нужного .o (или .cpp) в команду линковки.
Ошибка №3: линковать не все объектники проекта.
Это самая частая причина undefined reference на уровне командной строки. Вы собрали main.o, собрали text_stats.o, но в линковке написали только g++ main.o -o app. Линковщик не телепат: если файла нет в списке, его как бы не существует.
Ошибка №4: несоответствие сигнатуры объявления и определения.
Очень коварно выглядит ситуация, когда в заголовке int f(const std::string&);, а в .cpp вы написали int f(std::string);. Компиляция каждого файла по отдельности может пройти, а линковка упадёт. Линковщик ищет точное совпадение символа. Для него это разные функции, даже если вам кажется, что «разницы почти нет».
Ошибка №5: «быстро исправить», перенеся определение функции в заголовок, и получить новые проблемы.
Иногда после undefined reference новичок думает: «Ага! Значит, надо сделать так, чтобы реализация была видна везде» — и вставляет тело функции в .hpp. На маленьком проекте это может «случайно сработать», но в реальном проекте легко приводит к multiple definition (потому что один и тот же код попадает в несколько единиц трансляции). На нашем текущем этапе безопаснее придерживаться базовой дисциплины: реализация — в .cpp, заголовок — для объявлений.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ