Базовый CS с нуля
Компиляция против интерпретации
Теперь ты знаешь, что высокоуровневые программы не могут выполняться на CPU напрямую — сначала они должны быть транслированы в машинный код. Но как и когда происходит эта трансляция?
Есть два принципиально разных ответа. При первом подходе компилятор читает весь исходный файл до того, как программа когда-либо запустится, транслирует каждую строку в машинный код и сохраняет результат в двоичный файл. Затем ты запускаешь этот двоичный файл, и CPU исполняет машинный код напрямую — никакой трансляции во время выполнения не происходит. Именно так работают C, C++, Rust и Go.
При втором подходе интерпретатор читает исходный файл во время выполнения, оператор за оператором. Для каждого оператора он выясняет, что тот означает, и выполняет его, затем переходит к следующему. Никакого отдельного двоичного файла никогда не создаётся. Так работали ранние Python и Ruby.
Выбор между этими двумя стратегиями связан с реальными компромиссами: время запуска, сырая скорость, удобство отладки и переносимость. Понимание обеих — и гибридного подхода JIT-компиляции (just-in-time), используемого современными движками JavaScript, виртуальной машиной Java и PyPy для Python — необходимо для понимания того, как реально работают написанные тобой программы.
После этого урока ты сможешь определить понятия компилятора и интерпретатора, объяснить различие между трансляцией заранее и во время выполнения, описать компромиссы между двумя стратегиями и кратко объяснить, что делает JIT-компилятор.
Компилятор: сначала транслируй всё, затем запускай. Компилятор — это программа, которая принимает исходный файл на вход и создаёт файл с машинным кодом на выходе. Это происходит полностью до запуска программы — этот процесс называется компиляцией заранее (ahead-of-time, AOT). Скомпилированный двоичный файл содержит сырые инструкции CPU для конкретной платформы; для его запуска ни интерпретатор, ни компилятор не нужны.
Задача компилятора сложнее, чем однозначная трансляция ассемблера. Компилятор должен:
- Разобрать исходный код — преобразовать текст в структурированное представление смысла программы (абстрактное синтаксическое дерево).
- Проанализировать код — проверить типы, разрешить имена, обнаружить ошибки.
- Оптимизировать — переставить и улучшить код для более быстрого выполнения, не изменяя его смысл (переупорядочить инструкции, устранить мёртвый код, развернуть циклы и т. д.).
- Сгенерировать машинный код — выдать инструкции CPU для целевой платформы.
Конечный результат — двоичный файл. Когда ты запускаешь ./myprogram, операционная
система загружает этот двоичный файл прямо в память и устанавливает счётчик команд на
первую инструкцию. CPU немедленно запускает его без накладных расходов на трансляцию.
Интерпретатор: транслируй и выполняй, один оператор за раз. Интерпретатор — это программа, которая читает исходный код во время выполнения и выполняет каждый оператор по мере его встречи. Никакого отдельного файла с машинным кодом не создаётся. Исходный файл — это программа, которую доставляет пользователь; интерпретатор — движок, который её запускает.
Для каждого оператора интерпретатор примерно делает:
- Читает и разбирает следующий оператор из исходного файла.
- Определяет, что этот оператор означает (какую операцию он описывает).
- Выполняет соответствующее действие напрямую (запустив собственный машинный код интерпретатора для выполнения операции).
- Переходит к следующему оператору.
Этот цикл повторяется до конца программы или до ошибки, которая её останавливает. Интерпретатор никогда не записывает двоичный файл с машинным кодом. Вместо этого сам интерпретатор — это скомпилированный двоичный файл (написанный на C или другом языке), который выполняет твою программу, запуская собственный машинный код в ответ на то, что говорят твои операторы.
Компромиссы: скорость, запуск и переносимость.
Скорость. Скомпилированные программы обычно выполняются быстрее после запуска. Компилятор имел время проанализировать всю программу, применить глобальные оптимизации и создать машинный код, адаптированный к целевому CPU. Интерпретатор должен делать работу по трансляции для каждого оператора во время выполнения, добавляя накладные расходы к каждой операции.
Запуск и переносимость. Скомпилированный двоичный файл специфичен для одной платформы (машинный код для x86-64 не работает на ARM). Для каждой цели нужно компилировать отдельно. Интерпретируемый язык несёт только исходный файл — один и тот же источник работает на любой платформе, где установлен интерпретатор, без перекомпиляции. Именно поэтому скрипты Python или файлы JavaScript можно передавать как обычный текст и запускать на любой машине, где установлен соответствующий язык.
Обнаружение ошибок. Компилятор видит всю программу до запуска какой-либо её части, поэтому может обнаружить ошибки, охватывающие несколько строк — несоответствия типов, вызовы неопределённых функций, переменные, используемые до присвоения. Интерпретатор встречает ошибки только тогда, когда достигает проблемной строки во время выполнения, что может затруднить раннее обнаружение некоторых ошибок.
Скорость разработки. Интерпретируемые языки обычно имеют более плотный цикл редактирования-запуска-отладки. Ты меняешь строку и немедленно перезапускаешь интерпретатор — никакого шага компиляции. Компилируемые языки требуют повторного запуска компилятора, что для больших кодовых баз может занимать от секунд до минут.
Почему это работает
Почему сам интерпретатор не нужно интерпретировать? Интерпретатор — программа как
любая другая. Он написан на языке высокого уровня (обычно C), скомпилирован в машинный
код для хост-платформы и распространяется как скомпилированный двоичный файл. Когда ты
запускаешь python3 script.py, твоя ОС загружает двоичный файл интерпретатора Python в
память и выполняет его. Машинный код двоичного файла интерпретатора затем читает
script.py и выполняет его операторы. Ни в какой момент CPU не исполняет твой исходный
текст Python напрямую — CPU всегда выполняет только машинный код, который в данном случае
является собственным машинным кодом интерпретатора. Твой код Python управляет тем, что
делает интерпретатор, но не тем, как CPU работает.
JIT-компиляция (just-in-time): гибрид. Современные среды выполнения — движки JavaScript (V8, SpiderMonkey), виртуальная машина Java (JVM) и PyPy для Python — используют третью стратегию под названием JIT-компиляция (just-in-time). JIT начинает с интерпретации (или выполнения байт-кода через быструю виртуальную машину), а затем — во время выполнения, пока программа уже работает — определяет, какие части кода выполняются наиболее часто (называемые горячими точками), и компилирует только эти части в нативный машинный код на лету.
Преимущество: ты получаешь быстрый запуск и переносимость интерпретатора (никакого шага компиляции заранее) плюс скорость выполнения скомпилированного кода для наиболее важных горячих путей. JIT-компилятор также может использовать информацию, которую он знает только во время выполнения, — например, реальные типы переменных — для получения более хорошего машинного кода, чем мог бы произвести AOT-компилятор.
Недостаток: есть период прогрева — первый раз, когда функция выполняется, она интерпретируется (медленно). После достаточного количества вызовов JIT её компилирует. Пиковая производительность достигается только после прогрева. Именно поэтому приложения Node.js могут некоторое время набирать полную скорость, а результаты тестирования JavaScript улучшаются по мере более длительного выполнения кода.
Граничные случаи
Байт-код как промежуточный шаг. Многие языки (Python, Java, Kotlin) не интерпретируют исходный текст напрямую и не компилируют сразу до нативного машинного кода. Вместо этого они компилируют в байт-код: компактный переносимый бинарный формат, разработанный для конкретной виртуальной машины (виртуальная машина Python, JVM). Байт-код меньше и быстрее выполняется, чем обычный исходный текст, но всё ещё не зависит от железа. Виртуальная машина затем либо интерпретирует байт-код, либо применяет к нему JIT-компиляцию. Этот двухэтапный подход разделяет переносимость (байт-код можно доставить куда угодно, где существует виртуальная машина) и производительность (виртуальная машина или JIT обрабатывает окончательную трансляцию в нативный код).
Сравнение того, что происходит при запуске одной и той же программы в скомпилированном и интерпретируемом режимах.
Программа для вычисления: result = (a + b) * c, где a=3, b=4, c=5. Ожидаемый результат: 35.
Скомпилированный путь (C):
-
До запуска: Компилятор C читает исходный файл, разбирает выражение, оптимизирует его и генерирует машинный код. Для такого простого выражения компилятор может свести всё вычисление к константе (3+4)*5 = 35, хранящейся непосредственно в двоичном файле. Двоичный файл сохраняется на диск.
-
Во время выполнения: ОС загружает двоичный файл. CPU выбирает инструкцию, загружающую константу 35 в регистр. Готово. Трансляция произошла полностью на шаге 1; шаг 2 имеет нулевые накладные расходы на трансляцию.
Интерпретируемый путь (Python):
-
Во время выполнения: Запускается интерпретатор Python. Он читает строку
result = (a + b) * c. Находитa(3),b(4), складывает → 7. Находитc(5), умножает → 35. Сохраняет 35 в переменнуюresult. Каждый шаг был поиском, декодом и выполнением, произведёнными собственным машинным кодом интерпретатора. Интерпретатор не создал никакого машинного кода для твоей программы. -
Интерпретатор переходит к следующей строке. Если программа длинная, эти накладные расходы накапливаются. В CPython (стандартная реализация Python) интерпретируемый Python обычно в 10–100 раз медленнее скомпилированного C для вычислений, нагружающих CPU.
JIT-путь (JavaScript в V8):
- Первый вызов: V8 интерпретирует JavaScript быстро (с минимальной трансляцией).
- После многих вызовов: JIT V8 обнаруживает, что функция горячая. Он компилирует функцию в машинный код x86-64 на лету. Последующие вызовы выполняют нативный машинный код на почти-C-скорости.
- Стоимость прогрева была оплачена один раз; долгосрочная выгода — производительность близкая к скомпилированной.
Компилятор транслирует исходную программу в машинный код. Когда происходит эта трансляция — до запуска программы или во время её работы? Введи 1 (до) или 2 (во время).
Интерпретатор транслирует и выполняет исходный код. Когда он транслирует каждый оператор — до запуска программы или во время выполнения? Введи 1 (до) или 2 (во время выполнения).
Какая стратегия обычно даёт более быстрое выполнение при длительных CPU-интенсивных программах: компиляция или интерпретация? Введи 1 (компиляция) или 2 (интерпретация).
Скомпилированная программа на C создаёт двоичный файл для x86-64. Может ли этот двоичный файл работать напрямую на ARM CPU без перекомпиляции? Введи 1 (да) или 0 (нет).
JIT-компилятор обнаруживает части программы, которые выполняются наиболее часто, и компилирует только их в нативный машинный код во время выполнения. Как обычно называются эти часто исполняемые части? Введи 1 (горячие точки) или 2 (холодные пути).
В чём принципиальное различие между тем, как компилятор и интерпретатор транслируют высокоуровневую программу?
Существуют две фундаментальные стратегии для выполнения высокоуровневой программы на CPU. Компилятор транслирует весь исходный файл в машинный код заранее (до запуска программы), создавая платформо-специфичный двоичный файл, который CPU может выполнить напрямую. Интерпретатор читает исходные операторы и выполняет их во время выполнения, по одному, не создавая двоичного файла с машинным кодом. Компромиссы: скомпилированные программы обычно выполняются быстрее (CPU исполняет предварительно транслированный машинный код без накладных расходов на трансляцию), но должны быть перекомпилированы для каждого целевого CPU. Интерпретируемые программы более переносимы (один и тот же источник работает везде, где установлен подходящий интерпретатор) и не требуют шага компиляции, но выполняются медленнее из-за накладных расходов на пооператорную трансляцию. JIT-компиляция (just-in-time) — это гибрид: программа начинает интерпретироваться (или через компактную байт-код виртуальную машину), а JIT-компилятор обнаруживает часто исполняемые горячие точки и компилирует только эти части в нативный машинный код во время выполнения, достигая производительности, близкой к скомпилированной, после периода прогрева. Современные движки JavaScript (V8), JVM Java и PyPy используют JIT.