Что такое интерпретатор и как он работает

Что такое интерпретатор и как он работает Полезное

Интерпретатор — это программа, которая читает исходный код и выполняет его по ходу дела, не собирая заранее отдельный машинный файл. Компилятор, наоборот, переводит всю программу в машинный код до запуска, и дальше работает уже готовый результат. Граница не всегда резкая: многие современные среды сначала переводят код в промежуточный байткод, а потом либо интерпретируют его, либо на лету компилируют горячие участки (это и есть JIT).

Ниже разведу три близких термина, пройду интерпретацию по шагам (лексер, парсер, AST, байткод, виртуальная машина), соберу маленький рабочий интерпретатор и посмотрю, как устроены CPython, V8, PHP и Ruby.

Интерпретатор, компилятор и JIT: развожу термины

Эти три слова часто путают, поэтому сразу мини-словарь. Один и тот же язык может использовать сразу несколько подходов.

  • Компилятор (AOT, ahead-of-time) — переводит всю программу в машинный код заранее. На выходе отдельный файл (например .exe или ELF), который запускается без исходника и без самого компилятора. Так работают C и C++ через GCC или Clang, Go, Rust.
  • Интерпретатор — выполняет программу во время запуска, разбирая и исполняя ее по частям. Отдельного машинного файла не создает, для запуска нужен сам интерпретатор на устройстве.
  • JIT (just-in-time) — гибрид: код исполняется интерпретатором, но часто вызываемые («горячие») участки компилируются в машинный код прямо во время работы и дальше идут быстрее.

Важное уточнение: «интерпретируемый язык» — это свойство реализации, а не самого языка. Python по спецификации не запрещает компиляцию; просто самая распространенная реализация (CPython) устроена как интерпретатор байткода.

Как работает интерпретатор: от текста к выполнению

Исходный код — это просто строка символов. Чтобы ее выполнить, интерпретатор проходит несколько этапов.

  1. Лексический анализ (лексер). Текст режется на токены: числа, имена, операторы, скобки. Строка 2 + 3 превращается в последовательность NUMBER(2), PLUS, NUMBER(3).
  2. Синтаксический анализ (парсер). Из токенов строится дерево — AST (abstract syntax tree, абстрактное синтаксическое дерево). Оно отражает структуру: у сложения два потомка-операнда.
  3. Выполнение. Дальше два основных пути. Либо интерпретатор обходит AST и вычисляет узлы напрямую (tree-walking), либо сначала переводит AST в компактный байткод, который исполняет виртуальная машина.

Tree-walking проще в реализации, но медленнее. Байткод плюс виртуальная машина — основной подход в промышленных языках, потому что байткод удобнее оптимизировать и быстрее исполнять.

Минимальный интерпретатор арифметики

Соберу законченный tree-walking интерпретатор выражений. Парсинг я переиспользую из стандартного модуля ast, а сам обход дерева пишу руками — это и есть ядро интерпретации.

import ast
import operator

OPS = {
    ast.Add: operator.add,
    ast.Sub: operator.sub,
    ast.Mult: operator.mul,
    ast.Div: operator.truediv,
}

def interpret(expr: str):
    tree = ast.parse(expr, mode="eval").body  # шаг парсера: строим AST
    def ev(node):                             # шаг выполнения: обходим дерево
        if isinstance(node, ast.Constant):
            return node.value
        if isinstance(node, ast.BinOp):
            left = ev(node.left)
            right = ev(node.right)
            return OPS[type(node.op)](left, right)
        raise ValueError("неизвестный узел")
    return ev(node=tree)

print(interpret("2 + 3 * 4"))  # ожидаю 14, а не 20: приоритет умножения задан деревом

В консоли появится 14. Обратите внимание: порядок действий нигде не прописан в моем коде — его задает структура AST, где умножение оказалось глубже сложения. Именно поэтому этап парсинга так важен: он фиксирует смысл до выполнения.

Если подать сломанное выражение, разбор упадет уже на этапе парсера:

print(interpret("2 +"))
SyntaxError: invalid syntax

Исправление — подавать синтаксически полное выражение, например 2 + 0. Ошибка возникла на этапе парсинга, до вычислений: часть ошибок интерпретатор ловит при разборе, часть уже во время исполнения.

Байткод и виртуальная машина на примере CPython

Настоящий Python не обходит AST напрямую — он компилирует его в байткод, а исполняет байткод стековая виртуальная машина. Увидеть байткод можно модулем dis.

import dis

def add(a, b):
    return a + b

dis.dis(add)
  2           RESUME                   0

  3           LOAD_FAST                a
              LOAD_FAST                b
              BINARY_OP                0 (+)
              RETURN_VALUE

Каждая строка — инструкция байткода. LOAD_FAST кладет переменную на стек, BINARY_OP берет два верхних значения и складывает, RETURN_VALUE возвращает результат. Виртуальная машина CPython в цикле читает эти инструкции одну за другой и меняет стек. Точный набор опкодов и оформление вывода зависят от версии CPython, поэтому у себя вывод может отличаться в деталях.

