В интерпретируемых языках программирования (или в языках, которые включают возможность компиляции в runtime) есть возможность вычисления значения выражения, полученного из внешнего источника (например, для построения графика функции, введенной пользователем) с подстановкой актуальных значений переменных. Но JVM изначально ориентирована на компиляцию перед выполнением и механизма, аналогичного eval, в JVM нет (кроме, конечно, возможности использования Nashorn и eval в JavaScript-коде). В этой статье мы рассмотрим пример динамической генерации байт-кода из математического выражения.

Любое математическое выражение может быть преобразовано в последовательность операций над стеком, для этого начнем с простых преобразований математических операций без учета приоритета. Для простоты будем использовать только целочисленные операции (но доработка для использования float/double-значений не будет очень сложной и нужно будет только заменить тип операнда, например заменить IADD на FADD или DADD).

Например, операция 10+20 может быть записана в виде последовательности операций байт-кода - положить константу 10 в стек, положить константу 20 в стек, выполнить сложение, вернуть значение со стека как результат метода. Для генерации через ASM фрагмент может выглядеть следующим образом:

        var newMethod = writer.visitMethod(ACC_PUBLIC, "eval", "()I", null, null);
        newMethod.visitLdcInsn(10);
        newMethod.visitLdcInsn(20);
        newMethod.visitInsn(IADD);
        newMethod.visitInsn(IRETURN);
        newMethod.visitMaxs(2, 1);  //стек из двух значений, локальных переменных тоже 2 + this
        newMethod.visitEnd();

В общем виде можно использовать обратную польскую нотацию и преобразовывать ее в последовательность операций со стеком, например 10 + 20 + 30 можно преобразовать в одну операцию (+ 10 20 30), однако в JVM арифметические операции сложения, умножения, вычитания, деления и остатка от деления являются бинарными, поэтому потребуется дополнительное преобразование в (+ (+ 20 30) 10). Создадим абстракцию для хранения таких операций:

enum OperationType {
    ADD,
    SUB,
    MUL,
    DIV,
}

class Operation {
    OperationType type;
    Object operand1;
    Object operand2;
    Operation(OperationType type, Object operand1, Object operand2) {
        this.type = type;
        this.operand1 = operand1;
        this.operand2 = operand2;
    }
}

Теперь выполним преобразование в стековую машину, последовательность операций может быть такой:

        var newMethod = writer.visitMethod(ACC_PUBLIC, "sum", "()I", null, null);
        newMethod.visitLdcInsn(30);
        //операция 2
        newMethod.visitLdcInsn(10);
        newMethod.visitLdcInsn(20);
        newMethod.visitInsn(IADD);
        //операция 1
        newMethod.visitInsn(IADD);
        newMethod.visitInsn(IRETURN);
        newMethod.visitMaxs(3, 1);  //стек из двух значений, локальных переменных тоже 2 + this
        newMethod.visitEnd();

Будем использовать рекурсивную трансляцию описаний бинарных операций в байт-код, для упрощения выделим отдельный класс для генерации конструктора и генерации байт-кода для бинарной математической операции:

class OperationBuilder {
    OperationBuilder(ClassWriter writer) {
        this.writer = writer;
    }

    ClassWriter writer;

    void createConstructor(String className) {
        //создаем public class Calculator extends java.lang.Object
        writer.visit(V11, ACC_PUBLIC + ACC_SUPER, className, null, "java/lang/Object", null);
        //создаем конструктор без аргументов
        var newConstructor = writer.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
        newConstructor.visitVarInsn(ALOAD, 0);
        //вызываем конструктор родительского класса (не интерфейс, поэтому false последний аргумент)
        //()V - сигнатура метода без аргументов и с типом результата void (V)
        newConstructor.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
        newConstructor.visitInsn(RETURN);
        //определяем размер стека и локальных переменных
        newConstructor.visitMaxs(1, 1);
        //завершаем создание конструктора
        newConstructor.visitEnd();
    }

    void createMethod(String methodName, Operation root) {
        var newMethod = writer.visitMethod(ACC_PUBLIC, methodName, "()I", null, null);
        applyOperation(newMethod, root);
        newMethod.visitInsn(IRETURN);
        newMethod.visitMaxs(3, 1);  //стек из двух значений, локальных переменных тоже 2 + this
        newMethod.visitEnd();
    }

      void saveToClassFile(String className) throws IOException {
        var classFile = writer.toByteArray();
        var stream = new FileOutputStream("build/classes/java/main/"+className+".class");
        stream.write(classFile);
        stream.close();
    }

