JEP draft: Code Reflection (Incubator)
Code Reflection (рефлексия кода), версия Incubator (инкубационный модуль)
| Ответственный | Paul Sandoz |
| Тип | Feature |
| Область | JDK |
| Статус | Submitted |
| Компонент | core-libs |
| Обсуждение | babylon dash dev at openjdk dot org |
| Трудоёмкость | L |
| Длительность | L |
| Рецензенты | Adam Sotona, Gary Frost, Juan Fumero, Maurizio Cimadamore |
| Создан | 2025/06/30 19:54 |
| Обновлён | 2026/09/21 21:38 |
| Задача | 8361105 |
Аннотация
Расширить базовый API рефлексии (core reflection API), чтобы он позволял моделировать Java-код, строить и преобразовывать модели Java-кода, а также получать доступ к моделям Java-кода в методах и лямбда-выражениях. Библиотеки могут использовать это расширение, чтобы анализировать Java-код и расширять область его применения, например выполнять его как код на GPU. Это API в статусе Incubator.
Цели
- Дать Java-разработчикам возможность взаимодействовать с программными моделями, отличными от Java (внешними, foreign), с помощью привычных конструкций языка Java, таких как лямбда-выражения и статическая типизация.
- Побудить библиотеки предоставлять Java-разработчикам новые программные модели, не требуя от разработчиков встраивать не-Java-код в Java-код или писать утомительный Java-код, который строит структуры данных для моделирования Java-кода или другого (внешнего) кода.
Что не является целью
- Целью не является изменение смысла Java-программ, заданного спецификацией Java Language Specification, компиляция исходного кода Java во что-либо, кроме набора инструкций, заданного спецификацией Java Virtual Machine, изменение набора инструкций JVM и изменение HotSpot для поддержки наборов инструкций специализированных вычислительных устройств. Например, целью не является внесение таких изменений в платформу Java для выполнения Java-методов на GPU.
- Целью не является стандартизация внутреннего абстрактного синтаксического дерева (Abstract Syntax Tree)
javacв качестве модели Java-кода. - Целью не является предоставление доступа к байт-коду во время выполнения и использование байт-кода в качестве модели Java-кода.
- Целью не является создание универсального средства метапрограммирования или макросов для языка Java.
- Целью не является введение языковых конструкций, подобных литералам классов, для краткой записи доступа к модели кода.
Мотивация
Многим Java-программам нужно обрабатывать большие объёмы данных параллельно, и библиотеки Java упрощают реализацию параллельных вычислений. Например, в алгоритме распознавания лиц нам нужно преобразовать RGB-пиксели в оттенки серого; вот упрощённый код, который делает это с помощью лямбда-выражения и встроенных в JDK параллельных потоков данных (streams):
IntConsumer rgbToGray = i -> {
byte r = rgbImage[i * 3 + 0];
byte g = rgbImage[i * 3 + 1];
byte b = rgbImage[i * 3 + 2];
grayImage[i] = gray(r, g, b);
};
IntStream.range(0, N)
.parallel()
.forEach(rgbToGray);
Если количество пикселей N достаточно велико и/или вычисление каждого пикселя достаточно трудоёмко, поток данных вычислит результат быстрее, чем однопоточный цикл for, даже с учётом накладных расходов на запуск и координацию нескольких потоков.
for (int i = 0; i < N; i++) {
byte r = rgbImage[i * 3 + 0];
byte g = rgbImage[i * 3 + 1];
byte b = rgbImage[i * 3 + 2];
grayImage[i] = gray(r, g, b);
}
Закон Густафсона гласит, что по мере увеличения числа потоков M, каждый из которых обрабатывает достаточно большое количество пикселей, оценочное ускорение программы будет приближаться к M по мере роста доли времени, затрачиваемого на параллельные задачи.
К сожалению, число потоков, способных выполнять вычислительно ёмкие задачи, ограничено CPU: например, у AMD EPYC 9005 Zen 5c 384 потока. В Java 21 появились Virtual Threads (виртуальные потоки) для параллельного выполнения большого числа задач с интенсивным вводом-выводом, но Virtual Threads не создают новых вычислительных ресурсов и не могут ускорить код, который уже упирается в CPU.
Вычисления общего назначения на графических процессорах
Существует класс вычислительных устройств — графический процессор (Graphics Processing Unit, GPU), архитектура которого сильно отличается от CPU: вместо нескольких сотен потоков современный GPU, например NVIDIA Blackwell B200, может одновременно выполнять несколько сотен тысяч потоков.
Изначально GPU создавались для рендеринга изображений и видеоигр, но теперь их можно использовать для вычислений общего назначения, таких как распознавание лиц, общее умножение матриц (General Matrix Multiplication, GEMM) или быстрое преобразование Фурье (Fast Fourier Transformation, FFT).
Если бы мы могли выполнять параллельные задачи на GPU, а не на CPU, с числом потоков на порядки больше, мы могли бы либо значительно сократить время выполнения, либо выполнить больше вычислений за то же время.
Исторически одним из подходов было написать многопоточное вычисление на языке, который поддерживает GPU, например на CUDA C, и встроить его в Java-программу в виде строки. С помощью JNI можно было запустить компилятор CUDA C и передать скомпилированный код на GPU для выполнения.
static void gpuComputation(int N, byte[] rgbImage, byte[] grayImage) {
var cudaCCode = """
__device__
char gray(char r, char g, char b) { return ...; }
__global__
void computeGrayImage(int N, char* rgbImage, char* grayImage) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < N) {
char r = rgbImage[i * 3 + 0];
char g = rgbImage[i * 3 + 1];
char b = rgbImage[i * 3 + 2];
grayImage[i] = gray(r, g, b);
}
}
""";
var gpuCode = compileGpuCode(cudaCCode);
runGpuCode(gpuCode, N, rgbImage, grayImage);
}
Этот подход проблематичен: он даёт «протекающую» абстракцию, которая вынуждает разработчиков разбираться в артефактах CUDA, и код перестаёт быть независимым от оборудования. Ожидать, что разработчики будут писать Java-код, который лишь переносит не-Java-код, ошибочно, поскольку javac не может скомпилировать и проверить этот код; для переноса кода на CUDA C они могут выбрать любой язык, не только Java.
Поиск лучших абстракций
В 2010-х годах проект OpenJDK Sumatra ставил целью дать Java-разработчикам возможность использовать GPU за счёт улучшения JVM и параллельных потоков данных. JVM проекта Sumatra умела генерировать код для GPU AMD и размещать части кучи JVM в памяти GPU. Этот подход, при котором платформа Java скрывает присутствие GPU от Java-кода, резко контрастирует с ручным встраиванием кода GPU в Java-программу. Ни один из этих подходов не даёт правильной абстракции, и именно поэтому мы отказались от Sumatra.
Скрыть GPU особенно трудно. Во-первых, память разделена между CPU и GPU; управление кучей JVM одновременно на CPU и GPU может постоянно снижать производительность. Во-вторых, идиоматичный Java-код в лямбда-выражениях полиморфен: методы обычно вызываются на интерфейсах, а не на классах, и каждый вызов запускает поиск виртуального метода и, возможно, загрузку класса, его инициализацию и т. д. Переносить такое сильно изменчивое поведение, при котором каждый поток может выполнять разный код, на GPU контрпродуктивно, ведь там каждый поток должен синхронно выполнять одинаковый код на разных элементах данных в модели выполнения Single Instruction Multiple Thread (SIMT).
Расширение возможностей библиотек
Мы считаем, что лучший способ поддержать GPU в платформе Java — ввести примитивы, позволяющие создавать библиотеки, которые, в свою очередь, вводят новые программные модели и API, задействующие уникальные возможности GPU в работе с памятью и выполнении кода. С этими примитивами мы проектируем платформу Java с расчётом на рост. Библиотеки могут вводить свои новые программные модели, которые ощущаются частью платформы Java, но при этом могут свободно отступать от семантики Java-кода. Им не приходится ждать, ценой огромных затрат, добавления в платформу Java сомнительных возможностей. (Подробнее см. раздел Внешние программные модели.)
Один из таких примитивов — Foreign Function & Memory (FFM) API, появившийся в Java 22. Хотя FFM API ничего не знает о GPU, он позволяет библиотекам эффективно взаимодействовать с нативными драйверами устройств на CPU и тем самым косвенно управлять GPU.
Если библиотеки должны транслировать параллельные части Java-программ в код GPU, им нужен доступ к Java-коду. К счастью, в платформе Java давно есть примитив — базовая рефлексия (core reflection), — который позволяет библиотеке изучать структуру Java-программы. В Java 1.1 появилась рефлексия во время выполнения, API базовой рефлексии, положившая начало экосистеме библиотек для доступа к данным, модульного тестирования, обмена сообщениями и т. д. В Java 5 появилась рефлексия во время компиляции, позволившая процессорам аннотаций генерировать код, который расширяет приложение без затрат на сопровождение.
К сожалению, базовый API рефлексии ограничен и не даёт доступа к коду в методах или лямбда-выражениях. С помощью базового API рефлексии можно узнать, какие методы объявлены в классе, но нельзя пойти глубже и изучить код этих методов.
Библиотека может получить доступ к исходному коду методов и лямбда-выражений через внутренние API javac, но это возможно только во время компиляции и слишком сложно, поскольку содержит множество посторонних синтаксических деталей. Во время выполнения библиотека может получить доступ к байт-коду методов (но не лямбда-выражений) с помощью Class-File API, но это плохая замена исходному коду, к тому же class-файлы доступны не всегда.
Code Reflection: недостающий примитив
Чтобы эффективно поддержать библиотеки, мы предлагаем расширить рефлексию так, чтобы она открывала доступ не только к классам, полям и методам, но и к коду методов и лямбда-выражений. С этим расширением можно разрабатывать библиотеки, которые транслируют Java-код, например лямбда-выражение в параллельном потоке данных, в код GPU, и тогда не нужно вручную писать код на CUDA C. Зная и Java-код, и код GPU, библиотеки могут моделировать зависимости по данным и оптимизировать передачу данных между CPU и GPU для повышения производительности.
Внешние программные модели
Так же как FFM API не привязан к GPU, API, дающий доступ к Java-коду, тоже не привязан к GPU. Библиотеки могут использовать его, например, чтобы автоматически дифференцировать Java-код, передавать транслированный Java-код нативным средам выполнения машинного обучения или транслировать Java-код в SQL-запросы. Всё это примеры внешних (foreign) программных моделей.
Библиотека, которая транслирует Java-код в код какой-либо другой внешней программной модели, в общем случае не сохраняет семантику этого Java-кода. Например, GPU-библиотека в общем случае не сохранит семантику Java-кода, когда транслирует его в код на C, соответствующий программной модели CUDA C, заданной NVIDIA. GPU-библиотека сама определяет правила, какой Java-код можно транслировать. Например, она может отвергать инструкции try и не сохранять точность операций с плавающей точкой. Эти правила чужды программной модели Java, заданной спецификацией Java Language Specification, которая ничего не говорит ни о программных моделях для GPU, ни о моделях для автоматического дифференцирования и т. д.
Таким образом, с помощью Code Reflection мы можем задействовать внешнюю программную модель, а с помощью Foreign Function & Memory API — внешнюю среду выполнения. Используя их вместе, мы можем охватить внешний мир и организовать сложное взаимодействие между двумя мирами.
Включение API в статусе Incubator
Code Reflection — API в статусе Incubator, по умолчанию отключённый. Code Reflection предоставляется в Incubator-модуле jdk.incubator.code. Чтобы попробовать Code Reflection, необходимо запросить, чтобы Incubator-модуль jdk.incubator.code был разрешён (resolved):
- Скомпилируйте программу с
javac --add-modules jdk.incubator.code Main.javaи запустите её сjava --add-modules jdk.incubator.code Main; или - При использовании лаунчера исходного кода запускайте программу с
java --add-modules jdk.incubator.code Main.java; или - При использовании jshell запускайте его с
jshell --add-modules jdk.incubator.code.
Описание
Мы предлагаем расширить базовый API рефлексии механизмом Code Reflection. Code Reflection обеспечивает доступ во время выполнения к модели кода в методе или лямбда-выражении — модели кода (code model), — пригодной для анализа и преобразования.
С помощью Code Reflection библиотека может генерировать код на CUDA C из Java-кода. Вспомним пример с лямбда-выражением и потоком данных.
IntConsumer rgbToGray = i -> {
byte r = rgbImage[i * 3 + 0];
byte g = rgbImage[i * 3 + 1];
byte b = rgbImage[i * 3 + 2];
grayImage[i] = gray(r, g, b);
};
IntStream.range(0, N)
.parallel()
.forEach(rgbToGray);
Сначала мы объявляем лямбда-выражение рефлексируемым (reflectable) и тем самым открываем доступ к его коду. Для этого мы приводим лямбда-выражение к целевому интерфейсу, аннотированному @Reflect.
final byte[] rgbImage = ...
final float[] grayImage = ...
IntConsumer rgbToGray = (@Reflect IntConsumer) i -> {
byte r = rgbImage[i * 3 + 0];
byte g = rgbImage[i * 3 + 1];
byte b = rgbImage[i * 3 + 2];
grayImage[i] = gray(r, g, b);
};
Когда javac компилирует лямбда-выражение, он транслирует свою внутреннюю модель лямбда-выражения в стандартную модель — модель кода — и сохраняет модель кода в class-файле, связанном с class-файлом, который содержит байт-код скомпилированного лямбда-выражения. Модель кода — это неизменяемое дерево элементов кода, в котором, как правило, каждый элемент моделирует некоторую инструкцию или выражение Java.
Мы используем Code Reflection, чтобы получить доступ к модели кода лямбда-выражения; при этом загружается соответствующая модель кода, сохранённая в связанном class-файле.
var rgbToGrayModel = Op.ofLambda(rgbToGray).orElseThrow().op();
(Поскольку Code Reflection — это улучшение базового API рефлексии (core reflection API) в статусе Incubator, мы не можем добавлять новые API в пакеты других модулей, например в пакет java.lang.reflect модуля java.base. Пока что такие методы мы должны предоставлять в Incubator-модуле Code Reflection.)
Метод Op.ofLambda предоставляет доступ к модели кода для результата рефлексируемого лямбда-выражения, в данном случае экземпляра IntConsumer. По умолчанию лямбда-выражения не являются рефлексируемыми, поэтому мы возвращаем необязательное значение (optional). (Подробнее см. раздел Объявление рефлексируемого кода.)
Как выглядит модель кода лямбда-выражения? Чтобы получить некоторое представление о ней, мы можем преобразовать полученную модель кода в строку и вывести её. Ниже показана часть этой строки со встроенными комментариями, которые связывают элементы кода с моделируемыми ими элементами языка. Позже, в разделе Модели кода, мы объясним подробнее, детально рассмотрев модель кода метода gray.
%0 : java.type:"java.util.function.IntConsumer" = lambda
@lambda.isReflectable=true
(%1 : java.type:"int")java.type:"void" -> {
// declaration of method parameter i
%2 : Var<java.type:"int"> = var %1 @"i";
// access to captured rgbImage
%3 : java.type:"byte[]" = var.load %4;
// access to i
%5 : java.type:"int" = var.load %2;
// 3
%6 : java.type:"int" = constant @3;
// i * 3
%7 : java.type:"int" = mul %5 %6;
// 0
%8 : java.type:"int" = constant @0;
// i * 3 + 0
%9 : java.type:"int" = add %7 %8;
// rgbImage[i * 3 + 0]
%10 : java.type:"byte" = array.load %3 %9;
// byte r = rgbImage[i * 3 + 0]
%11 : Var<java.type:"byte"> = var %10 @"r";
...
// access to r
%31 : java.type:"byte" = var.load %11;
// conversion of byte to int
%32 : java.type:"int" = conv %31;
...
// gray(r, g, b)
%37 : java.type:"int" = invoke %32 %34 %36 @java.ref:"GPUExample::gray(int, int, int):int";
// conversion of int to float
%38 : java.type:"float" = conv %37;
// grayImage[i] = gray(r, g, b)
array.store %28 %30 %38;
// implicit return
return;
};
Получив модель кода Java, мы можем передать её нашей библиотеке для GPU.
String cudaCCode = translateJavaCodeToCudaCCode(rgbToGrayModel);
Библиотека для GPU с помощью Code Reflection обходит модель кода и транслирует её в код на CUDA C, встроенный в строку, — аналогичный тому, что мы ранее написали вручную, после чего пример, как и раньше, компилирует код на CUDA C и запускает его.
Обходя модель кода, библиотека для GPU встретит элемент, моделирующий выражение вызова метода gray. Этот метод тоже нужно транслировать в код на CUDA C, иначе мы сгенерируем неполную программу на CUDA. Однако встроенной поддержки этого метода в библиотеке нет. Библиотеке нужна модель кода этого метода, чтобы обойти и транслировать её так же, как это было сделано с моделью кода лямбда-выражения.
Для этого мы должны объявить метод gray тоже рефлексируемым и тем самым открыть доступ к его коду. Мы делаем это, также аннотировав наш метод аннотацией @Reflect.
@Reflect
static int gray(int r, int g, int b) {
return ...;
}
Обычно библиотека для GPU сама вызывает методы трансляции, компиляции и запуска от имени пользователя, так что пользователь просто вызывает всего один метод.
compileAndRun((@Reflect IntConsumer) i -> {
byte r = rgbImage[i * 3 + 0];
byte g = rgbImage[i * 3 + 1];
byte b = rgbImage[i * 3 + 2];
grayImage[i] = gray(r, g, b);
});
Так библиотека для GPU может скрыть детали трансляции и компиляции. Например, вместо трансляции в CUDA C библиотека может транслировать код в промежуточное представление SPIR-V — стандартный двоичный формат обмена для кроссплатформенных вычислений на GPU.
Что ещё важнее, библиотека для GPU может выбирать, где выполнять код. Когда производительность не важна, библиотеку можно настроить так, чтобы она напрямую вызывала лямбда-выражение в JVM, используя всего несколько платформенных потоков, например, внутри с помощью последовательного или параллельного стрима (stream). Тогда разработчик может использовать существующие инструменты Java для отладки своего кода и написания для него модульных тестов. После должной отладки и тестирования код можно запускать с большей уверенностью и производительностью на GPU, где отлаживать и тестировать код гораздо сложнее.
Объявление рефлексируемого кода
Выше мы показали, как объявить рефлексируемое лямбда-выражение с помощью аннотации @Reflect и получить доступ к его модели кода с помощью Code Reflection. Однако мы считаем, что объявлять рефлексируемый код лучше всего было бы с помощью нового ключевого слова языка Java, но Incubator-модуль не может вводить возможности языка. Поэтому пока достаточно аннотации. В каком-нибудь будущем JEP, уже не в статусе Incubator, мы, возможно, разработаем нужную возможность языка.
Объявление служит двум целям. Во-первых, мы явно разрешаем другим частям нашего Java-приложения, например библиотеке, за которую мы можем не отвечать напрямую, получать доступ к коду во время выполнения. Не весь код нужно рефлексировать, и не весь код следует, поэтому мы можем ограничиться только тем, чем необходимо поделиться. Во-вторых, объявление сообщает javac, что нужно выполнить дополнительные задачи, чтобы модель кода была построена и стала доступна во время выполнения.
Всего существует четыре синтаксических позиции, где может стоять @Reflect; они образуют три случая в порядке расширения области того, что объявляется рефлексируемым.
-
Если аннотация стоит в выражении приведения типа лямбда-выражения (или ссылки на метод), аннотируя использование типа в операторе приведения этого выражения, то лямбда-выражение (или ссылка на метод) и все содержащиеся в нём лямбда-выражения (и ссылки на методы) объявляются рефлексируемыми. Например,
compileAndRun((@Reflect IntConsumer) i -> { ... }); -
Если аннотация стоит как модификатор объявления поля или локальной переменной, аннотируя поле или локальную переменную, то все лямбда-выражения (или ссылки на методы) в выражении инициализатора переменной (если оно есть) объявляются рефлексируемыми. Это полезно, когда выражения приведения становятся многословными и/или о типах становится трудно рассуждать. Например, в fluent-выражениях наподобие стримов, где в качестве аргументов передаётся много рефлексируемых лямбда-выражений. Например,
@Reflect IntConsumer rgbToGray = i -> { ... }; compileAndRun(rgbToGray); -
Наконец, если аннотация стоит как модификатор объявления неабстрактного и не-native метода, аннотируя метод, то метод и все содержащиеся в нём лямбда-выражения (и ссылки на методы) объявляются рефлексируемыми. Например,
@Reflect static int gray(int r, int g, int b) { ... }
Если метод или лямбда-выражение (или ссылка на метод) объявлены рефлексируемыми, но не могут быть смоделированы из-за ограничений моделирования, то javac выдаёт предупреждение, и метод или лямбда-выражение (или ссылка на метод) не считаются рефлексируемыми.
Если аннотация стоит в любой другой допустимой синтаксической позиции, она игнорируется.
Объявление рефлексируемого лямбда-выражения или метода не расширяет неявно область рефлексируемого на вызываемые ими методы. (В нашем примере с GPU нам пришлось аннотировать метод gray.) Кроме того, объявление рефлексируемого лямбда-выражения расширяет эту область на окружающий код финальных или фактически финальных переменных, которые используются, но не объявлены в лямбда-выражении.
Мы получаем доступ к модели кода рефлексируемого метода, вызывая метод Op.ofMethod с заданным экземпляром Method; он возвращает необязательный (optional) экземпляр модели кода — элемент кода, моделирующий объявление метода (см. раздел Модели кода).
Мы получаем доступ к модели кода рефлексируемого лямбда-выражения, вызывая метод Op.ofLambda с заданным экземпляром функционального интерфейса, связанного с лямбда-выражением; он возвращает необязательный (optional) экземпляр Quoted<JavaOp.LambdaOp>. Из экземпляра Quoted мы можем получить модель кода — элемент кода, моделирующий лямбда-выражение. Кроме того, мы можем получить отображение элементов модели кода, которые моделируют финальные или фактически финальные переменные, используемые, но не объявленные в лямбда-выражении, на их значения времени выполнения.
Модели кода
Модель кода — это неизменяемый экземпляр структур данных, которые в общем случае могут моделировать множество видов кода, будь то код на Java или внешний код. У неё есть некоторые свойства абстрактного синтаксического дерева (AST), используемого компилятором исходного кода, таким как javac, — например, моделирование кода в виде дерева произвольной глубины, — и некоторые свойства промежуточного представления, используемого оптимизирующим компилятором, таким как HotSpot, — например, моделирование потока управления и потока данных в виде графов. Эти свойства гарантируют, что модели кода могут сохранять многие важные детали моделируемого кода и хорошо подходят для анализа и преобразования.
Модель кода Java, созданная javac, будет близка по точности к AST этого кода Java в javac. Эту модель можно постепенно понижать с помощью преобразований (сохраняя смысл моделируемого кода) до модели кода, близкой по точности к байт-коду или к IR компилятора C2 в HotSpot. Code Reflection предоставляет единый набор возможностей для поддержки моделей кода во всём спектре — от высокой точности до низкой.
Основная структура данных модели кода — дерево элементов кода. Существует три вида элементов кода: операция, тело и блок. Корень модели кода — операция, а операции-потомки образуют дерево произвольной глубины.
Code Reflection поддерживает представление структур данных модели кода, элементы кода для моделирования конструкций и поведения языка Java, обход моделей кода, построение моделей кода и преобразование моделей кода. Мы поясним это на дальнейших примерах.
Обход моделей кода
Продолжая пример с GPU, мы применим рефлексию к методу gray (снова приведённому вместе с реализацией), получим доступ к его модели кода и обойдём её, чтобы вывести древовидную структуру модели.
@Reflect
static int gray(int r, int g, int b) {
return (29 * r + 60 * g + 11 * b) / 100;
}
var grayMethod = GPUExample.class.getDeclaredMethod("gray",
int.class, int.class, int.class);
FuncOp grayModel = Op.ofMethod(grayMethod).orElseThrow();
(Для простоты мы предполагаем, что все байты массива rgbImage нормализованы и лежат в диапазоне от 0 до 100.)
Сначала мы получаем экземпляр Method базовой рефлексии для объявления метода gray, затем идём глубже и получаем доступ к модели кода метода с помощью Op.ofMethod (как объяснялось ранее). Корень модели кода — операция, экземпляр FuncOp, то есть операция объявления функции, моделирующая метод.
Затем с помощью метода CodeElement.elements мы можем получить стрим элементов модели кода, топологически отсортированных в порядке прямого обхода (pre-order).
grayModel.elements().forEach((CodeElement<?, ?> e) -> {
int depth = 0;
var parent = e;
while ((parent = parent.parent()) != null) depth++;
IO.println(" ".repeat(depth) + e.getClass());
});
Этот код выводит класс каждого встреченного элемента кода, добавляя перед ним пробелы пропорционально глубине элемента в дереве модели кода. (Глубину каждого элемента кода мы вычисляем, поднимаясь по дереву модели кода до корневого элемента. Таким образом, по дереву модели кода можно перемещаться как вверх, так и вниз.)
class jdk.incubator.code.dialect.core.CoreOp$FuncOp
class jdk.incubator.code.Body
class jdk.incubator.code.Block
class jdk.incubator.code.dialect.core.CoreOp$VarOp
class jdk.incubator.code.dialect.core.CoreOp$VarOp
class jdk.incubator.code.dialect.core.CoreOp$VarOp
class jdk.incubator.code.dialect.core.CoreOp$ConstantOp
class jdk.incubator.code.dialect.core.CoreOp$VarAccessOp$VarLoadOp
class jdk.incubator.code.dialect.java.JavaOp$MulOp
class jdk.incubator.code.dialect.core.CoreOp$ConstantOp
class jdk.incubator.code.dialect.core.CoreOp$VarAccessOp$VarLoadOp
class jdk.incubator.code.dialect.java.JavaOp$MulOp
class jdk.incubator.code.dialect.java.JavaOp$AddOp
class jdk.incubator.code.dialect.core.CoreOp$ConstantOp
class jdk.incubator.code.dialect.core.CoreOp$VarAccessOp$VarLoadOp
class jdk.incubator.code.dialect.java.JavaOp$MulOp
class jdk.incubator.code.dialect.java.JavaOp$AddOp
class jdk.incubator.code.dialect.core.CoreOp$ConstantOp
class jdk.incubator.code.dialect.java.JavaOp$DivOp
class jdk.incubator.code.dialect.core.CoreOp$ReturnOp
Мы видим, что на вершине дерева находится FuncOp с одним потомком — Body, который, в свою очередь, содержит одного потомка — Block, а тот содержит последовательность операций. Тела и блоки дают дополнительную структуру для моделирования кода. Каждая операция моделирует какую-то часть кода метода: например, операции объявления переменных (экземпляры VarOp) моделируют объявления переменных Java, в данном случае параметры метода, а операция сложения (экземпляр AddOp) моделирует оператор Java +.
Теперь, когда мы знаем, как получать доступ к моделям кода и обходить их, мы можем объединить одно с другим и перейти от модели кода лямбда-выражения к модели кода метода gray (как того требует библиотека для GPU).
final byte[] rgbImage = ...
final float[] grayImage = ...
@Reflect
IntConsumer rgbToGray = i -> {
byte r = rgbImage[i * 3 + 0];
byte g = rgbImage[i * 3 + 1];
byte b = rgbImage[i * 3 + 2];
grayImage[i] = gray(r, g, b);
};
Quoted<JavaOp.LambdaOp> rgbToGrayQuotedModel = Op.ofLambda(rgbToGray).orElseThrow();
JavaOp.LambdaOp rgbToGrayModel = rgbToGrayQuotedModel.op();
FuncOp grayModel = rgbToGrayModel.elements()
.flatMap((e) -> switch (e) {
case JavaOp.InvokeOp iop -> {
Method m;
try {
m = iop.invokeReference().resolveToMethod(MethodHandles.lookup());
} catch (ReflectiveOperationException ex) {
yield Stream.empty();
}
yield Op.ofMethod(m).stream();
}
default -> Stream.empty();
}).findFirst().orElseThrow();
Мы получаем доступ к модели кода лямбда-выражения с помощью Op.ofLambda (как объяснялось ранее); он возвращает экземпляр Quoted<LambdaOp>, содержащий модель кода. Модель кода — это операция, экземпляр LambdaOp, то есть операция лямбда-выражения, моделирующая лямбда-выражение. Экземпляр quoted также хранит значения времени выполнения финальных локальных переменных rgbImage и grayImage и связывает их с элементами модели кода, которые моделируют эти переменные. (Если бы вместо этого переменные были полями экземпляра, то экземпляр quoted хранил бы значение this.)
Выражение над стримом отображает операцию, моделирующую выражение вызова, — экземпляр InvokeOp — на модель кода вызываемого ею метода и возвращает первую встреченную модель. Для этого мы разрешаем описание вызываемого метода, содержащееся в операции вызова, в экземпляр Method, из которого можем получить его модель кода. (Мы предполагаем, что экземпляр MethodHandles.lookup(), переданный как аргумент в resolveToMethod, даёт разрешение на это.)
Чтобы лучше понять модель кода метода grayModel, мы можем преобразовать её в строку и вывести на печать.
IO.println(grayModel.toText());
(Метод toText обходит элементы кода примерно так же, как это делали мы ранее с помощью метода elements.)
36 ....@Reflect
37 ....static int gray(int r, int g, int b) {
38 .... return (29 * r + 60 * g + 11 * b) / 100;
39 ....}
func @loc="36:5:file:/.../GPUExample.java"
@func.source=java.ref:"GPUExample::gray(int, int, int):int"
(%0 : java.type:"int", %1 : java.type:"int", %2 : java.type:"int")java.type:"int" -> {
%3 : Var<java.type:"int"> = var %0 @loc="36:5" @"r";
%4 : Var<java.type:"int"> = var %1 @loc="36:5" @"g";
%5 : Var<java.type:"int"> = var %2 @loc="36:5" @"b";
%6 : java.type:"int" = constant @loc="38:17" @29;
%7 : java.type:"int" = var.load %3 @loc="38:22";
%8 : java.type:"int" = mul %6 %7 @loc="38:17";
%9 : java.type:"int" = constant @loc="38:26" @60;
%10 : java.type:"int" = var.load %4 @loc="38:31";
%11 : java.type:"int" = mul %9 %10 @loc="38:26";
%12 : java.type:"int" = add %8 %11 @loc="38:17";
%13 : java.type:"int" = constant @loc="38:35" @11;
%14 : java.type:"int" = var.load %5 @loc="38:40";
%15 : java.type:"int" = mul %13 %14 @loc="38:35";
%16 : java.type:"int" = add %12 %15 @loc="38:17";
%17 : java.type:"int" = constant @loc="38:45" @100;
%18 : java.type:"int" = div %16 %17 @loc="38:16";
return %18 @loc="38:9";
};
Текст модели кода рассчитан на чтение человеком. Его формат не специфицирован и предназначен для отладки, тестирования и понимания. Для дальнейшего облегчения отладки у каждой операции есть информация о номере строки, а у корневой операции есть также информация об источнике, из которого получена модель кода.
Текст модели кода показывает, что корневой элемент модели кода — операция объявления функции (func). Выражение, похожее на лямбда-выражение, представляет собой слияние единственного тела операции объявления функции и первого и единственного блока этого тела, называемого входным блоком. Далее во входном блоке идёт последовательность операций. Каждой операции соответствует экземпляр соответствующего Java-класса; все они являются экземплярами интерфейса jdk.incubator.code.Op, и мы уже видели их, когда выводили на печать классы. Неудивительно, что напечатанные операции и напечатанные классы операций идут в одном и том же порядке, поскольку метод toText обходит модель в том же порядке, в каком мы обходили её явно.
Входной блок объявляет три значения, называемые параметрами блока, — %0, %1 и %2; они моделируют начальные значения параметров метода r, g и b. Сосредоточившись на параметре блока %0, мы можем проследить его транзитивные зависимости: он используется как операнд операции, которая порождает значение, называемое результатом операции, а тот используется как операнд следующей операции, и так далее, пока мы не дойдём до операции return.
func @loc="36:5:file:/.../GPUExample.java"
@func.source=java.ref:"GPUExample::gray(int, int, int):int"
(%0 : java.type:"int", %1 : java.type:"int", %2 : java.type:"int")java.type:"int" -> {
// declaration of method parameter r
%3 : Var<java.type:"int"> = var %0 @loc="36:5" @"r";
...
// access to r
%7 : java.type:"int" = var.load %3 @loc="38:22";
// 29 * r
%8 : java.type:"int" = mul %6 %7 @loc="38:17";
...
// (29 * r + 60 * g)
%12 : java.type:"int" = add %8 %11 @loc="38:17";
...
// (29 * r + 60 * g + 11 * b)
%16 : java.type:"int" = add %12 %15 @loc="38:17";
...
// (29 * r + 60 * g + 11 * b) / 100
%18 : java.type:"int" = div %16 %17 @loc="38:16";
return %18 @loc="38:9";
};
Объявление параметра r моделируется вложенной операцией var, инициализированной параметром блока %0, который используется как единственный операнд операции var. Результат операции, %3, моделирует параметр как значение переменной. Значение переменной можно загружать и сохранять с помощью операций доступа к переменным, которые моделируют соответственно выражение, обозначающее переменную, и присваивание переменной. Выражение, обозначающее параметр r, моделируется операцией var.load, которая использует %3 как операнд. Результат операции, %7, моделирующий текущее значение r, используется операцией mul, и так далее. Наконец, результат операции div, моделирующей оператор /, — %18 — используется операцией return, моделирующей оператор return.
Исходный код нашего метода может содержать всевозможные синтаксические детали, которые javac представит в своей внутренней модели, но которые для рефлексии кода являются посторонними. В модели кода этой сложности нет. Например, та же самая модель кода была бы получена, если бы подвыражения в операторе return были явно сгруппированы, например (((29 * r) + (60 * g)) + (11 * b)) / (100).
Помимо элементов кода, образующих дерево, модель кода содержит и другие единицы кода — введённые ранее значения (параметры блоков или результаты операций), которые образуют двунаправленные графы зависимостей между своим объявлением и использованием. У значения также есть тип кода — ещё одна единица кода, моделирующая классификацию значений. В нашем примере многие типы кода моделируют типы Java, а некоторые — тип значений переменных (элемент типа результата операции var).
Внимательный читатель может заметить, что модели кода находятся в форме статического единственного присваивания (Static Single-Assignment, SSA) и в них нет явного различия, какое есть в исходном коде, между операторами и выражениями. Параметры блоков и результаты операций объявляются до использования и не могут быть переприсвоены (поэтому нам нужны специальные операции и типы кода для моделирования переменных, как мы показали ранее).
Наконец, мы можем выполнить модель кода, преобразовав её в байт-код, обернув его в дескриптор метода (method handle) и вызвав этот дескриптор.
var handle = BytecodeGenerator.generate(MethodHandles.lookup(), grayModel);
assert GPUExample.gray(32, 32, 32) == (int) handle.invokeExact(32, 32, 32);
Построение моделей кода
Построение моделей кода — важная возможность рефлексии кода, которая используется во многих других областях. Например, javac использует эту возможность для построения моделей рефлексируемого кода, а среда выполнения — при обращении к этим же моделям. Позже мы увидим, как построение комбинируется для поддержки преобразования моделей кода.
Мы можем написать код на Java, который строит модель кода, эквивалентную той, к которой мы ранее получали доступ и которую обходили (она была построена для нас во время компиляции и во время выполнения).
var builtGrayModel = func(
"gray",
CoreType.functionType(INT, INT, INT, INT))
.body((Block.Builder bldr) -> {
// int r
Op.Result varR = bldr.add(var("r", bldr.parameters().get(0)));
// int g
Op.Result varG = bldr.add(var("g", bldr.parameters().get(1)));
// int b
Op.Result varB = bldr.add(var("b", bldr.parameters().get(2)));
// (((29 * r) + (60 * g)) + 11 * b)
var sum = bldr.add(add(
bldr.add(add(
bldr.add(mul(
bldr.add(constant(INT, 29)),
bldr.add(varLoad(varR)))),
bldr.add(mul(
bldr.add(constant(INT, 60)),
bldr.add(varLoad(varG)))))),
bldr.add(mul(
bldr.add(constant(INT, 11)),
bldr.add(varLoad(varB))))));
// return (...) / 100;
bldr.add(return_(
bldr.add(div(
sum,
bldr.add(constant(INT, 100))))));
});
Лямбда-выражение-потребитель, переданное в метод body, работает с построителем блока — экземпляром Block.Builder, — представляющим строящийся входной блок. С его помощью мы добавляем операции во входной блок. При добавлении операция порождает результат операции, который можно использовать как операнд следующей операции, и так далее. Можно строить полные выражения в текучем стиле как деревья выражений, а можно строить отдельные подвыражения. В приведённом выше примере мы выделили построение подвыражения, соответствующего числителю, и использовали результат этого подвыражения, sum, как первый операнд операции div. Будет построена одна и та же модель кода независимо от того, строятся ли выражения в текучем стиле или по отдельности, как отдельные операторы (при условии, что сохраняется порядок вычисления слева направо в выражении оператора return).
Когда метод body возвращает управление, элемент тела и содержащийся в нём элемент входного блока будут полностью построены. Построение тщательно спроектировано так, чтобы структурно некорректные модели построить было невозможно.
Мы не ожидаем, что большинство пользователей будут часто строить полные модели кода на Java, поскольку это довольно многословный и утомительный процесс, хотя, возможно, и менее утомительный, чем другие подходы, например построение байт-кода или использование комбинаторов дескрипторов методов. Полные модели скорее будут строить инструменты, такие как javac, и поскольку он очень хорошо строит модели кода, его можно использовать для этого во многих целях. Мы ожидаем, что вместо этого многие пользователи будут строить части моделей, когда преобразуют их.
Преобразование моделей кода
Code Reflection поддерживает преобразование моделей кода, сочетая обход и построение. Преобразование модели кода представлено функцией, которая принимает операцию, встреченную в преобразуемой (входной) модели, и построитель модели кода для результирующей преобразованной (выходной) модели и определяет, как эта операция преобразуется (если вообще преобразуется) в другие строящиеся элементы кода. Нас вдохновил функциональный подход к преобразованию, разработанный в Class-File API, и мы адаптировали этот дизайн для работы с древовидной структурой (неизменяемых) моделей кода.
Мы можем написать простое преобразование модели кода, которое преобразует модель кода нашего метода gray, заменяя операцию, моделирующую оператор +, операцией вызова, моделирующей выражение вызова метода Integer.sum.
MethodRef SUM = MethodRef.method(Integer.class, "sum", int.class,
int.class, int.class);
CodeTransformer grayToMethodTransformer = CodeTransformer.opTransformer((
Function<Op, Op.Result> builder,
Op inputOp,
List<Value> outputOperands) -> {
switch (inputOp) {
// Replace a + b; with Integer.sum(a, b);
case AddOp _ -> builder.apply(invoke(SUM, outputOperands));
// Copy operation
default -> builder.apply(inputOp);
}
});
Функция преобразования кода, переданная как лямбда-выражение в CodeTransformer.opTransformer, принимает в качестве параметров функцию-построитель блока builder, операцию inputOp, встреченную при обходе входной модели кода, и outputOperands — список текущих сопоставленных выходных значений, соответствующих операндам входной операции. В этом примере входные операции, порождающие операнды найденной операции add, уже были встречены и скопированы, поэтому соответствующие выходные операнды сопоставлены до того, как преобразуется операция add.
В преобразовании кода мы выполняем switch по входной операции, и в данном случае сопоставляем только операцию add и, по умолчанию, любую другую операцию. Во втором случае мы применяем входную операцию к функции-построителю, которая создаёт новую выходную операцию — копию входной операции, — добавляет новую операцию в строящийся блок и связывает результат новой операции с результатом входной операции. Когда мы сопоставляем операцию add, мы заменяем её, строя часть модели кода — операцию invoke вызова метода Integer.sum, сконструированную с заданными выходными операндами. Результат выходной операции invoke автоматически связывается с результатом входной операции add.
Затем мы можем преобразовать модель кода метода, вызвав метод FuncOp.transform с преобразователем кода в качестве аргумента.
FuncOp transformedGrayModel = grayModel.transform(grayToMethodTransformer);
IO.println(transformedGrayModel.toText());
Преобразованная модель кода, естественно, очень похожа на входную модель кода.
func @loc="36:5:file:/.../GPUExample.java"
@func.source=java.ref:"GPUExample::gray(int, int, int):int"
(%0 : java.type:"int", %1 : java.type:"int", %2 : java.type:"int")java.type:"int" -> {
%3 : Var<java.type:"int"> = var %0 @loc="36:5" @"r";
%4 : Var<java.type:"int"> = var %1 @loc="36:5" @"g";
%5 : Var<java.type:"int"> = var %2 @loc="36:5" @"b";
%6 : java.type:"int" = constant @loc="38:17" @29;
%7 : java.type:"int" = var.load %3 @loc="38:22";
%8 : java.type:"int" = mul %6 %7 @loc="38:17";
%9 : java.type:"int" = constant @loc="38:26" @60;
%10 : java.type:"int" = var.load %4 @loc="38:31";
%11 : java.type:"int" = mul %9 %10 @loc="38:26";
%12 : java.type:"int" = invoke %8 %11 @java.ref:"java.lang.Integer::sum(int, int):int";
%13 : java.type:"int" = constant @loc="38:35" @11;
%14 : java.type:"int" = var.load %5 @loc="38:40";
%15 : java.type:"int" = mul %13 %14 @loc="38:35";
%16 : java.type:"int" = invoke %12 %15 @java.ref:"java.lang.Integer::sum(int, int):int";
%17 : java.type:"int" = constant @loc="38:45" @100;
%18 : java.type:"int" = div %16 %17 @loc="38:16";
return %18 @loc="38:9";
};
Видно, что две операции add заменены двумя операциями invoke. Кроме того, по умолчанию каждая скопированная операция сохраняет информацию о номере строки. Это преобразование кода можно также без изменений применить к модели кода нашего лямбда-выражения или к более сложным моделям, содержащим много операторов + в произвольно вложенных позициях.
Функция преобразования кода не является прямой реализацией функционального интерфейса CodeTransformer. Вместо этого мы выполнили адаптацию из другого функционального интерфейса, который проще реализовать для более простых преобразований операций. Прямые реализации CodeTransformer сложнее, но способны и на более сложные преобразования, например построение новых блоков и более полный контроль над связыванием единиц во входной и выходной моделях. Code Reflection предоставляет много сложных преобразователей кода, например для постепенного понижения уровня моделей кода, приведения моделей к чистой SSA-форме и встраивания одних моделей в другие. Мы продолжим исследовать дизайн преобразования моделей кода, чтобы лучше понять, как улучшить его во всём спектре от простых до сложных преобразований.
Дальнейшая работа
Мы исследуем доступ к моделям кода во время компиляции. Code Reflection предоставляет самую базовую поддержку доступа обработчиков аннотаций к моделям кода элементов программы, и, хотя она полезна для продвинутых экспериментов, она требует дополнительного рассмотрения.
По мере развития языка мы будем искать возможности улучшить рефлексию кода так, чтобы она использовала новые возможности языка, особенно связанные с Pattern Matching (сопоставление с образцом) и программированием, ориентированным на данные. Мы ожидаем, что Pattern Matching сильно повлияет на рефлексию кода и улучшит запросы к моделям кода. Кроме того, это возможность дать обратную связь о возможностях языка.
Нам нужно исследовать возможность языка для объявления рефлексируемого кода. Использование аннотации @Reflect — временное решение, достаточное для этапа Incubator, но недостаточное для статуса Preview (предварительная версия).
Нам нужно обеспечить, чтобы библиотека, использующая рефлексию кода, могла работать с моделями кода, созданными версией JDK новее той, с которой она была скомпилирована. Такая прямая совместимость — сложная задача. Мы исследуем решения, например объявление библиотекой верхней границы версий JDK поддерживаемого ею рефлексивного кода или возможность понижать моделируемую возможность языка, которую библиотека не способна обработать, до моделируемых возможностей, которые она обработать может (возможно, в ущерб высокой точности, но с сохранением смысла программы).
Альтернативы
Compiler Tree API
Пакет com.sun.source модуля jdk.compiler содержит javac API для доступа к абстрактным синтаксическим деревьям (AST), представляющим исходный код на Java. Javac использует реализацию этого API при разборе исходного кода. Этот API не подходит для стандартизации, поскольку он слишком тесно переплетён с реализацией javac, так как javac оставляет за собой право вносить в этот API несовместимые изменения по мере развития языка. В более общем плане AST бывает трудно анализировать и преобразовывать. Например, современный оптимизирующий компилятор преобразует своё AST, представляющее исходный код, в другую, чуть более низкоуровневую форму — промежуточное представление, — которую проще анализировать и преобразовывать в исполняемый код.
Байт-код
Байт-код нелегко получить во время выполнения, и его доступность в это время не гарантирована; но даже если бы мы сделали его доступным, это не было бы идеальным решением. При трансляции исходного кода на Java в байт-код с помощью javac многие возможности языка Java исчезают, и восстановить их трудно: например, лямбда-выражения транслируются в инструкции invoke dynamic и синтетические методы. Кроме того, байт-код по умолчанию слишком низкоуровневый, поэтому его трудно анализировать и преобразовывать. Например, компилятор HotSpot C2 преобразует байт-код в другую, более высокоуровневую форму — промежуточное представление, — которую проще анализировать и преобразовывать в исполняемый код.
Деревья выражений C#
Деревья выражений C# представляют код в виде древовидной структуры данных, каждый узел которой является выражением. Компилятор C# поддерживает объявление выразимого кода на C#, чтобы можно было получить доступ к дереву выражений этого кода. Деревья выражений можно также строить напрямую.
У деревьев выражений C# много общего с Code Reflection. Однако C# может моделировать лишь ограниченный набор операторов и выражений C#, и поэтому набор кода на C#, к которому можно получить доступ в виде деревьев выражений, тоже ограничен. Code Reflection может моделировать почти все операторы и выражения Java и поэтому может получить доступ в виде моделей кода к более широкому спектру кода на Java. Кроме того, модели кода лучше подходят для анализа и преобразования, поскольку сочетают в себе свойства моделей, которые используют компиляторы исходного кода и оптимизирующие компиляторы.
Возможность языка C# для объявления выразимого кода превосходит используемый в Code Reflection способ объявлять рефлексируемый код с помощью аннотации @Reflect. Разрабатывая аналогичную возможность языка Java, мы можем поучиться у C#.
Тестирование
Тестирование будет сосредоточено на наборе модульных тестов для компилятора и среды выполнения, которые обеспечивают высокое покрытие моделирования и покрытие кода. Где возможно, мы стараемся повторно использовать возможности Code Reflection в работе, например при сохранении моделей в class-файлах и загрузке их оттуда.
Мы должны гарантировать, что модели кода Java, создаваемые компилятором, сохраняют смысл программы на Java. Мы выберем существующий набор тестов Java и перекомпилируем исходный код, который они тестируют, с помощью специального внутреннего флага javac, так чтобы байт-код генерировался из моделей кода, созданных компилятором. Тестирование этих специально скомпилированных исходников должно давать те же результаты, что и тестирование исходников, скомпилированных обычным образом.
Риски и допущения
Пока возможность находится в статусе Incubator, мы будем стремиться свести к минимуму число изменений, которые требуется внести в код модулей java.base и jdk.compiler, чтобы уменьшить нагрузку на сопровождающих и рецензентов. Пока изменения скромные.
Добавление новой возможности языка, даже скромной, требует значительных усилий и множества задач по обновлению многих областей платформы. Code Reflection пополнит этот список задач, поскольку новую возможность языка нужно будет моделировать и поддерживать так же, как уже моделируемые возможности. Есть риск, что её моделирование, особенно с высокой точностью, потребует значительных усилий. Мы считаем, что этот риск снижается благодаря универсальным возможностям моделирования, которые дают модели кода, и что уже сейчас мы можем моделировать все операторы и выражения Java с высокой точностью.