Многие, возможно, думают, что работа с байт-кодом Java (будь то чтение или, тем более, генерация) — это какая-то особенная магия, доступная только продвинутым разработчикам с особенно крутым опытом. На самом деле, я считаю такую точку зрения ошибочной. JVM устроена гораздо проще, чем CPU; она оперирует такими высокоуровневыми понятиями как классы, интерфейсы, методы, а не просто лопатит байты в памяти. В отличие от CPU, который легко уронить криво сгенерированным машинным кодом, JVM заботливо отверифицирует любой байт-код и в общем не даст выстрелить в ногу. Но с чего начать погружение в байт-кодную магию? В сети есть некоторое количество туториалов по этому вопросу. Как мне кажется, они либо показывают слишком простые случаи, от которых непонятно, как перейти к чему-то более интересному, либо очень основательные и требуют вникать в теорию, собирать целиком картину в голове по кусочкам. Я хотел бы попробовать внести свой вклад в эту тему — надеюсь, у меня получится показать, как можно побороть первый страх и написать что-то похожее на реалистичный сценарий без особого вникания в теорию на первом этапе. Весь приведённый код доступен в моём репозитории. Я вдохновился книгой Бьёрна Страуструпа, по которой лет 20 назад изучал C++. В одной из первых глав в качестве задачи для введения в язык предлагается написать калькулятор выражений. Я же предлагаю не вычислять выражения, а генерировать байт-код, который вычисляет выражения. Итак, формулировка: необходимо написать метод, которые принимает на вход строку с математическими выражениями и выдаёт на выходе экземпляр такого интерфейса: Выражения в списке разделены точкой с запятой (;), метод evaluate возвращает результат вычисления последнего из выражений. Выражения определим так: Число (например, 2, 42, 3.14) — это выражение. Идентификатор (например, foo, pi, myVar_1) — это выражение. Значение по-умолчанию для переменной вычисляется с помощью вызова inputs.apply(id). Если A и B — выражения, то A + B, A - B, A * B, A / B, -A, (A) — так же выражения Если A — это идентификатор, и B — это выражение, то A = B — так же выражение Для начала напишем генератор класса, реализующего интерфейс Expression. Для этого вначале нужно подключить библиотеку ASM (кстати, документация по ней — это отличный мануал ещё и по байт-коду). В Gradle это может выглядеть так: Теперь напишем заготовку нашего генератора: Поясню пару моментов. Во-первых, мы передаём некие параметры в конструктор ClassWriter. Вот зачем это нужно. Дело в том, что в байт-коде методов обязательно указывать размер стека метода и количество слотов под локальные переменные. Так же с некоторых пор стало необходимо добавлять информацию о фреймах — типах переменных и значений на стеке в некоторых точках байт-кода, эта информация позволяет ускорить JIT-компиляцию (в давние времена фреймы были опциональными). Суровые компиляторщики скорее всего всю эту информацию вычислят самостоятельно, но и в образовательных целях, и во многих практических задачах проще поручить эту задачу ASM. Во-вторых, для диагностики удобно добавлять распечатку сгенерированного байт-кода. Для этого мы используем некоторый ClassVisitor. Тут надо немного рассказать про архитектуру ASM. В нём чтение и запись байт-кода производятся ClassVisitor-ами (а так же MethodVisitor, FieldVisitor и т.д.). При чтении класса мы создаём ClassReader и передаём туда ClassVisitor, который получает от ClassReader-а последовательность команд. ClassWriter наследуется от ClassVisitor и для создания класса нужно вызывать всё те же методы, что вызывает ClassReader при чтении. Так можно, например, трансформировать классы, вставив между reader-ом и writer-ом некоторую логику, однако даже если мы генерируем класс из воздуха, можно пользоваться преимуществами такой архитектуры. Например, в составе ASM есть TraceClassVisitor, которые все вызовы выводит в консоль, чем мы и пользуемся, завернув в него наш ClassWriter. Итак, теперь напишем генератор пустого конструктора: Обратите внимание, что конструктор в JVM называется не так же как класс (как это сделано в Java), а имеет специальное имя <init>. Для методов необходимо указывать дескриптор — тип возвращаемого значения и аргументов. JVM определяет специальный синтаксис дескрипторов, в и нашем случае дескриптор имеет вид ()V. Однако, можно попросить ASM сгенерировать дескриптор за нас. JVM — это стековая машина. Любая операция берёт некоторое количество значений со стека, производит над ними действие и кладёт результат на стек. Например, арифметические команды IADD, ISUB и прочие, снимают с вершины стека два значения и кладут результат операции на вершину стека. В данном случае мы вызываем не статический метод и необходимо в качестве аргумента инструкции INVOKESPECIAL указать получатель (receiver) метода, т.е. то, что стоит слева от точки (хотя в случае c super() синтаксис с точкой не предусмотрен). Кстати, об INVOKESPECIAL. Есть три инструкции для вызова метода: INVOKESPECIAL, INVOKEVIRTUAL и INVOKEINTERFACE. INVOKESPECIAL — это вызов некоторого метода с заранее известной реализацией, т.е. которую не нужно искать в рантайме. В противовес INVOKEVIRTUAL, когда необходимо нужный метод искать в виртуальной таблице. Такое бывает в следующих случаях: вызов конструктора, вызов super-метода, вызов приватного метода. Наконец, напишем генератор метода evaluate: всю основную работу делает класс CodeGenerator, которым управляет Parser. В конце своей работы CodeGenerator оставляет одно значение на стеке — результат вычисления последнего выражения. Его и возвращает инструкция DRETURN. Парсер отдаёт результат во внешний мир через следующий интерфейс: Для выражений парсер вначале уведомляет о парсинге операндов, а затем — о самой операции. Например, для выражения 2 + 3 последовательность будет такой: number(2) number(3) add() Наш генератор реализует этот интерфейс: Генерация кода для числа выглядит так: Имя инструкции LDC расшифровывается как "load constant". Думаю, тут всё понятно. Не менее тривиальной выглядит генерация операций: Наконец, ещё одной тривиальной операцией является statement, отделяющее выражения друг от друга: Она снимает выражение со стека. Смысл в этом вот какой: каждое выражение оставляет на стеке одно значение — результат вычисления выражения. Однако, функция возвращает только значение последнего выражения. Остальные будут скорее всего присваиваниями, их значение можно проигнорировать. Почему же POP2? Дело в том, что в JVM значения типа long и double занимают по два "слота" в стеке и в локальных переменных. Теперь возьмёмся за переменные. Генератор присвоений реализуем следующим образом: Т.е. выделяем по два слота в локальных переменных для каждой новой переменной и пишем в локальную переменную значение со стека, а затем снова кладём только что сохранённое значение на стек — это необходимо для того, чтобы воспроизвести семантику присвоения в C/C++, т.е. оно тоже является выражением, результатом которого является значение, присвоенное переменной. Наконец, сгенерируем чтение переменной: логика сгенерированного байт-кода может быть выражена следующим псевдокодом: Наконец, напишем выброс исключения: В принципе, наш код уже умеет генерировать байт-код, однако что с ним делать? Можно сохранить его на диск в файл с расширением class и линковать к коду. Однако, полезность такой утилиты невысока. Интереснее запустить его тут же, используя старый добрый reflection: Секретный ингредиент здесь: Пусть вас не смущает то, что класс всегда создаётся по одному имени. В JVM имя класса не обязано быть уникальным, уникальной должна быть пара (имя класса, ClassLoader). Каждое новое выражение будет загружено через новый ClassLoader, поэтому никаких проблем у нас не возникнет. Здесь нет ничего особенного с точки зрения именно нашей задачи. Руководств по тому, как это правильно делать, достаточно много. Поэтому я приведу код с минимальным количеством комментариев. В качестве домашнего задания можете пропустить этот раздел и написать парсер самостоятельно. Итак, обычно парсер разделяют на лексер и собственно парсер. Лексер разбивает поток символов на лексемы. Определим для начала список лексем нашего языка: и напишем сам лексер: Предполагается, что у лексера последовательно вызывают next, он читает очередную лексему, и далее можно через публичные методы получать её свойства. Наконец, парсер: Проверим получившийся калькулятор: вывод: Однако, гораздо интереснее посмотреть сгенерированный байт-код. Запускаем с системным свойством konsoletyper.exprbc.log=true: Казалось бы, я должен был поместить статью в хаб "ненормальное программирование". Однако, у генерации байт-кода есть свои применения. Во-первых, это производительность. Хотя редко, но бывает, что какое-то узкое место можно оптимизировать только генерацией байт-кода. Во-вторых, это навешивание всякой дополнительной функциональности на имеющиеся классы (например, сериализация, RPC). И хотя всё то же самое можно сделать через reflection, у reflection есть один недостаток: он плохо работает с AOT-компиляторами, которым в какой-то мере является, например, Android SDK, а точнее его часть под названием r8. Сюда же относится Graal Native Image, который можно использовать для запуска Java-приложений на iOS. Альтернативой генерации байт-кода здесь являются annotation processor. Однако, у них есть ряд недостатков: Они плохо работают на стыке Java и Kotlin. Они плохо дружат с инкрементальной компиляцией. Точнее, они с ней дружат, но порой внезапно система сборки хочет перекомпилировать слишком много всего. Сгенерировать байт-код обычно намного быстрее, чем спарсить и порезолвить часть исходников модуля. Иногда при работе с generics в случае с исходниками приходится очень сильно заморачиваться, чтобы правильно вывести типовые аргументы. В байт-коде все generics стёрты и проблемы просто нет. Наконец, байт-код иногда не генерируют с нуля, а модифицируют существующий. Такое уж точно нельзя заменить никакими reflection или annotation processor. Есть, правда, пара трудностей: Отлаживать всё это достаточно тяжело — некуда поставить брейкпоинт. Теоретически можно через ASM вписывать в байт-код информацию для отладчика, можно параллельно генерировать какой-нибудь псевдокод. Вот только мне не известны способы заставить IDE всё это понимать. Порой приходится иметь дело с багами ASM. Когда мы генерируем заведомо валидный байт-код, всё работает как часы. Однако, порой при генерации невалидного байт-кода ASM просто крэшится вместо того, чтобы напечатать понятное сообщение об ошибке.