    void applyOperation(MethodVisitor methodVisitor, Operation operation) {
        if (operation.operand1.getClass()==Operation.class) {
            applyOperation(methodVisitor, (Operation)operation.operand1);
        } else {
            methodVisitor.visitLdcInsn(operation.operand1);
        }
        if (operation.operand2.getClass()==Operation.class) {
            applyOperation(methodVisitor, (Operation)operation.operand2);
        } else {
            methodVisitor.visitLdcInsn(operation.operand2);
        }
        switch (operation.type) {
            case ADD -> methodVisitor.visitInsn(IADD);
            case SUB -> methodVisitor.visitInsn(ISUB);
            case MUL -> methodVisitor.visitInsn(IMUL);
            case DIV -> methodVisitor.visitInsn(IDIV);
        }
    }
}

Теперь сделаем преобразование математического выражения, для правильного вычисления операций с одним приоритетом будем использовать последовательное выполнение бинарных операций, например 10+20+30 преобразуется в последовательность добавления значения констант в стек с последующим сложением. Но для генерации из математического выражения нужно будет также предусмотреть приоритеты операций и сначала подготовим наше выражение и добавим дополнительные скобки вокруг операций с более высоким приоритетом.

Для решения этой задачи будем добавлять рекурсивно дополнительные скобки вокруг выражений, приоритет которых выше, чем приоритет ранее обнаруженных операций. Например, 10+20*30-40 будет преобразован в 10+(20*30)-40. Это позволит выполнять операции над стеком без учета приоритета, при этом выражения в скобках будут выполняться как отдельный набор операций, который возвращает результат в стек. При обнаружении скобок приоритет сбрасывается и значение в скобках рассматривается как отдельная подстрока:

    public ElementResult expandBrackets(String current, int index, int priority) {
        StringBuilder builder = new StringBuilder();
        while (index<current.length()) {
            //завершили выражение - возвращаем подстроку и позицию после скобки
            if (current.charAt(index)==')') {
                return new ElementResult(builder.toString(), index+1);
            }
            //начало подвыражения в скобках
            if (current.charAt(index)=='(') {
                var result = expandBrackets(current, index+1, 1);
                index = result.position;
                builder.append(result.result);
                continue;
            }
            //начало последнего обнаруженного числа
            int lastPosition = index;
            //пропускаем число
            while (index<current.length() && current.charAt(index) >= '0' && current.charAt(index) <= '9') index++;
            //определяем приоритет следующей операции
            int myPriority = priority;
            if (index < current.length()) {
                char op = current.charAt(index);
                if (op == '+' || op == '-') myPriority = 1;
                if (op == '*' || op == '/') myPriority = 2;
            }
            if (myPriority > priority) {
                //если больше - оборачиваем в скобки (возможно в несколько пар, если разница приоритетов >1)
                ElementResult subitem = expandBrackets(current, lastPosition, myPriority);
                index = subitem.position;
                String result = subitem.result;
                for (int i = priority; i < myPriority; i++) result = "(" + result + ")";
                builder.append(result);
            } else if (myPriority < priority) {
                //если меньше - просто добавляем число и выходим (продолжаем в предыдущих скобках)
                builder.append(current.substring(lastPosition, index));
                return new ElementResult(builder.toString(), index);
            } else {
                //иначе добавляем к линейной последовательности операторов
                builder.append(current.substring(lastPosition, index));
                if (index<current.length() && current.charAt(index)!=')') {
                    builder.append(current.charAt(index));
                } else {
                    return new ElementResult(builder.toString(), index);
                }
                index++;
            }
        }
        return new ElementResult(builder.toString(), index);
    }

Теперь полученную строку будем использовать для генерации последовательности команд размещения констант в стек и выполнения вычислений над стеком:

    void createMethod(String methodName, String math) {
        var newMethod = writer.visitMethod(ACC_PUBLIC, methodName, "()I", null, null);
        applyMethod(newMethod, math, 0);
        newMethod.visitInsn(IRETURN);
        newMethod.visitMaxs(10, 1);  //стек из группы значений, локальных переменных тоже 2 + this
        newMethod.visitEnd();
    }

    OperationType getType(char symbol) {
        OperationType ot = null;
        switch (symbol) {
            case '+' -> ot = ADD;
            case '-' -> ot = SUB;
            case '*' -> ot = MUL;
            case '/' -> ot = OperationType.DIV;
        }
        return ot;
    }

    void putOperationIfNeeded(MethodVisitor methodVisitor, OperationType operation) {
        if (operation!=null) {
            System.out.println("Push operation " + operation.name());
            switch (operation) {
                case ADD -> methodVisitor.visitInsn(IADD);
                case SUB -> methodVisitor.visitInsn(ISUB);
                case MUL -> methodVisitor.visitInsn(IMUL);
                case DIV -> methodVisitor.visitInsn(IDIV);
            }
        }
    }

    int applyMethod(MethodVisitor visitor, String math, int currentIndex) {
        OperationType lastOperation = null;
        while (currentIndex<math.length()) {
            if (math.charAt(currentIndex)==')') {
                //скобки закончились - завершаем операции над стеком и возвращаем следующую позицию
                putOperationIfNeeded(visitor, lastOperation);
                return currentIndex+1;
            }
            if (math.charAt(currentIndex) == '(') {
                //скобки собираем как отдельное значение в стеке
                currentIndex = applyMethod(visitor, math, currentIndex + 1);
                putOperationIfNeeded(visitor, lastOperation);
                lastOperation=null;
                continue;
            }
            //сохраняем операцию для применения к следующему значению
            var type = getType(math.charAt(currentIndex));
            if (type!=null) {
                lastOperation = type;
                currentIndex++;
                continue;
            }
            //извлекаем значение числового операнда
            StringBuilder number = new StringBuilder();
            while (currentIndex < math.length() && math.charAt(currentIndex) >= '0' && math.charAt(currentIndex) <= '9') {
                number.append(math.charAt(currentIndex));
                currentIndex++;
            }
            System.out.println("Push constant "+number);
            visitor.visitLdcInsn(Integer.parseInt(number.toString()));
            //применяем операцию над стеком (если необходимо)
            putOperationIfNeeded(visitor, lastOperation);
            //и готовимся к следующей операции
            lastOperation = null;
        }
        return currentIndex;
    }

Для корректного создания метода также необходимо предусмотреть вычисление глубины стека (передается в visitMaxs), для этого будем сохранять текущее значение глубины и увеличивать его на единицу при сохранении константы (или в дальнейшем добавим переменные) или уменьшать на два при выполнении бинарной операции (извлекается два значения, размещает один результат):

    int maxDepth = 0;
    int depth = 0;
    void changeDepth(int delta) {
        depth+=delta;
        if (depth>maxDepth) {
            maxDepth = depth;
        }
    }

Для тестирования нужно будет создать объекта класса OperationBuilder, сгенерировать конструктор, метод eval и сохранить это в .class-файл:

   public static void main(String[] args) throws IOException, ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException, InstantiationException {
        String className = "Calculator2";
        var writer = new ClassWriter(0);
        var builder = new OperationBuilder(writer);
        var result = builder.expandBrackets("20*(3+2)-(2+5)", 0, 1);
        System.out.println("Brackets expansion result is "+result.result);
        builder.createConstructor(className);
        builder.createMethod("eval", result.result);
        builder.saveToClassFile(className);

        var calculator = Class.forName(className);
        var calculatorInstance = calculator.getDeclaredConstructor().newInstance();
        var method = calculator.getMethod("eval");
        var eval = method.invoke(calculatorInstance);
        System.out.print(eval);
    }

После того, как мы создали интерпретатор константных значений, мы можем добавить поддержку переменных. Здесь есть несколько вариантов решения, но мы ограничимся добавлением аргументов к функции в соответствии с обнаруженными ссылками на переменные, для этого мы должны будем изменить сигнатуру функции (добавить необходимое количество букв I для передачи целочисленных аргументов), использовать ее при обнаружении функции в getMethod("eval", Integer.class)и вместо подстановки значения константы использовать обращение к локальной переменной из фрейма метода со смещением, который совпадает с обнаруженной буквой названия переменной. Полный текст eval с поддержкой переменных представлен в https://github.com/dzolotov/bytecode (ветка eval).


Материал подготовлен в преддверии старта онлайн-курса "Java Developer. Professional".

Недавно в рамках курса прошел открытый урок, посвященный разбору протокола HTTP. На уроке рассмотрели, что он из себя представляет, а для закрепления материала написали простейшие HTTP клиент и сервер на java.io. Если интересно, посмотреть запись урока можно на странице курса по ссылке.

Комментарии (5)


  1. APXEOLOG
    19.04.2023 20:07
    +2

    Эх, а я уж думал будет полноценный eval произвольного Java-кода


    1. aleksandy
      19.04.2023 20:07

      Для этого лучше более высокоуровневый javassist использовать. Он умеет прямо код принимать.


  1. illia-ivanou
    19.04.2023 20:07
    +2

    и механизма, аналогичного eval, в JVM нет (кроме, конечно, возможности использования Nashorn и eval в JavaScript-коде)

    Возрожу существованием Jshell и GraalVM с его Polyglot и Truffle, возможности которых выходят далеко за пределы арифметических операций, включая интерпретацию других языков.
    И там и там буквально есть методы eval


  1. zzzzzzzzzzzz
    19.04.2023 20:07

    "... но зачем?"


  1. Teapot
    19.04.2023 20:07
    +1

    Есть отличная готовая библиотека SourceBuddy https://github.com/sourcebuddy/sourcebuddy
    На самом деле интересный вопрос, зачем эвалюировать непременно джаву/