Как сделать ссылки на методы дружелюбными для отладки

В Java 8 появилось два вида функциональных выражений — лямбда-выражения вида s -> System.out.println(s) и ссылки на методы вида System.out::println. Поначалу ссылки на методы вызывали больше энтузиазма: они часто компактнее, вам не требуется придумывать имя для переменной, а ещё старожилы говорят, что они несколько оптимальнее, чем лямбда-выражения. Однако со временем энтузиазм ослаб. Одна из проблем со ссылками на методы — затруднённая отладка ошибок.

Давайте напишем простую программу, где исключение пролетает через функциональное выражение. Например, так:

import java.util.Objects;
import java.util.function.Consumer;

public class Test {
  public static void main(String[] args) {
    Consumer consumer = obj -> Objects.requireNonNull(obj);

    consumer.accept(null);
  }
}

Запускать я буду на ранних сборках Java 17, которая уже скоро выйдет. Запускаем и видим:

Exception in thread "main" java.lang.NullPointerException
    at java.base/java.util.Objects.requireNonNull(Objects.java:208)
    at Test.lambda$main$0(Test.java:6)
    at Test.main(Test.java:8)

Перед вами хороший stack trace. В нём есть как точка вызова функции (Test.java:8), так и точка её определения (Test.java:6). Также пошаговый отладчик позволяет вам зайти внутрь лямбды:

image-loader.svg

Давайте теперь заменим лямбду на ссылку на метод:

  public static void main(String[] args) {
    Consumer consumer = Objects::requireNonNull;

    consumer.accept(null);
  }

Запускаем снова и видим:

Exception in thread "main" java.lang.NullPointerException
    at java.base/java.util.Objects.requireNonNull(Objects.java:208)
    at Test.main(Test.java:8)

Ой, у нас проблема. Мы больше не видим в трейсе точку создания функции. В отличие от лямбды, для ссылки на метод не генерируется синтетический метод в классе, поэтому некуда подписать отладочную информацию о номере строки.

Кому-то может показаться, что проблема невелика, но в больших программах точка создания и точка вызова функции могут быть в кардинально разных местах, и отсутствие информации о том, где же создана функция, может существенно усложнить диагностику ошибки. Аналогичная проблема в пошаговом отладчике: даже если вы воспользуетесь Force step into, вы никогда не попадёте на строчку Objects: requireNonNull:

image-loader.svg

Вместо этого вы сразу же попадёте внутрь вызываемого метода Objects: requireNonNull. Потому что к моменту вызова функции виртуальная машина уже совершенно не в курсе, где функция была создана. А мало ли сколько у вас ссылок на этот метод в программе, замучаешься все искать!

Вот было бы здорово создать какой-то промежуточный фрейм в стеке и прикрутить к нему нужную отладочную информацию. Погодите, но у нас уже есть промежуточный фрейм! Видите серенькую строчку accept:-1, Test$$Lambda$14/0x0000000800c02508 в отладчике? Вот это он.

Дело в том, что для адаптации функции к функциональному интерфейсу, рантайм Java генерирует маленький классик, который собственно реализует наш интерфейс. Генерация выполняется в методе InnerClassLambdaMetafactory: generateInnerClass. По идее можно пропатчить это место и добавить в этот фрейм отладочную информацию. Но откуда её взять? Очень просто: когда вызывается генерация синтетического класса, текущий стек-трейс содержит всё что нам надо. Чтобы убедиться в этом, достаточно поставить туда breakpoint:

image-loader.svg

Видите, там всякий внутренний ад, потом «linkCallSite:271, MethodHandleNatives», а после этого уже нужная нам шестая строчка в методе main. Как вытащить эту информацию во время исполнения? Есть модный StackWalker API, который удобный, современный и быстрый. Одна проблема: он требует Stream API, а Stream API создаёт какие-то функции внутри, а функции вызывают InnerClassLambdaMetafactory. Если вы попробуете это сделать, вы получите StackOverflowError на этапе инициализации JVM. Возможно, есть способ обойти эту проблему, например, используя внутренний API Reflection::getCallerClass, чтобы запретить обход стека для функций стандартной библиотеки. Но мы поступим просто по старинке, через new Exception().getStackTrace(). Это может быть медленнее, но мы помним, что бутстрап-метод вызывается только один раз на каждую функцию в исходниках, поэтому горячий код нисколько не пострадает. Напишем что-нибудь такое (эх, без Stream API как без рук):

