Java
September 11, 2023

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

Многие, возможно, думают, что работа с байт-кодом 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 '#39;:
      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 просто крэшится вместо того, чтобы напечатать понятное сообщение об ошибке.

Источник