Course program← Программирование на языке C++
1Введение в язык C++
2Как выполняются программы на C++
3Структуры и классы
4Объектно-ориентированное программирование
5Дополнительные возможности C++
6Шаблоны
7Методические материалы

Как из текста получается программа

This lesson is not translated yet – the Russian original is below.

В интерпретируемых языках между исходником и результатом ничего нет. В C++ между ними четыре шага, и каждый умеет падать по-своему. Разобравшись, кто за что отвечает, вы будете читать ошибки сборки, а не гадать.

Зачем вообще компилятор

Процессор исполняет числа – коды инструкций. Писать их руками теоретически можно, практически нет: получится текст, привязанный к одной модели процессора, нечитаемый и неисправимый.

Компилятор – переводчик с языка, удобного человеку, на язык, удобный машине. Попутно он делает две вещи, ради которых его терпят:

Проверяет. Опечатка в имени, число вместо строки, забытый return – всё это ловится до запуска. Чем строже язык, тем больше ошибок остаётся на этом этапе и тем меньше доходит до пользователя.

Оптимизирует. Написанное вами и полученный машинный код совпадают далеко не построчно. Компилятор разворачивает циклы, встраивает мелкие функции, выкидывает вычисления, результат которых никому не нужен, и держит горячие переменные в регистрах.

int Square(int x) { return x * x; }

int main() { return Square(7); }

С включённой оптимизацией от этой программы останется return 49; – вызова функции в машинном коде не будет вовсе. Вычислить 7 * 7 компилятор может и сам.

Отсюда правило замеров: сравнивать скорость на сборке без оптимизации бессмысленно. Флаги -O2 у GCC и Clang, /O2 у MSVC меняют картину до неузнаваемости.

Четыре этапа

main.cpp main.i main.o program ваш текст после подстановки машинный код готовый файл препроцессор компилятор компоновщик #include, #define разбор, проверка, оптимизация + другие .o и библиотеки

1. Препроцессор

Работает с текстом и про C++ не знает ничего. Подставляет #include, разворачивает #define, вырезает ветки #ifdef, выбрасывает комментарии. На выходе – один большой файл: единица трансляции.

Посмотреть на результат можно и стоит хотя бы раз:

g++ -E main.cpp -o main.i
wc -l main.i          # десятки тысяч строк из-за одного <iostream>

Отсюда время сборки. Каждый .cpp, подключивший <vector> и <string>, заставляет компилятор заново разбирать одни и те же тысячи строк шаблонов. С этим борются: предкомпилированные заголовки, а в C++20 – модули.

2. Компилятор

Разбирает единицу трансляции, проверяет типы, применяет оптимизации и выдаёт машинный код. Здесь возникают ошибки, которые вы видите чаще всего: несовпадение типов, неизвестное имя, пропущенная точка с запятой.

Ключевое: компилятор видит ровно одну единицу трансляции. Про то, что происходит в соседнем .cpp, он не знает ничего – только объявления, которые вы ему подставили.

3. Объектный файл

Результат компиляции одного .cpp – файл .o (.obj на Windows). Внутри машинный код и две таблицы имён:

  • что этот файл даёт – функции и переменные, определённые здесь;
  • чего ему не хватает – то, что он вызывает, но не определяет.

Вызов Distance в main.o – это пока дырка с подписью «сюда нужен адрес функции с такой сигнатурой».

4. Компоновщик

Берёт все объектные файлы и библиотеки, сводит таблицы вместе и подставляет адреса вместо дырок. Не нашёл имени – undefined reference. Нашёл два одинаковых – multiple definition.

g++ -c main.cpp -o main.o     # только компиляция
g++ -c point.cpp -o point.o
g++ main.o point.o -o program # компоновка

Раздельная компиляция

Каждый .cpp компилируется независимо – в этом весь смысл разбиения. Правка внутри point.cpp требует пересобрать point.o и заново скомпоновать; трогать main.o незачем.

g++ main.cpp point.cpp -o program   # то же самое одной командой

Одной командой удобно, пока файлов три. Дальше подключают систему сборки – CMake, Make – которая сама следит за датами файлов и пересобирает только устаревшее.

Правка заголовка – другое дело: пересобрать нужно все единицы трансляции, которые его подставляют. Поэтому в заголовках стараются держать поменьше и не тащить туда лишние #include.

ODR: одно определение

Правило одного определения (One Definition Rule) – формулировка того, обо что спотыкается любой новичок в C++.

Объявлять сущность можно сколько угодно раз. Определить – ровно один раз на всю программу.

// point.h – так делать нельзя
double Distance(Point a, Point b) { ... }   // определение в заголовке

Заголовок подставится в main.cpp и в point.cpp, определение попадёт в оба объектных файла, и компоновщик выдаст multiple definition. Правильно – объявление в заголовке, определение в одном .cpp.

Из правила есть исключения, и они важны: определения классов, шаблоны и функции, помеченные inline, в заголовках жить могут. Про inline – в пятом модуле.

Почему ошибки такие длинные

Два самых неприятных сообщения C++ объясняются устройством конвейера.

Ошибка в шаблоне на пятьсот строк. Шаблон – это заготовка; компилятор проверяет её только когда подставит конкретный тип. Ошибка вылезает в глубине стандартной библиотеки, а в сообщении разворачивается весь путь подстановки. Читать такое надо с конца: первые строки – про ваш код, дальше – машинерия.

Ошибка компоновки без номера строки. Компоновщик работает с машинным кодом; исходников он не видел и сказать, где именно, не может. Зато он называет имя – по нему и ищут.

Имя в сообщении компоновщика обычно искажено (_Z8Distance5PointS_) – так кодируются типы аргументов, чтобы перегруженные функции не совпали. Развернуть обратно: c++filt _Z8Distance5PointS_.