Лексический анализ превращает текст в токены
Первым делом компилятор не видит в коде ни переменных, ни функций — он видит поток символов. Лексический анализатор, или лексер, проходит по этому потоку и разбивает его на токены: ключевые слова, имена переменных, числа, операторы, скобки, знаки препинания. Пробелы и переносы строк обычно отбрасываются сразу, а комментарии вырезаются целиком, для дальнейших этапов они не несут информации. На выходе лексера получается уже не текст, а плоская последовательность помеченных кусочков смысла.
Синтаксический анализ складывает токены в дерево
Дальше парсер берёт последовательность токенов и проверяет, складываются ли они в конструкции, разрешённые грамматикой языка: правильно ли расставлены скобки, стоит ли точка с запятой там, где положено, идёт ли за именем функции список аргументов в круглых скобках. Результат работы парсера — абстрактное синтаксическое дерево, где вложенность узлов отражает вложенность конструкций кода: внутри узла условия лежит его условие и его тело, внутри вызова функции — её аргументы. Именно на этом шаге компилятор ловит синтаксические ошибки вроде незакрытой скобки или пропущенной точки с запятой.
Семантический анализ проверяет смысл
Синтаксически правильный код ещё не обязательно правильный по смыслу. Семантический анализатор проверяет, что каждая переменная объявлена до использования, что типы данных в операции совместимы друг с другом, что вызов функции получает столько аргументов, сколько она ожидает, и того типа, который она умеет принимать. Именно здесь всплывает ошибка вроде сложения числа со строкой в языке со строгой типизацией — предложение построено грамматически верно, но по смыслу не имеет права на существование.
Промежуточное представление
После проверки смысла дерево переводят в промежуточное представление — упрощённую форму программы, независимую и от исходного языка, и от конкретного процессора. Такое разделение экономит труд разработчиков компилятора: один и тот же оптимизатор и один и тот же набор инструментов генерации кода можно использовать для нескольких разных языков программирования, если каждый из них умеет переводить себя в общее промежуточное представление. Именно так устроен проект LLVM: и Clang для C и C++, и компиляторы Rust и Swift опираются на общую инфраструктуру промежуточного представления и заднюю часть компилятора, хотя синтаксис у этих языков совершенно разный.
Оптимизация даёт тот же результат быстрее
На уровне промежуточного представления компилятор ищет способы ускорить будущую программу, не меняя её поведения. Мёртвый код, до которого управление никогда не доходит, вырезают целиком. Выражения с заранее известными числовыми значениями вычисляют один раз на этапе компиляции, а не при каждом запуске программы. Маленькие функции подставляют прямо в место вызова, чтобы избежать накладных расходов на сам вызов. Каждая такая оптимизация по отдельности экономит немного, а вместе они способны ускорить программу в разы.
Генерация кода и компоновка
Заключительный этап компилятора превращает оптимизированное промежуточное представление в настоящие машинные инструкции конкретного процессора — набор для x86 будет отличаться от набора для ARM, хотя исходный код и промежуточное представление до этого шага оставались общими. Отдельно скомпилированные файлы программы и код внешних библиотек, например функция вывода текста на экран из стандартной библиотеки языка, ещё не связаны между собой. Компоновщик соединяет всё это в единый исполняемый файл, подставляя реальные адреса вместо ссылок на внешние функции. Без этого шага программа просто не запустится — в ней останутся вызовы в никуда.
Что происходит при нажатии «Запустить»
Для языков вроде C, C++, Rust или Go кнопка «Запустить» в среде разработки обычно прогоняет исходники через все описанные выше этапы, получает готовый исполняемый файл и сразу же передаёт его операционной системе для запуска — весь путь от текста до работающей программы занимает доли секунды на маленьком проекте. У Python дело устроено иначе: интерпретатор CPython переводит код в упрощённый набор инструкций, байт-код, но не доводит его до машинного кода конкретного процессора — вместо этого виртуальная машина Python выполняет байт-код шаг за шагом при каждом запуске. Java занимает промежуточное положение: компилятор javac переводит исходный код в байт-код виртуальной машины Java, а сама виртуальная машина сначала интерпретирует его, а для часто выполняемых участков включает JIT-компиляцию — компиляцию в машинный код уже во время работы программы, на лету.
Семь шагов от текста до работающей программы
- лексический анализ, который превращает текст в токены
- синтаксический анализ, который строит дерево разбора
- семантический анализ, который проверяет типы и имена
- перевод дерева в промежуточное представление
- оптимизация промежуточного представления
- генерация машинного кода под конкретный процессор
- компоновка отдельных файлов и библиотек в исполняемый файл
Частые вопросы
Чем компиляция отличается от интерпретации
Компилятор заранее переводит всю программу целиком в другую форму, обычно в машинный код, и результат потом можно запускать независимо, без исходного текста рядом. Интерпретатор читает и выполняет код или байт-код пошагово при каждом запуске, ничего не переводя заранее в самостоятельный исполняемый файл.
Почему один и тот же язык иногда называют и компилируемым, и интерпретируемым
Потому что граница между этими подходами на практике размытее учебника. Python и Java сначала компилируют исходный код в байт-код, а потом этот байт-код уже исполняет виртуальная машина, интерпретируя его или, как в случае Java, местами компилируя на лету через JIT. Чистое разделение на два лагеря скорее упрощение, чем точное описание современных языков.
Что такое компоновка и почему без неё программа не запустится
Компоновка — это сборка отдельно скомпилированных частей программы и кода нужных библиотек в один исполняемый файл, с заменой ссылок на внешние функции реальными адресами. Без этого шага в получившемся файле остались бы вызовы функций, для которых процессор не знает, куда именно передать управление.
Может ли компилятор молча выдать неправильную программу без единой ошибки
Сам компилятор ошибается редко — это зрелый, десятилетиями обкатанный инструмент, и такие случаи считаются серьёзными багами компилятора. Гораздо чаще программа ведёт себя не так, как ожидал автор, просто потому, что компилятор проверяет синтаксис и типы, а не замысел — логическую ошибку в правильно написанном по всем правилам коде он пропустит без единого предупреждения.