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

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

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

Строка 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.

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