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 меняют картину до неузнаваемости.
Четыре этапа
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_.