Зачем понимать, как работает сборка
В IDE всё происходит по нажатию одной кнопки — и кажется, что знать нечего.
Но рано или поздно появляется ошибка undefined reference to ...
или проект не собирается на другом компьютере. И тогда без понимания
этапов сборки найти причину невозможно.
Найти причину ошибки
Ошибки на разных этапах выглядят совершенно по-разному. Поняв, на каком этапе они возникли, вы сразу сузите круг поиска.
Собирать проект из нескольких файлов
Как только программа становится больше одного файла, без заголовков
.h и системы сборки не обойтись.
Использовать сторонние библиотеки
Любая библиотека — это набор уже собранных объектных файлов и заголовков. Нужно уметь их подключать.
Четыре этапа: от .cpp до исполняемого файла
Один файл main.cpp проходит четыре преобразования, прежде чем стать
программой. Нажмите на любой этап, чтобы увидеть подробности.
#include, #define,
#ifdef. Разворачивает заголовки, подставляет макросы,
удаляет комментарии.
# Одной командой (внутри вызываются все четыре этапа)
g++ main.cpp -o program
# То же самое, но по шагам — видно промежуточные файлы
g++ -E main.cpp -o main.i # только препроцессор
g++ -S main.i -o main.s # препроцессор + компиляция
g++ -c main.s -o main.o # + ассемблирование
g++ main.o -o program # + линковка
Почему проект разбивают на несколько файлов
Один файл main.cpp на 5000 строк — это кошмар для поддержки.
Проект разбивают на модули, каждый в своей паре .h + .cpp.
Плохо: один большой файл
// все объявления и определения здесь
class Product { ... };
class Order { ... };
class Customer { ... };
void saveToFile(...) { ... }
void loadFromFile(...) { ... }
int main() { ... }
// Проблемы:
// - найти нужное место сложно
// - изменения в одном классе требуют
// пересборки всего файла
// - два программиста не могут
// работать параллельно
Хорошо: модульный проект
project/
├── include/
│ ├── Product.h // объявления
│ ├── Order.h
│ └── Customer.h
├── src/
│ ├── Product.cpp // определения
│ ├── Order.cpp
│ ├── Customer.cpp
│ ├── io.cpp
│ └── main.cpp // только точка входа
└── CMakeLists.txt
.h — объявления: какие функции
и классы существуют. .cpp — определения: как они работают.
Компилятор должен знать объявления, чтобы использовать функцию в другом файле.
Демонстрация: что делает #include
Директива #include буквально вставляет содержимое одного файла
в другой. Нажмите «Развернуть #include», чтобы увидеть, что видит компилятор
после работы препроцессора.
Исходный проект
#include "math.h"
int main() {
int r = square(5);
return 0;
}
int square(int x);
После препроцессора
#include уже развёрнуты, все #define уже подставлены.
Проблема двойного включения и header guards
Если заголовок включён дважды, компилятор увидит определения два раза и
выдаст ошибку redefinition. Решение — header guards.
Интерактивно: что происходит без защиты
// Способ 1: #pragma once — современный, короткий
#pragma once
int square(int x);
// Способ 2: классический header guard с #ifndef
#ifndef MATH_H
#define MATH_H
int square(int x);
#endif // MATH_H
// Способ 3: в новых проектах — обычно только #pragma once
// (поддерживается всеми популярными компиляторами)
.h и этот заголовок включён из нескольких .cpp —
получите ошибку линковки «multiple definition». Определения должны быть в
.cpp.
Ошибки линковки: undefined reference и multiple definition
Это самые загадочные ошибки для новичка. Компилятор их не выдаёт — они появляются на последнем этапе и выглядят совершенно иначе.
Статические и динамические библиотеки
Когда кода становится много и он переиспользуется в нескольких проектах, его выносят в библиотеку. Библиотеки бывают двух видов.
📦 Статическая библиотека
# Создание
g++ -c math.cpp -o math.o
ar rcs libmath.a math.o
# Использование
g++ main.cpp -L. -lmath -o program
Код библиотеки копируется внутрь исполняемого файла при линковке.
- ✅ Программа самодостаточна — можно переносить одним файлом
- ✅ Нет задержки на загрузку библиотеки при запуске
- ❌ Размер программы больше
- ❌ При обновлении библиотеки нужно пересобирать программу
🔗 Динамическая библиотека
# Создание
g++ -fPIC -c math.cpp -o math.o
g++ -shared math.o -o libmath.so
# Использование
g++ main.cpp -L. -lmath -o program
LD_LIBRARY_PATH=. ./program
Программа содержит только ссылку на библиотеку, код грузится при запуске.
- ✅ Программа меньше
- ✅ Библиотеку можно обновить, не пересобирая программу
- ✅ Экономия памяти при запуске нескольких программ
- ❌ Библиотека должна быть на месте при запуске
CMake: система сборки, независимая от платформы
Вызывать g++ вручную для каждого файла проекта — неудобно.
На смену этому пришли системы сборки. CMake — самая популярная из них.
cmake_minimum_required(VERSION 3.20)
project(MyApp VERSION 1.0 LANGUAGES CXX)
# Стандарт C++
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Исполняемый файл из нескольких .cpp
add_executable(myapp
src/main.cpp
src/math.cpp
src/geometry.cpp
)
# Директории с заголовками
target_include_directories(myapp PRIVATE include)
# 1. Конфигурация (создаёт Makefile или Ninja-файл в build/)
cmake -S . -B build
# 2. Сборка (запускает компиляцию и линковку)
cmake --build build
# 3. Запуск
./build/myapp
Интерактивно: соберите свой CMakeLists.txt
Настройте параметры проекта и посмотрите, как будет выглядеть итоговый файл сборки.
Лучшие практики
1. Заголовок — только объявления, реализация — в .cpp
В .h пишите только то, что нужно для использования: объявления функций,
классов, константы. Определения функций — в .cpp. Единственные
исключения — шаблоны, inline-функции и constexpr.
2. #pragma once в каждом заголовке
Одна строка сверху файла — и вы защищены от двойного включения. Это дешевле, чем разбираться с ошибками redefinition.
3. Собирайте в отдельную папку build/
Никогда не запускайте cmake в корне проекта — все объектные файлы
и Makefile'ы попадут в исходники. Собирайте в build/ и добавляйте
её в .gitignore.
build/
*.o
*.a
*.so
*.dylib
*.exe
CMakeCache.txt
CMakeFiles/
4. Не пишите относительные пути к заголовкам вручную
Вместо #include "../include/math.h" используйте
target_include_directories в CMake. Тогда из любого файла можно
писать #include "math.h", и всё работает одинаково.
5. Включите предупреждения — это ловит большинство ошибок
target_compile_options(myapp PRIVATE
-Wall -Wextra -Wpedantic
)
Антипаттерн: определения функций в .h
// math.h
int square(int x) { // ← определение в заголовке
return x * x;
}
// main.cpp
#include "math.h" // square определена здесь
int main() { return square(5); }
// other.cpp
#include "math.h" // square определена И ЗДЕСЬ
// Линковщик: multiple definition of 'square(int)'
Правильно: объявление в .h, определение в .cpp.
Антипаттерн: #include "big_library.h" в каждом файле
Раздувает время компиляции. Каждый #include — это буквальная вставка
десятков тысяч строк. Если заголовок нужен только в одном .cpp,
включайте его там, а не в общем .h. По возможности используйте
forward declarations вместо включения.
Антипаттерн: ручные g++-команды для всего проекта
Работает, пока файлов пять. С ростом проекта становится невозможно поддерживать: забытые зависимости, несогласованные флаги, разные настройки для разных файлов. Используйте CMake с самого начала.
Шпаргалка
| Задача | Команда |
|---|---|
| Скомпилировать один файл | g++ main.cpp -o program |
| Скомпилировать без линковки | g++ -c file.cpp -o file.o |
| Посмотреть код после препроцессора | g++ -E file.cpp |
| Собрать из нескольких .cpp | g++ a.cpp b.cpp main.cpp -o program |
| Подключить папку с заголовками | g++ -Iinclude main.cpp |
| Подключить библиотеку | g++ main.cpp -L. -lmylib |
| Строгие предупреждения | -Wall -Wextra -Wpedantic |
| Оптимизация | -O2 |
| Отладочные символы | -g |
| Собрать через CMake | cmake -S . -B build && cmake --build build |
| Пересобрать с нуля | rm -rf build && cmake -S . -B build |
| Запустить тесты CTest | cd build && ctest |
Задание
- Скомпилируйте программу из одного файла
main.cppкомандойg++ main.cpp -o program. Запустите её. - Сделайте то же самое в четыре команды: сначала
-E, потом-S, потом-c, потом линковка. Посмотрите размеры промежуточных файлов. - Создайте три файла:
math.h(объявлениеint square(int);),math.cpp(определение) иmain.cpp(использование). Соберите проект командойg++ main.cpp math.cpp -o program. - Уберите файл
math.cppиз команды сборки. Какую ошибку выдаст линковщик? Объясните её смысл. - Добавьте определение функции прямо в
math.hи включите её из двух.cpp-файлов. Какую ошибку выдаст линковщик? - Добавьте
#pragma onceвmath.h. Проверьте, что теперь её можно включать несколько раз без ошибок. - Напишите
CMakeLists.txtдля проекта из трёх файлов. Соберите его командойcmake -S . -B build && cmake --build build. - Добавьте в CMakeLists.txt флаги
-Wall -Wextra -Wpedantic. Что изменится в выводе компилятора? - Соберите статическую библиотеку из
math.cpp:g++ -c math.cpp -o math.o && ar rcs libmath.a math.o. Затем слинкуйте с ней свою программу. - Соберите ту же библиотеку как динамическую (
-fPIC -shared). Сравните размеры итоговых файлов и способ запуска.