private static StackTraceElement getCallerFrame() {
    StackTraceElement[] trace = new Exception().getStackTrace();
    for (int i = 0; i < trace.length - 1; i++) {
        StackTraceElement ste = trace[i];
        if (ste.getClassName().equals("java.lang.invoke.MethodHandleNatives") &&
            ste.getMethodName().equals("linkCallSite")) {
            return trace[i + 1];
        }
    }
    return null;
}

Вернём null, если что-нибудь пошло не так. В этом случае не стоит ломать программу, можно просто вести себя как раньше.

Прекрасно, информацию мы получили. Как её теперь впихнуть в генерируемый класс? Тут хорошая новость: для генерации класса используется старый добрый ASM, подпакованный внутрь JDK. Поэтому всё делается на раз-два. Например, чтобы задать имя файла, надо написать лишь:

StackTraceElement ste = getCallerFrame();
if (ste != null) {
    cw.visitSource(ste.getFileName(), null);
}

С номером строчки чуть больше возни: надо передать её в ForwardingMethodGenerator: generate, там создать в начале метода метку и добавить строчку в таблицу номеров строк:

Label start = new Label();
visitLabel(start);
...
if (lineNumber >= 0) {
    visitLineNumber(lineNumber, start);
}

Вот, собственно, и всё. Весь патч целиком можно взять тут и приложить его к коду OpenJDK (ревизия 57611b30 на момент написания статьи). Этот файл можно отдельно скомпилировать с помощью Java 17:

"C:\Program Files\Java\jdk-17\bin\javac.exe" -Xlint:all --patch-module java.base=src/ -d mypatch src/java/lang/invoke/*

Мы получим пропатченные класс-файлы в каталоге mypatch. Затем надо запускать приложение с опцией --patch-module java.base=mypatch.

Проверяем пошаговый отладчик:

image-loader.svg

Ура, Force Step Into нас действительно привёл в нужное место! Теперь у метода accept светится номер строки 6, чего мы и добивались! Правда у IDEA немного поехала крыша, потому что она не поняла, где это мы оказались. В результате она решила, что аргумент функции null — это параметр метода main args. Но это нестрашно, можно игнорировать. Да и при желании среду разработки тоже можно научить распознавать такие фреймы. Главное, что теперь заходя в вызов ссылки на метод, мы можем узнать, где она определена.

Что же со стек-трейсом при исключении? К сожалению, там всё то же. Дело в том, что сгенерированный класс-адаптер — это весьма специальный «скрытый» класс. В числе прочего, фреймы из скрытых классов не показываются по умолчанию в стек-трейсах. Включить их можно через опцию виртуальной машины -XX:+UnlockDiagnosticVMOptions -XX:+ShowHiddenFrames. Тогда мы действительно увидим нужный нам фрейм:

Exception in thread "main" java.lang.NullPointerException
    at java.base/java.util.Objects.requireNonNull(Objects.java:208)
    at Test$$Lambda$28/0x00000007c00c0880.accept(Test.java:6)
    at Test.main(Test.java:8)

Кажется, никому особо не повредит, если эту опцию держать включенной на продакшне. Ну станут стектрейсы в логах немного длиннее, зато и полезнее! Вообще, конечно, классно, что сейчас всё больше внутренних вещей в Java runtime пишется на самой Java. В результате, чтобы сделать такой патч, не надо залезать в страшный C++ и пересобирать виртуальную машину полностью. Достаточно пересобрать один класс.

Понятно, что никто в здравом уме не примет такой патч в OpenJDK, я даже пытаться не буду. Но никто не мешает сделать это у себя локально. Конечно, я не даю никаких гарантий, что оно будет правильно работать у вас!

© Habrahabr.ru