Перестаём бояться генерировать байт-код

—

от автора

Многие, возможно, думают, что работа с байт-кодом Java (будь то чтение или, тем более, генерация) — это какая-то особенная магия, доступная только продвинутым разработчикам с особенно крутым опытом. На самом деле, я считаю такую точку зрения ошибочной. JVM устроена гораздо проще, чем CPU; она оперирует такими высокоуровневыми понятиями как классы, интерфейсы, методы, а не просто лопатит байты в памяти. В отличие от CPU, который легко уронить криво сгенерированным машинным кодом, JVM заботливо отверифицирует любой байт-код и в общем не даст выстрелить в ногу.

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

Весь приведённый код доступен в моём репозитории.

Задача

Я вдохновился книгой Бьёрна Страуструпа, по которой лет 20 назад изучал C++. В одной из первых глав в качестве задачи для введения в язык предлагается написать калькулятор выражений. Я же предлагаю не вычислять выражения, а генерировать байт-код, который вычисляет выражения.

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

public interface Expression {   double evaluate(Function<String, Double> inputs); }

Выражения в списке разделены точкой с запятой (;), метод evaluate возвращает результат вычисления последнего из выражений. Выражения определим так:

  1. Число (например, 2, 42, 3.14) — это выражение.

  2. Идентификатор (например, foo, pi, myVar_1) — это выражение. Значение по-умолчанию для переменной вычисляется с помощью вызова inputs.apply(id).

  3. Если A и B — выражения, то A + B, A — B, A * B, A / B, -A, (A) — так же выражения

  4. Если A — это идентификатор, и B — это выражение, то A = B — так же выражение

Генерируем класс

Для начала напишем генератор класса, реализующего интерфейс Expression. Для этого вначале нужно подключить библиотеку ASM (кстати, документация по ней — это отличный мануал ещё и по байт-коду). В Gradle это может выглядеть так:

dependencies {   implementation "org.ow2.asm:asm:9.5"   implementation "org.ow2.asm:asm-util:9.5" }

Теперь напишем заготовку нашего генератора:

public class ClassGenerator {   // Имя генерируемого класса. В принципе может быть любым.   public static final String CLASS_NAME = ClassGenerator.class.getName()        + "$Generated";      // Имя сгенерированного класса, в терминах JVM.   // В JVM в качестве разделителя пакетов используется / вместо .   private static final String CLASS_INTERNAL_NAME =        CLASS_NAME.replace('.', '/');    public byte[] generate(String expr) {     // Точка входа в ASM - ClassWriter.      // Собственно, он и генерирует байт-код.       var cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES          | ClassWriter.COMPUTE_MAXS);          // Добавляем вывод дизассемблированного байт-кода в консоль     ClassVisitor cv = cw;     if (Boolean.getBoolean("konsoletyper.exprbc.log")) {       cv = new TraceClassVisitor(cv, new PrintWriter(System.out));     }      // Создаём класс     cv.visit(       Opcodes.V11,         // Версия JVM       Opcodes.ACC_PUBLIC,  // модификаторы (public)       CLASS_INTERNAL_NAME, // имя класса (в терминах JVM)       null,                // generic сигнатура, нам не нужна       Type.getInternalName(Object.class),         // имя родительского класса         // в Java можно не указывать extends Object,         // однако в JVM нужно всё указывать явно         // и предельно скрупулёзно       new String[] { Type.getInternalName(Expression.class) }         // имена реализуемых интерфейсов     );      // Создаём конструктор     // В Java тривиальный конструктор необязателен,     // однако в JVM нужно всё описывать явно     generateEmptyConstructor(cv);          // Создаём метод evaluate     generateWorkerMethod(cv, expr);      // Заканчиваем создание класса и генерируем байт-код     cv.visitEnd();     return cw.toByteArray();   } }

Поясню пару моментов.

Во-первых, мы передаём некие параметры в конструктор ClassWriter. Вот зачем это нужно. Дело в том, что в байт-коде методов обязательно указывать размер стека метода и количество слотов под локальные переменные. Так же с некоторых пор стало необходимо добавлять информацию о фреймах — типах переменных и значений на стеке в некоторых точках байт-кода, эта информация позволяет ускорить JIT-компиляцию (в давние времена фреймы были опциональными). Суровые компиляторщики скорее всего всю эту информацию вычислят самостоятельно, но и в образовательных целях, и во многих практических задачах проще поручить эту задачу ASM.

Во-вторых, для диагностики удобно добавлять распечатку сгенерированного байт-кода. Для этого мы используем некоторый ClassVisitor. Тут надо немного рассказать про архитектуру ASM. В нём чтение и запись байт-кода производятся ClassVisitor-ами (а так же MethodVisitor, FieldVisitor и т.д.). При чтении класса мы создаём ClassReader и передаём туда ClassVisitor, который получает от ClassReader-а последовательность команд. ClassWriter наследуется от ClassVisitor и для создания класса нужно вызывать всё те же методы, что вызывает ClassReader при чтении. Так можно, например, трансформировать классы, вставив между reader-ом и writer-ом некоторую логику, однако даже если мы генерируем класс из воздуха, можно пользоваться преимуществами такой архитектуры. Например, в составе ASM есть TraceClassVisitor, которые все вызовы выводит в консоль, чем мы и пользуемся, завернув в него наш ClassWriter.

Итак, теперь напишем генератор пустого конструктора:

private void generateEmptyConstructor(ClassVisitor cv) {   // Создаём метод   var mv = cv.visitMethod(     Opcodes.ACC_PUBLIC,   // модификаторы     "<init>",             // имя метода     Type.getMethodDescriptor(Type.VOID_TYPE),       // дескриптор метода - типы параметров и возвращаемого значения     null,                 // сигнатура generic метода                           // (для нас не актуально)     null                  // выбрасываемые исключения   );   mv.visitCode();    // Кладём на стек локальную переменную 0    // (всегда соответствует this для не статических методов)   mv.visitVarInsn(Opcodes.ALOAD, 0);      // Вызываем метод Object.<init>, т.е. конструктор   // родительского класса   // (аналог super() в Java - мы же помним, что в байт-коде   // всё прописывается явно)   mv.visitMethodInsn(     Opcodes.INVOKESPECIAL,     Type.getInternalName(Object.class),       // у какого класса вызываем метод -       // да, в байт-коде нужно быть настолько       // скрупулёзным     "<init>",     Type.getMethodDescriptor(Type.VOID_TYPE),       // дескриптор вызываемого метода     false  // вызов *не* у интерфейса   );      // Явно пишем return (хотя в Java он и не обязателен)   mv.visitInsn(Opcodes.RETURN);    // Тут прописываем размер стека и количество слотов    // под локальные переменные, про которые упоминалось   // выше. Мы попросили ASM вычислять их автоматически,   // но вызвать visitMaxs мы обязаны, передав им 0   mv.visitMaxs(0, 0);   mv.visitEnd(); }

Обратите внимание, что конструктор в JVM называется не так же как класс (как это сделано в Java), а имеет специальное имя <init>. Для методов необходимо указывать дескриптор — тип возвращаемого значения и аргументов. JVM определяет специальный синтаксис дескрипторов, в и нашем случае дескриптор имеет вид ()V. Однако, можно попросить ASM сгенерировать дескриптор за нас.

JVM — это стековая машина. Любая операция берёт некоторое количество значений со стека, производит над ними действие и кладёт результат на стек. Например, арифметические команды IADD, ISUB и прочие, снимают с вершины стека два значения и кладут результат операции на вершину стека. В данном случае мы вызываем не статический метод и необходимо в качестве аргумента инструкции INVOKESPECIAL указать получатель (receiver) метода, т.е. то, что стоит слева от точки (хотя в случае c super() синтаксис с точкой не предусмотрен).

Кстати, об INVOKESPECIAL. Есть три инструкции для вызова метода: INVOKESPECIAL, INVOKEVIRTUAL и INVOKEINTERFACE. INVOKESPECIAL — это вызов некоторого метода с заранее известной реализацией, т.е. которую не нужно искать в рантайме. В противовес INVOKEVIRTUAL, когда необходимо нужный метод искать в виртуальной таблице. Такое бывает в следующих случаях: вызов конструктора, вызов super-метода, вызов приватного метода.

Наконец, напишем генератор метода evaluate:

private void generateWorkerMethod(ClassVisitor cv, String expr) {   var mv = cv.visitMethod(       Opcodes.ACC_PUBLIC,       "evaluate",       Type.getMethodDescriptor(Type.DOUBLE_TYPE,            Type.getType(Function.class)),        null, null   );   mv.visitCode();    var generator = new CodeGenerator(mv);   var lexer = new Lexer(expr);   var parser = new Parser(lexer, generator);   parser.parse();   mv.visitInsn(Opcodes.DRETURN);    mv.visitMaxs(0, 0);   mv.visitEnd(); }

всю основную работу делает класс CodeGenerator, которым управляет Parser. В конце своей работы CodeGenerator оставляет одно значение на стеке — результат вычисления последнего выражения. Его и возвращает инструкция DRETURN.

Пишем генератор байт-кода выражений

Парсер отдаёт результат во внешний мир через следующий интерфейс:

public interface ParserConsumer {   void number(double value);   void identifier(String id);   void assignment(String id);   void add();   void subtract();   void multiply();   void divide();   void negate();   void statement(); }

Для выражений парсер вначале уведомляет о парсинге операндов, а затем — о самой операции. Например, для выражения 2 + 3 последовательность будет такой:

  1. number(2)

  2. number(3)

  3. add()

Наш генератор реализует этот интерфейс:

public class CodeGenerator implements ParserConsumer {   private final MethodVisitor mv;    public CodeGenerator(MethodVisitor mv) {     this.mv = mv;   } }

Генерация кода для числа выглядит так:

@Override public void number(double value) {   mv.visitLdcInsn(value); }

Имя инструкции LDC расшифровывается как «load constant». Думаю, тут всё понятно. Не менее тривиальной выглядит генерация операций:

@Override public void add() {   mv.visitInsn(Opcodes.DADD); } @Override public void subtract() {   mv.visitInsn(Opcodes.DSUB); } @Override public void multiply() {   mv.visitInsn(Opcodes.DMUL); } @Override public void divide() {   mv.visitInsn(Opcodes.DDIV); } @Override public void negate() {   mv.visitInsn(Opcodes.DNEG); }

Наконец, ещё одной тривиальной операцией является statement, отделяющее выражения друг от друга:

@Override public void statement() {   mv.visitInsn(Opcodes.POP2); }

Она снимает выражение со стека. Смысл в этом вот какой: каждое выражение оставляет на стеке одно значение — результат вычисления выражения. Однако, функция возвращает только значение последнего выражения. Остальные будут скорее всего присваиваниями, их значение можно проигнорировать. Почему же POP2? Дело в том, что в JVM значения типа long и double занимают по два «слота» в стеке и в локальных переменных.

Теперь возьмёмся за переменные. Генератор присвоений реализуем следующим образом:

private final Map<String, Integer> variables = new HashMap<>(); private int lastVariableIndex = 2;  @Override public void assignment(String id) {   var index = variables.computeIfAbsent(id, k -> introduceVariable());   // Пишем значение со стека в локальную переменную   mv.visitVarInsn(Opcodes.DSTORE, index);   // Читаем значение локальной переменной в стек   mv.visitVarInsn(Opcodes.DLOAD, index); }  private int introduceVariable() {   var result = lastVariableIndex;   lastVariableIndex += 2;   return result; }

Т.е. выделяем по два слота в локальных переменных для каждой новой переменной и пишем в локальную переменную значение со стека, а затем снова кладём только что сохранённое значение на стек — это необходимо для того, чтобы воспроизвести семантику присвоения в C/C++, т.е. оно тоже является выражением, результатом которого является значение, присвоенное переменной.

Наконец, сгенерируем чтение переменной:

@Override public void identifier(String id) {   var index = variables.computeIfAbsent(id, k -> {     var newIndex = introduceVariable();     // Если переменная ещё не упоминалась, пытаемся     // прочитать её из inputs     getAndCacheVariable(newIndex, k);     return newIndex;   });   mv.visitVarInsn(Opcodes.DLOAD, index); }  private void getAndCacheVariable(int index, String id) {   // переменная с индексом 1 - это параметр inputs   mv.visitVarInsn(Opcodes.ALOAD, 1);   mv.visitLdcInsn(id);   mv.visitMethodInsn(     Opcodes.INVOKEINTERFACE,     // не забываем явно прописать тип receiver-а - Function     Type.getInternalName(Function.class),     "apply",     // erasure в действии. После компиляции от generics     // не остаётся и следа. Теперь любая Function<T,R>     // "превращается" в Function<Object,Object>     Type.getMethodDescriptor(Type.getType(Object.class),         Type.getType(Object.class)),     true     );    // Если результат null - кидаем исключение   var continueLabel = new Label();   // дублируем значение на стеке, т.к. вначале первую копию    // поглощает IFNONNULL, а вторую - логика ниже   mv.visitInsn(Opcodes.DUP);   mv.visitJumpInsn(Opcodes.IFNONNULL, continueLabel);   reportUndefinedVariable(id);    mv.visitLabel(continueLabel);   // erasure в действии - тип у нас стёрся,   // поэтому нужно вставить явный cast к DOUBLE   mv.visitTypeInsn(Opcodes.CHECKCAST, Type.getInternalName(Double.class));   // так же с присущей JVM скрупулёзностью делаем явный unboxing   mv.visitMethodInsn(     Opcodes.INVOKEVIRTUAL,     Type.getInternalName(Double.class),     "doubleValue",     Type.getMethodDescriptor(Type.DOUBLE_TYPE),     false   );   mv.visitVarInsn(Opcodes.DSTORE, index); }

логика сгенерированного байт-кода может быть выражена следующим псевдокодом:

Object value = inputs.apply("varName"); if (value == null) {   throw new ExecutionException("Undefined variable varName"); } double varName = ((Double) value).doubleValue();

Наконец, напишем выброс исключения:

private void reportUndefinedVariable(String id) {   // В JVM отсутствует операция вызова конструктора.   // Вместо этого вначале надо создать пустой объект,   // А потом вызвать для него метод <init>   mv.visitTypeInsn(Opcodes.NEW,        Type.getInternalName(ExecutionException.class));   // Первый раз наше исключение передаётся в <init>   // Второй раз - в инструкцию ATHROW   mv.visitInsn(Opcodes.DUP);   mv.visitLdcInsn("Undefined variable " + id);   mv.visitMethodInsn(     Opcodes.INVOKESPECIAL,      Type.getInternalName(ExecutionException.class),     "<init>",     Type.getMethodDescriptor(Type.VOID_TYPE,         Type.getType(String.class)),     false   );   mv.visitInsn(Opcodes.ATHROW); }

Создаём класс в рантайме

В принципе, наш код уже умеет генерировать байт-код, однако что с ним делать? Можно сохранить его на диск в файл с расширением class и линковать к коду. Однако, полезность такой утилиты невысока. Интереснее запустить его тут же, используя старый добрый reflection:

public class Compiler {   private final ClassLoader classLoader;    public Compiler(ClassLoader classLoader) {     this.classLoader = classLoader;   }    public Expression compile(String expr) {     var bytecode = new ClassGenerator().generate(expr);     var loader = new ProvidedBytecodeClassLoader(classLoader, bytecode);     Class<?> cls;     try {       cls = Class.forName(ClassGenerator.CLASS_NAME, false, loader);     } catch (ClassNotFoundException e) {       throw new RuntimeException(e);     }     try {       return (Expression) cls.getConstructor().newInstance();     } catch (InstantiationException          | IllegalAccessException          | NoSuchMethodException          | InvocationTargetException e) {       throw new RuntimeException(e);     }   } }

Секретный ингредиент здесь:

public class ProvidedBytecodeClassLoader extends ClassLoader {   private final byte[] bytecode;    public ProvidedBytecodeClassLoader(ClassLoader parent, byte[] bytecode) {     super(parent);     this.bytecode = bytecode;   }    @Override   protected Class<?> findClass(String name) throws ClassNotFoundException {     if (!name.equals(ClassGenerator.CLASS_NAME)) {       return super.findClass(name);     }     return defineClass(ClassGenerator.CLASS_NAME, bytecode, 0, bytecode.length);   } }

Пусть вас не смущает то, что класс всегда создаётся по одному имени. В JVM имя класса не обязано быть уникальным, уникальной должна быть пара (имя класса, ClassLoader). Каждое новое выражение будет загружено через новый ClassLoader, поэтому никаких проблем у нас не возникнет.

Пишем парсер

Здесь нет ничего особенного с точки зрения именно нашей задачи. Руководств по тому, как это правильно делать, достаточно много. Поэтому я приведу код с минимальным количеством комментариев. В качестве домашнего задания можете пропустить этот раздел и написать парсер самостоятельно.

Итак, обычно парсер разделяют на лексер и собственно парсер. Лексер разбивает поток символов на лексемы. Определим для начала список лексем нашего языка:

public enum Token {   NUMBER,   PLUS,   MINUS,   STAR,   SLASH,   LEFT_BRACE,   RIGHT_BRACE,   SEMICOLON,   ASSIGNMENT,   IDENTIFIER,   EOF }

и напишем сам лексер:

public class Lexer {   private final CharSequence inputString;   private int position;   private Token token;   private int tokenPosition;   private double number;   private String identifier;    public Lexer(CharSequence inputString) {     this.inputString = inputString;   }    public void next() {     if (token == Token.EOF) {       return;     }     skipWhitespace();     identifier = null;     number = 0;     tokenPosition = position;     if (position == inputString.length()) {       token = Token.EOF;       return;     }      var c = inputString.charAt(position);     switch (c) {       case '+':         simpleToken(Token.PLUS);         break;       case '-':         simpleToken(Token.MINUS);         break;       case '*':         simpleToken(Token.STAR);         break;       case '/':         simpleToken(Token.SLASH);         break;       case '(':         simpleToken(Token.LEFT_BRACE);         break;       case ')':         simpleToken(Token.RIGHT_BRACE);         break;       case '=':         simpleToken(Token.ASSIGNMENT);         break;       case ';':         simpleToken(Token.SEMICOLON);         break;       default:         if (isIdentifierStart(c)) {           parseIdentifier();         } else if (isDigit(c)) {           parseNumber();         } else {           error("Unexpected character");         }         break;     }   }    private void simpleToken(Token token) {     this.token = token;     ++position;   }    private void skipWhitespace() {     while (position < inputString.length()          && Character.isWhitespace(inputString.charAt(position))) {       ++position;     }   }    private void parseIdentifier() {     token = Token.IDENTIFIER;     tokenPosition = position;     while (position < inputString.length()          && isIdentifierPart(inputString.charAt(position))) {       ++position;     }     identifier = inputString.subSequence(tokenPosition, position).toString();   }    private void parseNumber() {     token = Token.NUMBER;     while (position < inputString.length()          && isDigit(inputString.charAt(position))) {       ++position;     }     if (position < inputString.length()          && inputString.charAt(position) == '.') {       ++position;       if (position >= inputString.length()            || !isDigit(inputString.charAt(position))) {         error("Invalid number literal");       }       while (position < inputString.length()            && isDigit(inputString.charAt(position))) {         ++position;       }     }     try {       number = Double.parseDouble(inputString.subSequence(           tokenPosition, position).toString());     } catch (NumberFormatException e) {       error("Invalid number literal");     }   }    private static boolean isIdentifierStart(char c) {     switch (c) {       case '$':       case '_':         return true;       default:         return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z';     }   }    private static boolean isIdentifierPart(char c) {     return isIdentifierStart(c) || isDigit(c);   }    private static boolean isDigit(char c) {     return c >= '0' && c <= '9';   }    private void error(String error) {     throw new ParseException(error, position);   }    public int getTokenPosition() {     return tokenPosition;   }    public Token getToken() {     return token;   }    public double getNumber() {     return number;   }    public String getIdentifier() {     return identifier;   } }

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

Наконец, парсер:

public class Parser {   private final Lexer lexer;   private final ParserConsumer consumer;    public Parser(Lexer lexer, ParserConsumer consumer) {     this.lexer = lexer;     this.consumer = consumer;   }    public void parse() {     lexer.next();     parseSum();     while (lexer.getToken() == Token.SEMICOLON) {       skipSemicolons();       if (lexer.getToken() == Token.EOF) {         break;       }       consumer.statement();       parseSum();     }     if (lexer.getToken() != Token.EOF) {       error("End of input expected");     }   }    private void skipSemicolons() {     while (lexer.getToken() == Token.SEMICOLON) {       lexer.next();     }   }    private void parseSum() {     parseProd();     while (lexer.getToken() == Token.PLUS || lexer.getToken() == Token.MINUS) {       var token = lexer.getToken();       lexer.next();       parseProd();       if (token == Token.PLUS) {         consumer.add();       } else {         consumer.subtract();       }     }   }    private void parseProd() {     parsePrime();     while (lexer.getToken() == Token.STAR || lexer.getToken() == Token.SLASH) {       var token = lexer.getToken();       lexer.next();       parsePrime();       if (token == Token.STAR) {         consumer.multiply();       } else {         consumer.divide();       }     }   }    private void parsePrime() {     switch (lexer.getToken()) {       case NUMBER:         consumer.number(lexer.getNumber());         lexer.next();         break;       case IDENTIFIER:         parseIdentifierOrAssignment();         break;       case MINUS:         lexer.next();         parsePrime();         consumer.negate();         break;       case LEFT_BRACE:         lexer.next();         parseParenthesized();         break;       default:         error("Unexpected token");         break;     }   }    private void parseIdentifierOrAssignment() {     var id = lexer.getIdentifier();     lexer.next();     if (lexer.getToken() == Token.ASSIGNMENT) {       lexer.next();       parseSum();       consumer.assignment(id);     } else {       consumer.identifier(id);     }   }    private void parseParenthesized() {     parseSum();     if (lexer.getToken() != Token.RIGHT_BRACE) {       error("Closing brace expected");     }     lexer.next();   }    private void error(String error) {     throw new ParseException(error, lexer.getTokenPosition());   } }

Пример

Проверим получившийся калькулятор:

var compiler = new Compiler(ClassLoader.getSystemClassLoader()); var expr = compiler.compile("pi = 3.14159; pi * r * r");  var inputs = new HashMap<String, Double>(); inputs.put("r", 2.0); System.out.println(expr.evaluate(inputs::get));  inputs.put("r", 5.0); System.out.println(expr.evaluate(inputs::get));

вывод:

12.56636 78.53975

Однако, гораздо интереснее посмотреть сгенерированный байт-код. Запускаем с системным свойством konsoletyper.exprbc.log=true:

  ; pi = 3.14159   LDC 3.14159   DSTORE 2   DLOAD 2   POP2      ; первое вычисление r   DLOAD 2   ALOAD 1   LDC "r"   INVOKEINTERFACE java/util/function/Function.apply (Ljava/lang/Object;)Ljava/lang/Object; (itf)   DUP   IFNONNULL L0   NEW konsoletyper/exprbc/ExecutionException   DUP   LDC "Undefined variable r"   INVOKESPECIAL konsoletyper/exprbc/ExecutionException.<init> (Ljava/lang/String;)V   ATHROW L0   CHECKCAST java/lang/Double   INVOKEVIRTUAL java/lang/Double.doubleValue ()D   DSTORE 4   DLOAD 4      ; pi * r   DMUL      ; второе вычисление r   ; теперь значение закэшировано в локальной переменной   DLOAD 4      ; pi * r * r   DMUL   DRETURN 

Зачем это всё?

Казалось бы, я должен был поместить статью в хаб «ненормальное программирование». Однако, у генерации байт-кода есть свои применения. Во-первых, это производительность. Хотя редко, но бывает, что какое-то узкое место можно оптимизировать только генерацией байт-кода. Во-вторых, это навешивание всякой дополнительной функциональности на имеющиеся классы (например, сериализация, RPC). И хотя всё то же самое можно сделать через reflection, у reflection есть один недостаток: он плохо работает с AOT-компиляторами, которым в какой-то мере является, например, Android SDK, а точнее его часть под названием r8. Сюда же относится Graal Native Image, который можно использовать для запуска Java-приложений на iOS. Альтернативой генерации байт-кода здесь являются annotation processor. Однако, у них есть ряд недостатков:

  1. Они плохо работают на стыке Java и Kotlin.

  2. Они плохо дружат с инкрементальной компиляцией. Точнее, они с ней дружат, но порой внезапно система сборки хочет перекомпилировать слишком много всего. Сгенерировать байт-код обычно намного быстрее, чем спарсить и порезолвить часть исходников модуля.

  3. Иногда при работе с generics в случае с исходниками приходится очень сильно заморачиваться, чтобы правильно вывести типовые аргументы. В байт-коде все generics стёрты и проблемы просто нет.

Наконец, байт-код иногда не генерируют с нуля, а модифицируют существующий. Такое уж точно нельзя заменить никакими reflection или annotation processor.

Есть, правда, пара трудностей:

  1. Отлаживать всё это достаточно тяжело — некуда поставить брейкпоинт. Теоретически можно через ASM вписывать в байт-код информацию для отладчика, можно параллельно генерировать какой-нибудь псевдокод. Вот только мне не известны способы заставить IDE всё это понимать.

  2. Порой приходится иметь дело с багами ASM. Когда мы генерируем заведомо валидный байт-код, всё работает как часы. Однако, порой при генерации невалидного байт-кода ASM просто крэшится вместо того, чтобы напечатать понятное сообщение об ошибке.


ссылка на оригинал статьи https://habr.com/ru/articles/759990/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *