Компиляция и интерпретация

Предпосылки: Ассемблер (машинный код, мнемоники), Переменные и типы (статическая и динамическая типизация), Функции (вызов функций).

← Ошибки и исключения

Строка salary = 1500 написана для человека. Процессор работает с инструкциями низкого уровня в ассемблере — в конечном счёте с последовательностями байтов (байт — минимальная адресуемая ячейка, обычно 8 бит). Между исходным текстом и выполнением всегда стоит перевод, и языки различаются не тем, «какой лучше», а тем, каким путём один и тот же исходник добирается до процессора.

Путь удобно описывать тремя осями:

  • когда происходит перевод — до запуска или во время;
  • что получается после перевода — машинный код, промежуточное представление или ничего сохраняемого;
  • кто исполняет результат — сам процессор или программа-посредник.

Компиляция, виртуальная машина и интерпретация — три разных набора ответов на эти три вопроса. Статическая и динамическая типизация — отдельная ось и к форме запуска отношения не имеет.

Компиляция заранее: получи исполнимый файл до запуска

Самый прямой набор ответов по трём осям: когда — до запуска, что — готовый машинный код, кто — сам процессор. Такой подход называется ahead-of-time (AOT — «заблаговременно», до запуска) компиляцией.

flowchart LR
    S["исходный код"] --> C["компилятор"] --> M["машинный код"] --> R["запуск"]

Примеры:

$ gcc file.c -o program
$ ./program
$ rustc file.rs
$ ./file

После компиляции получается файл с машинными инструкциями для конкретной платформы. Операционная система загружает его, и процессор выполняет эти инструкции напрямую.

Этот путь снимает одну важную проблему: значительная часть подготовки уже сделана заранее. Поэтому запуск обычно быстрый, а часть ошибок можно поймать ещё до первого выполнения.

Но у такого подхода есть цена:

  • после каждого изменения код нужно перекомпилировать;
  • получившийся файл привязан к платформе;
  • если нужна другая операционная система или другой процессор, обычно требуется новая сборка.

Виртуальная машина: переведи не в машинный код процессора, а в общий промежуточный код

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

Ответы по трём осям сдвигаются: когда — перевод по-прежнему до запуска, что — не машинный код процессора, а байткод (bytecode — инструкции, закодированные короткими последовательностями байтов) для виртуальной машины, кто — виртуальную машину исполняет не процессор напрямую, а программа-посредник. Виртуальная машина (virtual machine) — это программа, которая ведёт себя как отдельный компьютер: у неё свой набор инструкций и своя модель памяти, но работает она поверх настоящего процессора.

flowchart LR
    S["исходный код"] --> B["байткод"] --> VM["виртуальная машина"] --> R["выполнение"]

В Java это видно прямо в командах:

$ javac Main.java
$ java Main

javac создаёт .class-файлы с байткодом JVM. Команда java запускает этот байткод на виртуальной машине Java.

В Ruby этот промежуточный шаг обычно скрыт от пользователя. Снаружи вы пишете ruby file.rb, но внутри рантайм всё равно сначала разбирает исходный код и превращает его во внутреннюю форму, с которой потом работает виртуальная машина.

Этот путь решает другую задачу, чем AOT-компиляция. Здесь важен не только запуск, но и переносимость: одна и та же промежуточная форма может работать на разных платформах, если на каждой есть своя реализация VM.

Цена тоже своя:

  • между программой и процессором появляется дополнительный слой;
  • модель запуска становится сложнее;
  • часть работы откладывается на рантайм виртуальной машины.

Прямая интерпретация: разбирай и выполняй на ходу

Третий путь жертвует подготовкой ради короткого цикла обратной связи. По трём осям: когда — перевод совмещён с выполнением, что — отдельного сохраняемого артефакта нет, кто — программу исполняет интерпретатор, читающий исходный текст.

flowchart LR
    S["исходный код"] --> I["интерпретатор"] --> R["выполнение"]

Так работали ранние версии BASIC: строка вводилась, сразу разбиралась и сразу выполнялась. Для обучения и экспериментов это было удобно: цикл «написал → увидел результат» становился максимально коротким.

Важно не спутать две вещи:

  • “нет отдельной команды компиляции”;
  • “нет вообще никакой промежуточной внутренней формы”.

Во многих современных системах граница размыта: снаружи язык выглядит интерпретируемым, но внутри рантайм всё равно строит внутреннее представление программы. Для базовой модели достаточно помнить главное: у программиста нет отдельного пользовательского шага сборки, код запускается сразу.

Это не то же самое, что статическая и динамическая типизация

В заметке о типах уже был другой вопрос: когда язык проверяет допустимость операций и насколько явно программист описывает типы.

Это отдельная ось.

Например:

Поэтому нельзя просто сказать:

  • “компилируемый язык значит статический”;
  • “интерпретируемый язык значит динамический”;
  • “виртуальная машина значит все ошибки только во время работы”.

Это разные свойства языка и среды выполнения.

Время компиляции и время выполнения

Из-за этого различия полезно отдельно держать ещё одну пару понятий.

Время компиляции — момент, когда код переводится и проверяется до запуска.

Время выполнения — момент, когда программа уже работает.

Часть ограничений относится именно ко времени компиляции:

  • статическая проверка типов;
  • проверка владения в Rust;
  • отказ собрать программу при явном нарушении контракта.

А часть проблем относится ко времени выполнения:

  • исключения;
  • отсутствие файла;
  • неверный ввод пользователя;
  • ошибки, которые проявляются только на реально дошедшем до них пути выполнения.

Это важно, потому что одна и та же программа почти всегда живёт в обеих фазах: что-то можно проверить заранее, а что-то становится видно только на реальных данных и реальном ходе выполнения.

Почему границы размываются

На практике современные рантаймы часто смешивают несколько подходов.

Виртуальная машина может использовать JIT (just-in-time — «в момент выполнения»): часто выполняемый байткод дополнительно переводится в машинный код уже во время работы программы через компиляцию.

Интерпретатор может сначала построить внутреннее представление программы, а потом выполнять уже его, а не исходный текст посимвольно.

Поэтому названия вроде «компилируемый» и «интерпретируемый» лучше понимать как описание доминирующего пути запуска, а не как обещание, что внутри существует только один механизм.

Что это даёт программисту

ПодходЧто получается после переводаЧто это даётЦена
AOT-компиляциямашинный кодбыстрый запуск, много работы сделано заранеенужна пересборка, результат привязан к платформе
Виртуальная машинабайткод или другая промежуточная формапереносимость и дополнительный слой оптимизацийвыполнение идёт через VM
Прямая интерпретациянет отдельного пользовательского артефактакороткий цикл “изменил → запустил”меньше подготовки вынесено до запуска

Это снова не три разных класса языков «по природе». Это три разных ответа на один и тот же вопрос: где именно проходит граница между написанным текстом и реальным выполнением на машине. Исторически к каждому ответу пришли отдельно — A-0 Грейс Хоппер (1952) и FORTRAN сделали массовой AOT-компиляцию, BASIC — немедленный запуск, Java — виртуальную машину как переносимый слой, — но эти ветки не отменяют друг друга, а отвечают на разные практические требования: скорость выполнения, скорость обратной связи, переносимость и объём проверок до запуска.

Sources

  • Hopper, G., 1952, The Education of a Computer. Proceedings of the ACM.
  • Aho, A. et al., 2006, Compilers: Principles, Techniques, and Tools. Pearson.
  • Thomas, D. et al., 2023, Programming Ruby 3.3. Pragmatic Bookshelf.

← Ошибки и исключения