JavaRush /Курсы /C++ SELF /Раздельная компиляция: -c, объектники и линковка

Раздельная компиляция: -c, объектники и линковка

C++ SELF
33 уровень , 2 лекция
Открыта

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.

С раздельной компиляцией вы делаете так:

  1. пересобираете только один объектник:

    g++ -std=c++23 -c text_stats.cpp -o text_stats.o
  2. заново линкуете:

    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.

Если это линковка, то проблема почти всегда в одном из трёх:

  1. не добавили нужный .o (или .cpp) в линковку;
  2. объявили одно, а определили другое (сигнатуры не совпали);
  3. определили «слишком много раз» (это уже 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.

Шпаргалка команд

Иногда полезно иметь короткую карту, чтобы не держать всё в голове.

Цель Команда Результат
Скомпилировать один файл, но не линковать
g++ -std=c++23 -c a.cpp -o a.o
объектник
a.o
Слинковать программу из объектников
g++ a.o b.o -o app
исполняемый
app
Пересобрать только изменённый модуль
g++ -std=c++23 -c b.cpp -o b.o
обновлённый
b.o
Быстро понять undefined reference проверить, что на линковке есть нужный
.o
исчезновение ошибки

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, заголовок — для объявлений.

1
Задача
C++ SELF, 33 уровень, 2 лекция
Недоступна
Модуль приветствия
Модуль приветствия
1
Задача
C++ SELF, 33 уровень, 2 лекция
Недоступна
Сила модулей
Сила модулей
1
Задача
C++ SELF, 33 уровень, 2 лекция
Недоступна
Быстрая пересборка
Быстрая пересборка
1
Задача
C++ SELF, 33 уровень, 2 лекция
Недоступна
Пропавший объектник
Пропавший объектник
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