Байткод модуля Python кешируется на диск в каталоге __pycache__ (файлы .pyc), чтобы не разбирать исходник заново при импорте. Это по-прежнему не машинный код процессора — его исполняет виртуальная машина.

JIT: компиляция во время исполнения

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

  • JavaScript, V8 (Chrome, Node.js). Движок сначала гонит байткод через интерпретатор Ignition, а часто исполняемые функции оптимизирующие компиляторы (Maglev, TurboFan) переводят в машинный код. Если предположения оптимизации нарушаются, движок делает деоптимизацию и возвращается к байткоду.
  • PHP (Zend Engine). Исходник компилируется в опкоды, OPcache их кеширует; с PHP 8.0 доступен JIT для горячего кода.
  • Ruby (CRuby). Байткод исполняет виртуальная машина YARV; YJIT добавляет JIT-компиляцию.
  • Python (CPython). Исторически JIT в стандартной сборке не было; экспериментальный JIT появился в недавних версиях и по умолчанию выключается. Точные номера версий и статус стоит сверять по официальным заметкам о релизе.

Вывод: деление на «интерпретируемые» и «компилируемые» языки условно. У реальных движков внутри и разбор кода, и байткод, и JIT одновременно.

Интерпретатор и компилятор: сравнение

Ниже сводка отличий. Речь про чистые случаи AOT-компилятора и интерпретатора байткода; JIT-среды сочетают свойства обеих колонок.

Признак Компилятор (AOT) Интерпретатор
Когда переводится код целиком до запуска по частям во время запуска
Результат отдельный машинный файл отдельного файла нет
Нужен ли инструмент при запуске нет, файл самостоятелен да, нужен сам интерпретатор
Скорость выполнения обычно выше обычно ниже (сглаживает JIT)
Старт после правки кода нужна пересборка запуск сразу, без сборки
Где искать ошибку много ошибок на этапе сборки часть ошибок только при исполнении
Переносимость под каждую платформу свой бинарник один исходник на разных платформах

Плюсы и минусы интерпретаторов

Сильные стороны:

  • Быстрый цикл правка-запуск: сборку ждать не нужно, удобно для разработки и обучения.
  • Переносимость: один исходник работает везде, где есть интерпретатор нужной версии.
  • Гибкость: интерактивная оболочка (REPL), выполнение кода, собранного на лету, простая пошаговая отладка.

Ограничения:

  • Скорость обычно ниже, чем у заранее скомпилированного машинного кода; JIT сокращает, но не всегда убирает разрыв.
  • Для запуска нужен установленный интерпретатор подходящей версии.
  • Исходный код по умолчанию распространяется в открытом виде.

Утверждения про «меньше памяти» или «всегда медленнее» из старых обзоров обманчивы: реальные потребление и скорость зависят от движка, версии и наличия JIT, а не от самого факта интерпретации.

Выводы

  • Интерпретатор выполняет код во время запуска и не создает отдельный машинный файл; компилятор переводит программу в машинный код заранее.
  • Интерпретация проходит по шагам: лексер режет текст на токены, парсер строит AST, дальше идет обход дерева или исполнение байткода виртуальной машиной.
  • JIT — это гибрид: горячие участки компилируются в машинный код на лету (V8, PHP 8, Ruby YJIT).
  • «Интерпретируемый язык» — характеристика реализации, а не языка: CPython, V8, Zend и CRuby совмещают байткод и JIT.
  • Деление языков на компилируемые и интерпретируемые условно; смотреть надо на конкретный движок и его версию.

Где применяется / связь с практикой

Понимание того, как интерпретатор разбирает и исполняет код, помогает читать сообщения об ошибках (что упало на разборе, а что при выполнении), осознанно пользоваться REPL и модулем dis, объяснять разницу в скорости между Python и C. Python — удобная точка входа именно потому, что интерпретатор дает мгновенный цикл правка-запуск и интерактивную оболочку.

Освойте тему на практике

Если хочется системно освоить язык и заодно разобраться, как устроено его выполнение, посмотрите курс Python Basic — там теория подкреплена практикой. Проверить формат и уровень подачи можно на бесплатных открытых уроках перед стартом.

FAQ

Python компилируется или интерпретируется?
И то, и другое. CPython сначала компилирует исходник в байткод (файлы .pyc), а затем исполняет байткод виртуальной машиной. Отдельного машинного файла под процессор при этом не создается.

Чем байткод отличается от машинного кода?
Машинный код исполняет процессор напрямую, он привязан к архитектуре. Байткод — это инструкции для виртуальной машины, они переносимы, но их исполняет программа-интерпретатор, а не железо.

Всегда ли интерпретируемый код медленнее компилируемого?
Не всегда и не во столько раз, как кажется. JIT в V8, PHP и Ruby компилирует горячие участки в машинный код, а на задачах ввода-вывода разница часто незаметна. Точную оценку дает только замер на конкретной нагрузке.

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)