Занятие 1. Сборка C++: от кода до .exe
Курс начинается не с синтаксиса, а с ответа на вопрос, который обычно откладывают «на потом» — и зря: что происходит между текстом программы и её запуском? Это знание превращает загадочные ошибки сборки в понятные сообщения с конкретным виновником и делает осмысленным всё, что мы будем писать дальше.
Компиляция и интерпретация
Процессор исполняет только машинные команды. Все языки программирования отличаются, по большому счёту, тем, в какой момент и кто переводит текст программы в эти команды.
C++ — компилируемый язык: перевод происходит заранее, один раз, и на выходе получается нативный исполняемый файл. Мы платим временем сборки и получаем скорость: между программой и процессором нет посредников.
Python — интерпретируемый: исходник на лету транслируется в байткод, который исполняет виртуальная машина. Мгновенный старт разработки, но интерпретатор работает при каждом запуске программы.
Между этими полюсами — JIT-компиляция (Java, C#, PyPy, JavaScript/V8): байткод докомпилируется в машинный код прямо во время исполнения, на «горячих» участках.
Зачем это знать
Многие свойства C++, которые поначалу раздражают — раздельная компиляция, заголовочные файлы, ошибки линковки, — прямые следствия компилируемой модели. Понимая модель, вы понимаете и их.
Одна команда — четыре стадии
Минимальная программа:
Команда g++ выглядит как один шаг, но это драйвер, запускающий конвейер из четырёх стадий. Каждую можно остановить флагом и рассмотреть промежуточный результат.
| Стадия | Вход → выход | Остановиться здесь |
|---|---|---|
| Препроцессор | .cpp → «расшитый» текст .i |
g++ -E |
| Компилятор | .i → ассемблер .s |
g++ -S |
| Ассемблер | .s → объектный файл .o |
g++ -c |
| Линковщик | .o + библиотеки → исполняемый файл |
— |
Стадия 1. Препроцессор
Препроцессор — это текстовый редактор без понятия о C++. Он выполняет директивы, начинающиеся с #, до того, как компилятор увидит хоть одну строку:
#include— дословно вставляет содержимое файла в это место;#define— подстановка макросов, поиск-замена по токенам;#ifdef / #if / #endif— условно вырезает куски текста.
Масштаб текстовых подстановок легко недооценить:
Шесть строк превратились в 59 349 — столько тащит за собой один #include <iostream> (замер: Apple Clang 21). Отсюда практическое следствие: каждая единица трансляции заново компилирует все свои заголовки, и лишние #include — это минуты жизни на больших проектах.
#include: кавычки и скобки
Дополнительные каталоги поиска подключаются флагом -I include/. Формально точный порядок поиска — implementation-defined, но GCC, Clang и MSVC ведут себя одинаково: "file" — от каталога текущего файла, затем как <file>; <file> — по системным путям и -I.
Макросы: сила и проклятие
Макрос — текстовая подстановка, и в этом вся его опасность:
Современный C++ решает те же задачи средствами языка — с проверкой типов и однократным вычислением аргумента:
Легитимные ниши макросов сегодня: include guards, условная компиляция под платформы, __LINE__ / __FILE__ в логировании.
Условная компиляция
Макросы _WIN32, __APPLE__, __linux__ определяет сам компилятор — код «знает», где его собирают. Вырезанный текст не доходит даже до компилятора: нулевая цена в рантайме. Так устроены все кроссплатформенные библиотеки. (С C++17 часть таких задач решает if constexpr — ветвление на этапе компиляции внутри языка.)
Стадия 2. Компилятор
Компилятор — самая сложная часть конвейера, три больших блока:
- Фронтенд: лексер и парсер строят синтаксическое дерево (AST), проверяются типы — здесь рождаются сообщения об ошибках компиляции.
- Оптимизатор: inlining, свёртка констант, удаление мёртвого кода и десятки других преобразований.
- Бэкенд: выбор машинных инструкций и распределение регистров под целевую архитектуру (x86-64, ARM64…).
Уровни оптимизации: -O0 (без оптимизаций, для отладки), -O2 (стандарт для релиза), -O3/-Os (агрессивнее / компактнее).
Привычка с первого дня
собирайте с -Wall -Wextra -Werror. Предупреждение компилятора — это почти всегда найденный за вас баг. -Werror не даёт «потом посмотрю» превратиться в «никогда».
Пара «исходник + все его заголовки» называется единицей трансляции. Компилятор видит только её — что происходит в соседних .cpp, ему неизвестно. Это ключ к пониманию линковки.
Заглянем в ассемблер
Вся функция — две инструкции ARM64: перемножить регистр сам на себя и вернуться. Имя __Z6squarei — не опечатка, о нём ниже.
Инструмент недели
Compiler Explorer (godbolt.org) — пишете C++ слева, видите ассемблер любого компилятора справа. Лучший способ развить интуицию «во что превращается мой код». Сравните -O0 и -O2 на своей функции.
Стадия 3. Объектный файл
Ассемблер превращает .s в объектный файл — контейнер с секциями:
| Секция | Содержимое |
|---|---|
.text |
машинный код функций |
.rodata |
константы и строковые литералы |
.data |
глобальные переменные с инициализацией |
.bss |
глобальные, заполняемые нулями |
| таблица символов | «что я определяю, что мне нужно снаружи» |
Таблицу символов можно посмотреть утилитой nm:
Разрешить все U-символы — работа линковщика.
Name mangling
В C++ разрешена перегрузка — несколько функций с одним именем. Линковщик различает только строки, поэтому компилятор «вшивает» типы аргументов прямо в символ:
Расшифровать имя обратно умеет c++filt; extern "C" отключает манглинг (и перегрузку) — так C++ стыкуется с C-библиотеками и FFI других языков. Схемы манглинга у GCC/Clang и MSVC разные — ещё одна причина, почему объектники разных компиляторов несовместимы.
Стадия 4. Линковщик
Линковщик собирает все .o и библиотеки в исполняемый файл: сшивает секции, разрешает каждый undefined-символ ровно одним определением, расставляет адреса.
Статическая линковка (.a/.lib) копирует код библиотеки внутрь программы: один самодостаточный файл, нет проблем с версиями, но больше размер. Динамическая (.so/.dylib/.dll) оставляет в программе только ссылку — библиотека загружается при запуске: одна копия на все программы, обновляется отдельно, но появляется зависимость от окружения («dll hell»).
Читаем ошибки: кто именно ругается?
Умение отличить ошибку компиляции от ошибки линковки — половина успеха в диагностике:
| Симптом | Стадия | Типовая причина |
|---|---|---|
error: + файл:строка |
компилятор | опечатка, типы, забытая ; |
undefined symbol |
линковщик | забыли .cpp в команде сборки |
duplicate symbol |
линковщик | определение в заголовке |
Заголовочные файлы
Объявление и определение
Объявление сообщает сигнатуру — его можно повторять сколько угодно раз. Определение порождает код или память — оно должно быть ровно одно на программу:
Идея заголовка: в .h кладём объявления — «интерфейс», который раздаётся всем желающим через #include; определения живут в .cpp и компилируются один раз.
Канонический мини-проект
main.cpp ничего не знает о реализации gcd — только объявление. Обещание «найдётся при линковке» выполняет gcd.o.
Раздельная компиляция
Каждый .cpp компилируется независимо (хоть параллельно, make -j). Изменили один файл — пересобрали один .o и перелинковали: на проекте в миллион строк это разница между секундами и часами. Обратная сторона: правка заголовка перекомпилирует все включившие его .cpp — поэтому заголовки держат минимальными и стабильными.
ODR — правило одного определения
Заголовок вставляется в несколько .cpp, а значит, всё его содержимое «размножается». One Definition Rule: каждая функция и переменная определяется ровно один раз на программу.
Нельзя в заголовке:
Можно в заголовке:
Современный смысл
inline— не «встроить код», а «разрешаю несколько одинаковых определений; линковщик, оставь одно».
Защита от повторного включения
Проблема: ромб включений
main.cpp включает matrix.h и vector.h, оба включают point.h — препроцессор честно вставляет его дважды, и компилятор видит два определения struct Point в одной единице трансляции:
Include guards
Первое включение: POINT_H не определён → тело вставляется и определяет его. Второе включение в той же единице трансляции: всё до #endif вырезается. В другой единице трансляции препроцессор начинает «с чистого листа» — и это правильно. Имя стража должно быть уникальным во всём проекте (ПРОЕКТ_ПУТЬ_ФАЙЛ_H) — коллизия имён означает тихо пропавший заголовок и очень странные ошибки.
#pragma once
#pragma once |
include guards | |
|---|---|---|
| стандарт C++ | нет (но поддержан GCC/Clang/MSVC) | да |
| ошибки автора | исключены | коллизия/опечатка имени |
| краевые случаи | файл виден по двум путям (симлинки) | нет |
В курсе используем #pragma once — коротко и безопасно. Но guards обязаны уметь читать: ими написана вся стандартная библиотека.
Циклические зависимости и forward-декларации
Если user.h и group.h включают друг друга, guards не спасут — цикл надо разорвать. Часто полное определение и не нужно:
Достаточно class G; |
Нужно полное определение |
|---|---|
указатель G*, ссылка G& |
член по значению G g; |
| параметр/возврат в объявлении | вызов методов, sizeof(G) |
std::vector<G*> |
наследование : public G |
Правило стиля: в заголовках — минимум включений и forward-декларации; полные #include — в .cpp. Бонус — быстрее компиляция.
Окружение: три ОС
| Linux | macOS | Windows | |
|---|---|---|---|
| установка | sudo apt install g++ gdb make |
xcode-select --install |
Visual Studio или WSL2 |
| компилятор | GCC (Clang — рядом) | Apple Clang (g++ — псевдоним clang!) |
MSVC / GCC в WSL |
| примечание | эталон для автотестов курса | настоящий GCC — brew install gcc |
рекомендуем WSL2: та же среда, что у тестов |
Редактор — на вкус: VS Code (+ clangd), CLion (бесплатно по студенческой лицензии JetBrains), Visual Studio. Важно одно: уметь собрать проект руками из терминала — IDE делает то же самое, но за шторкой.
Боевой набор флагов для всего кода курса:
-std=c++20— стандарт языка курса;-g— отладочная информация для gdb/lldb;-fsanitize=address— ловит выход за границы массива, use-after-free;-fsanitize=undefined— ловит UB: переполнение знаковых, null-разыменование.
Когда файлов станет много, сборку возьмёт на себя CMake (минимальный CMakeLists.txt — пять строк); познакомимся с ним при первом многофайловом проекте.
Самопроверка
- Почему ошибка «undefined symbol» принципиально не может быть обнаружена компилятором?
- Что произойдёт, если два
.cppвключат заголовок со строкойint x = 0;? А со строкойextern int x;? - Почему изменение
.hпересобирает пол-проекта, а изменение.cpp— один файл? - Зачем в C++ манглинг имён, а в C его нет?
Практика
- Настроить окружение под свою ОС и собрать hello с полным набором флагов.
- Разнести программу на
.h+ два.cpp, собрать вручную через-cи линковку. - Сломать сборку тремя способами — ошибка компиляции, undefined symbol, duplicate symbol — и прочитать сообщения.
- Посмотреть свою функцию на godbolt.org при
-O0и-O2.