Что такое компилятор и как он работает
Знакомимся с программой, которая умеет создавать другие программы для того, чтобы создавать ещё больше программ.
Иллюстрация: Оля Ежак для Skillbox Media
Компилятор — это программа, которая переводит исходный код на языке программирования в машинный код. Если этого не сделать, компьютер не поймёт, как выполнить инструкции разработчика. Поэтому мы отдаём компилятору строки кода, а он сравнивает их со своим словарём, учитывает контекст и выдаёт набор из нулей и единиц.
В этой статье разберёмся:
Для чего нужен компилятор
Выдвинем дерзкое утверждение: компьютеры очень глупы, они не понимают человеческого языка — и, в частности, языков программирования. Всё, что они умеют, — это принимать электрические сигналы и как-то на них реагировать.
Если упрощать, то компьютер — это коробка с миллиардами переключателей. Дёрнули одни — сложили два числа, дёрнули другие — записали данные на жёсткий диск. И хотя современные компьютеры с аппаратной точки зрения устроены сложнее, принцип остаётся похожим.
Когда мы пишем код, то используем понятные для людей слова, такие как print, string, import, Процедура и Исключение. Нам их значение кажется очевидным: здесь вывели результат на печать, а там объявили строковую переменную. Но для компьютера эти слова ничего не значат.
Компьютер видит слово print и воспринимает его ровно так же, как вы воспринимаете слова из любого неизвестного вам языка. Ничего не понятно, но какой-то смысл у них точно есть. Поэтому компьютеру, как и нам, нужен переводчик — или компилятор.
Компилятор понимает, что значит слово print — и даже умеет сказать компьютеру, как его правильно обработать. Таким образом, он решает три задачи:
- разбирает синтаксис написанного;
- анализирует его;
- генерирует машинный код.
На вход компилятор принимает исходный код, а отдаёт исполняемый файл — программу, которая готова к работе.
Звучит просто. Но к компиляторам есть много вопросов — например, на каких языках их пишут, как они устроены внутри и каких видов бывают. Обо всём этом расскажем в статье. И начнём с того, как работают компиляторы.
Как работают компиляторы
Итак, ещё раз: чтобы компьютеры выполняли команды программистов, им нужны переводчики с человеческого на машинный. Рассмотрим процесс перевода — сначала в общих чертах, а потом подробно.
🏃♂️ Коротко
Компилятор получает на вход файл с кодом на каком-то языке программирования. Он преобразовывает конструкции языка в формат, понятный компьютеру, и возвращает файл, который тот сможет выполнить.
Чтобы преобразовать исходный код, компилятор использует собственный словарь с определениями — например, оператор if меняет на 11010011100110, а сложение — на 101011. Он делает это, пока не закончатся все строки в файле. Получается исполнительный файл, который выглядит так:
001011011010010101110101010101010100001100001110111100110100001010001001110…
В таком формате компьютеру уже удобно читать инструкции и выполнять их. А значит, компилятор сделал свою работу хорошо.
🤔 Подробнее. Компилирование состоит из пяти этапов: синтаксического анализа, парсинга, семантического анализа, оптимизации и генерации кода. Давайте разберём каждую стадию.
1️⃣ Синтаксический анализ. Это что-то вроде разбора грамматики языка. Когда мы пишем код, то следуем определённым правилам — синтаксису. Например, в Java между командами ставим точку с запятой. Если этого не сделать, то получим ошибку.
На этапе синтаксического анализа компилятор проверяет, соответствует ли код правилам конкретного языка программирования. И пока он не думает о том, что именно написано, — проверка идёт только по формальным признакам.
2️⃣ Парсинг. На этом этапе компилятор разбивает код на маленькие кусочки — токены. Каждый токен — это какое-то слово или символ, например if, while, int или (.
Из токенов строится синтаксическое дерево, которое содержит слова и символы, и пригодится на следующем этапе — семантическом анализе. Каждый узел дерева — это либо операция, например сложение, либо переменная. Обычно, когда мы доходим до переменной, то дальше ветви не разрастаются.
Давайте посмотрим, как выглядит такое дерево.
Допустим, у нас есть простой код со сложением двух чисел:
x = 5 + 3
Здесь пять токенов: x, =, 5, + и 3. Пробелы считать не будем. Из этих токенов строится такое дерево:
Мы видим, что на вершине находится главная операция — присваивание переменной x результата сложения двух чисел. От неё отходит две ветви — сама переменная x и символ сложения, который ветвится на слагаемые числа.
В процессе парсинга компилятор не понимает, зачем нужен каждый из токенов. Пока что он машинально выполняет свою работу — думать будет на следующем этапе.
3️⃣ Семантический анализ. Компилятор начинает вдумываться в то, что написано в коде, анализируя составленное синтаксическое дерево. Например, если мы объявили переменную, он понимает, что это значит и какие операции можно с ней выполнить.
Ещё компилятор на этом этапе может предполагать, какие именно действия с переменной возможны. Если он видит, что у нас есть переменная неизменяемого типа, например константа, то при попытке кода её изменить, выдаст ошибку.
4️⃣ Оптимизация. Когда синтаксис разобран и стало понятно, что делает программа, время ускорить работу кода. Компилятор ищет способы повысить скорость его выполнения или уменьшить количество занимаемой им памяти.
Самый простой пример оптимизации — умножение на ноль. Например, у нас есть фрагмент кода:
Чтобы определить значение переменной y, потребуется сначала вычислить сложную формулу для переменной x. Но мы, люди, сразу видим, что при умножении на ноль, результат будет нулём, а значит, смысла считать переменную x нет. Компилятор тоже видит такие вещи — и не будет вычислять то, что вычислять бесполезно. Он просто заменит эти две строки кода на одну:
Удобно, правда? Но это сработает только в том случае, если переменная x не пригодится нам в программе дальше.
Это возможно из-за особенностей работы компилятора — он не выполняет код, а сначала читает его и ищет способы оптимизации программы.
5️⃣ Генерация кода. Синтаксис разобран, анализ проведён, код оптимизирован — пора перевести его на язык компьютера. На этом этапе все команды, что мы писали на языке программирования, переводятся в машинные инструкции.
После перевода мы получаем исполняемый файл, например в формате .exe, который можно запустить и проверить работу программы. На этом компиляция завершается.
На каких языках пишут компиляторы
👉 Подождите, раз компилятор переводит исходный код в машинный, а сам он является программой, то на каком языке тогда он написан? Какой-то замкнутый круг получается.
На самом деле всё не так сложно. Компиляторы можно писать на любом языке — хоть на Python, хоть на языке ассемблера. Но есть нюанс.
Самый первый компилятор написан на языке ассемблера, потому что программистам нужно было как-то упростить себе работу с машинным кодом. Работают они так:
- разработчик пишет код на ассемблере;
- компилятор переводит его в машинные инструкции;
- компьютер запускает эти инструкции.
Получается, что компилятор на ассемблере — это другая программа на нём же, которая умеет переводить код. Например, она подставляет вместо команды jmp строку 001110111, которая запускает нужные шестерёнки внутри процессора.
Читайте также:
После уже появились языки более высокого уровня — например, C. Компилятор для C написан на том же ассемблере. Работает он похожим образом:
- разработчик пишет программу на C;
- компилятор переводит команды на языке программирования с C в машинные инструкции;
- компьютер запускает эти инструкции.
Дальше — вверх по высокоуровневости языков программирования. Компилятор на С++ написан на C, а для JavaScript — на C++. Но если спускаться по цепочке, то мы рано или поздно придём к ассемблеру.
Почему не всегда в одном языке один компилятор
👉 Стойте, а зачем тогда языкам программирования несколько компиляторов? Почему бы всем не использовать только один?
Для каждого языка программирования первый компилятор обычно пишут его разработчики. Например, возьмём язык C.
Его компилятор написан на ассемблере, а сделал это Деннис Ритчи. Он исходил из принципов, что одни команды языка должны конвертироваться в одни инструкции для ассемблера, а другие — в другие. Но, возможно, это была не лучшая реализация: в каких-то местах компилятор мог работать медленно, а в каких-то и вовсе не справлялся. Поэтому сторонние разработчики решили написать свои версии «переводчика» кода на C.
Например, кто-то мог взглянуть на код компилятора C и подумать: «Да тут же нет сборщика мусора, это что такое-то?!» — и пойти написать свою версию, которая будет залатывать все утечки памяти и чистить неиспользуемые переменные.
Другой разработчик может взглянуть и подумать: да тут же нет нормальной оптимизации под мои задачи из машинного обучения. А затем пойти и написать компилятор, который будет конвертировать код на C в TensorFlow-структуры.
Каждая реализация компилятора нужна для своих целей: кому-то важно собирать мусор, а кому-то иметь супербыстрый код, который обгонит любой другой. Это значит, что они будут различаться архитектурой, используемым языком программирования, скоростью работы и назначением. Но глобально — будут делать одну и ту же вещь: компилировать.
Какими бывают компиляторы
К сожалению, ещё нет универсального компилятора, который бы переводил код любого языка программирования в машинный код для всех устройств. У нас есть разные операционные системы, их версии, разная архитектура процессоров и так далее.
В зависимости от задач компиляторы можно разделить на несколько групп. Например, по направлению перевода кода.
Традиционные компиляторы
Умеют переводить код на языке программирования в машинный. Именно о них мы преимущественно и говорили в этой статье. Пример — компилятор g++ для языка C++.
Кросс-компиляторы
Эти компиляторы работают на одной платформе и создают код для другой. Их часто используют разработчики для встроенных систем, мощности которых недостаточно для самостоятельного компилирования. Например, в микроконтроллерах.
К кросс-компиляторам относят GCC (GNU Compiler Collection). Он поддерживает C++, Objective-C, Java, Fortran и Go и разную архитектуру процессоров.
Транспайлеры
Преобразуют исходный код языка высокого уровня в исходный код другого языка высокого уровня. Например, транспайлер Babel преобразует ECMAScript 2015+ в JavaScript.
Обратные компиляторы
Эти компиляторы делают обратное действие — анализируют уже скомпилированный код и пытаются превратить его в исходный код на высокоуровневом языке. Это может быть полезно для анализа или отладки.
Читайте также:
Компилятор, интерпретатор, транслятор: в чём разница
Компиляторы — это не единственный способ перевести исходный код в машинный. Ещё есть интерпретаторы и JIT-компиляторы. Давайте коротко расскажем, в чём различия между ними.
Интерпретатор. Это как синхронный переводчик. Он читает исходный код и сразу же выполняет его построчно. Интерпретатор не создаёт дополнительных файлов и не строит синтаксические деревья, а выполняет инструкции на лету, переводя их в байт-код. Например, так работает CPython для языка Python.
JIT-компилятор. Это гибрид компилятора и интерпретатора. Он начинает работать как интерпретатор и выполняет команды по ходу чтения кода. Но часть команд переводит в машинный код, чтобы использовать их в тех случаях, если они будут повторяться в будущем. Это ускоряет работу программы, так как позволяет не выполнять одно и то же действие повторно.
Отдельно стоит упомянуть байт-код. Это специальный код, который запускается на виртуальной машине. Можно сказать, что он занимает промежуточное положение между кодом, написанным на языке программирования, и машинным кодом. Его реализацию можно найти в Java или Python.
Преимущества и недостатки компилируемых языков
Давайте посмотрим на список аргументов за и против для компилируемых языков — то есть тех, которые используют компиляторы. Примеры таких языков: C++, Haskell, Fortran, Rust, Swift и Go.
Плюсы
✅ Быстрота выполнения. Компилятор переводит исходный код в машинный всего один раз. А дальше — всё уже оптимизировано и готово к запуску. Поэтому такие программы работают быстрее, так как компьютеру не приходится тратить время на их повторный перевод.
✅ Эффективное использование ресурсов. Один из этапов компилирования — это оптимизация кода. А так как компиляторы пишут либо создатели языка, либо опытные разработчики, то производительность таких программ будет высокой.
✅ Скрытие исходного кода. Это неочевидный плюс, но это правда преимущество. После того как программа скомпилирована, её исходный код понять трудно. Это помогает избежать взломов и обезопасить данные.
Минусы
⛔️ Долгая компиляция. Процесс компиляции может занимать очень много времени. Для небольших проектов это не так страшно, но когда количество строк кода у проекта переваливает за миллион, то лишний раз запускать компиляцию не хочется.
⛔️ Сложность исправления ошибок. Обычно ошибки при компилировании выглядят устрашающе из-за запутанного описания проблемы. Просто попробуйте не поставить точку с запятой в файле с C++ и убедитесь, что ничего хуже вы не видели.
⛔️ Зависимость от платформы. Если скомпилировать программу для Windows, то её никак нельзя будет запустить на macOS. Поэтому придётся дополнительно брать другой компилятор и начинать процесс заново — или использовать кросс-компиляторы.
Что почитать
В этой статье мы затронули только базовые принципы работы компиляторов. Если вы хотите лучше разобраться в том, как они работают, или даже написать свою версию переводчика на машинный язык, вот несколько ресурсов, где можно изучить тему глубже:
- Компиляторы: принципы, технологии и инструментарий Альфреда Ахо и Моники Лам.
- «Конструирование компиляторов», Сергея Свердлова.
- Бесплатный курс «Языки программирования и компиляторы» от Computer Science Center.
Больше интересного про код — в нашем телеграм-канале. Подписывайтесь!