В Java, как известно, inline-методов нет. Но такое понятие существует в других языках, исполняющихся на JVM. Например, в Scala или Kotlin. Во время компиляции вызов такого метода заменяется на его тело, как если бы разработчик написал этот код вручную.
Прекрасный инструмент для добавления синтаксического сахара и создания проблемно-ориентированных языков (DSL) малой ценой, но как это всё отлаживать?
С тем, какие ухищрения помогают не замечать расхождения исходного текста программы и её байткода во время отладки и предлагаю разобраться.
В этом сезоне в моде Kotlin, так что рассмотрим на его примере. Для экспериментов возьмём простейший, всего 17 строк, пример с вызовом inline-функции, принимающей лямбду в качестве параметра.
fun main() {
println(">> main()")
inlineMethod(true) {
println("\t\tLambda function executed.")
}
println("<< main()")
}
inline fun inlineMethod(flag: Boolean, body: () -> Unit) {
println("\t>> inlineMethod()")
if (flag) {
body()
}
println("\t<< inlineMethod()")
}
Дизассемблировав class-файл примера можно убедиться, что и inline-метод и переданная ему параметром лямбда действительно были встроены в тело метода
main()
. Для удобства чтения инструкции байткода приведены как комментарии к строкам исходного кода на Kotlin.fun main() {
println(">> main()")
// 0: ldc #8 // String >> main()
// 2: getstatic #14 // Field java/lang/System.out:Ljava/io/PrintStream;
// 5: swap
// 6: invokevirtual #20 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
inlineMethod(true) {
// 9: iconst_1
// 10: istore_0
// 11: iconst_0
// 12: istore_1
println("\t>> inlineMethod()")
// 13: ldc #22 // String \t>> inlineMethod()
// 15: getstatic #14 // Field java/lang/System.out:Ljava/io/PrintStream;
// 18: swap
// 19: invokevirtual #20 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
if (flag) {
// 22: nop
body()
// 23: iconst_0
// 24: istore_2
println("\t\tLambda function executed.")
// 25: ldc #24 // String \t\tLambda function executed.
// 27: getstatic #14 // Field java/lang/System.out:Ljava/io/PrintStream;
// 30: swap
// 31: invokevirtual #20 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
}
// 34: nop
// 35: nop
println("\t<< inlineMethod()")
// 36: ldc #26 // String \t<< inlineMethod()
// 38: getstatic #14 // Field java/lang/System.out:Ljava/io/PrintStream;
// 41: swap
// 42: invokevirtual #20 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
}
// 45: nop
println("<< main()")
// 46: ldc #28 // String << main()
// 48: getstatic #14 // Field java/lang/System.out:Ljava/io/PrintStream;
// 51: swap
// 52: invokevirtual #20 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
}
// 55: return
Самое время проверить, как с этим справится отладчик.
JDB, консольный отладчик из состава JDK, совершенно ничего не знает ни про Kotlin, ни про
Запустим отладку.
jdb -classpath … -sourcepath … SimpleInlineKt
Initializing jdb ...
> stop at SimpleInlineKt:3
Deferring breakpoint SimpleInlineKt:3.
It will be set after the class is loaded.
> run
run SimpleInlineKt
Set uncaught java.lang.Throwable
Set deferred uncaught java.lang.Throwable
>
VM Started: Set deferred breakpoint SimpleInlineKt:3
Breakpoint hit: "thread=main", SimpleInlineKt.main(), line=3 bci=0
3 println(">> main()")
main[1] step
>
>> main()
Step completed: "thread=main", SimpleInlineKt.main(), line=4 bci=9
4 inlineMethod(true) {
main[1] step
>
Step completed: "thread=main", SimpleInlineKt.main(), line=11 bci=13
11 println("\t>> inlineMethod()")
main[1] step
>
>> inlineMethod()
Step completed: "thread=main", SimpleInlineKt.main(), line=12 bci=22
12 if (flag) {
main[1] step
>
Step completed: "thread=main", SimpleInlineKt.main(), line=13 bci=23
13 body()
main[1] step
>
Step completed: "thread=main", SimpleInlineKt.main(), line=5 bci=25
5 println("\t\tLambda function executed.")
main[1] step
>
Lambda function executed.
Step completed: "thread=main", SimpleInlineKt.main(), line=6 bci=34
6 }
main[1] step
>
Step completed: "thread=main", SimpleInlineKt.main(), line=13 bci=35
13 body()
main[1] step
>
Step completed: "thread=main", SimpleInlineKt.main(), line=15 bci=36
15 println("\t<< inlineMethod()")
main[1] step
>
<< inlineMethod()
Step completed: "thread=main", SimpleInlineKt.main(), line=16 bci=45
16 }
main[1] step
>
Step completed: "thread=main", SimpleInlineKt.main(), line=7 bci=46
7 println("<< main()")
main[1] step
>
<< main()
Step completed: "thread=main", SimpleInlineKt.main(), line=8 bci=55
8 }
main[1] step
>
Step completed: "thread=main", SimpleInlineKt.main(), line=-1 bci=3
main[1] step
>
The application exited
Если оставить за скобками различия в интерфейсе, то всё работает точно так же, как и в IDE от JetBrains. Мы посещаем правильные строки, в правильном порядке. Inline-методы отладчику никак не мешают.
Глубже в class-файл
Как показал эксперимент с jdb, вся нужная для отладки информация уже содержится в class-файле. Изучим его пристальнее, стандартного дизассемблера javap будет достаточно.
Выполнив команду
javap -c -v SimpleInlineKt.class
помимо байткода, который мы уже видели ранее, мы обнаружим некоторые интересные особенности.Первой будет странная таблица номеров строк:
LineNumberTable:
line 3: 0
line 4: 9
line 18: 13
line 19: 22
line 20: 23
line 5: 25
line 6: 34
line 20: 35
line 22: 36
line 23: 45
line 7: 46
line 8: 55
Эта таблица задаёт соответствие между смещением в байткоде и номером строки. Как можно заметить, номера строк в ней идут не совсем по порядку, что странно. Между четвёртой и пятой появились строки 18, 19 и 20, а между шестой и седьмой — строки 20, 22 и 23.
Вторая особенность — аттрибут
SourceDebugExtension
:SourceDebugExtension:
SMAP
SimpleInline.kt
Kotlin
*S Kotlin
*F
+ 1 SimpleInline.kt
SimpleInlineKt
*L
1#1,17:1
11#1,6:18
*S KotlinDebug
*F
+ 1 SimpleInline.kt
SimpleInlineKt
*L
4#1:18,6
*E
Именно этот аттрибут — ключ к разгадке.
Он был разработан в рамках JSR-045: Debugging Support for Other Languages («поддержка отладки для других языков»). В первую очередь, конечно же, это делалось ради Java2EE и Java Server Pages, помните такие аббревиатуры?
При его помощи можно задать правила перевода номеров строк, записанных в class-файле в пары (имя файла, новый номер строки).
Сначала отладчик по смещению исполняемой инструкции байтода определяет номер текущей строки. Затем, используя информацию из аттрибута
SourceDebugExtension
, этот номер преобразуется в пару (имя файла, номер строки). И уже эту строку отладчик показывает пользователю как текущую.Как кто-то сказал, «все проблемы в программировании решаются путём создания дополнительного уровня косвенности».
Рассмотрим на примере
Вначале идёт заголовок — сигнатура
SMAP
, имя исходного файла, имя набора правил по умолчанию. В терминах JSR-045 набор правил называется «слой» (Stratum). SMAP
SimpleInline.kt
Kotlin
Затем идёт декларация слоя с именем «Kotlin».
*S Kotlin
Наш inline-метод определён в том же файле, что и используется и потому в секции файлов у нас ровно одна запись, назначающая файлу
SimpleInline.kt
идентификатор 1
. *F
+ 1 SimpleInline.kt
SimpleInlineKt
В секции номеров строк у нас две записи:
*L
1#1,17:1
11#1,6:18
- Первые 17 строк отображаются с номеров строк в class-файле на номера строк в исходном файле один-к-одному.
- «Виртуальные» строки в class-файле с 18 по 23 отображаются на строки декларации inline-метода в исходном файле
На этом декларация слоя заканчивается.
Дальше идёт дополнительный слой
KotlinDebug
, используемый в IntelliJ IDEA для реконструкции стека вызовов с учётом inline-функций, но для пошаговой отладки он не критичен. Интересующиеся деталями могут найти их в InlineStackTraceCalculator.kt. *S KotlinDebug
*F
+ 1 SimpleInline.kt
SimpleInlineKt
*L
4#1:18,6
Окончание данных промаркировано секцией
*E
: *E
А что там со Scala?
В 2017 году появилось предложение поработать над удобством отладки под номером SCP-011:
A major criticism of Scala is that the debugging experience is poor compared to Java. The main reason for this is because Scala's representation as JVM bytecode is not always intuitive. Although visual debuggers (Scala IDE, IntelliJ and ENSIME) are able to hide much of the demangling detail from the developer, there remains a great deal of ambiguity regarding the block of code that is executing.
В 2019 году про это вспомнили вновь и создали предложение SCP-022:
This is a proposal to prioritize the completion of SCP-11. To reduce the scope of SCP-11, this proposal suggests to focus only contributing JSR-45 support to the Scala 2 compiler.
В середине 2020 появился Pull Request #9121 для реализации этой функциональности в Scala 2.13.x, проделан некий объём работ, но всё закончилось закрытием PR в 2021 году по неактивности.
Сейчас открыт PR #15684, нацеленный на Scala 3:
Rebase of #11492 to the latest main. At Scala Center, we're planning to bring this over the finish line.
Пожелаем разработчикам порвать финишную ленту этого семилетнего супермарафона.
Заключение
В Kotlin есть поддержка inline-методов и для упрощения отладки таких методов применяется изящный подход с «виртуальными» строками. Виртуальные строки назначаются байткоду заинлайненой функции и затем отображаются на реальные строки в исходном файле при помощи спецификации JSR-045, разработанной в далёких нулевых для поддержки Java 2 Enterprise Edition.
В каждой программе на Kotlin есть немного Ынырпрайза.
sshikov
HostSpot JIT давно и успешно инлайнит методы. Настолько давно, что упоминание об этом было еще в документации для Solaris. Причем, это именно JVM, то есть потенциально это применяется ко всем языкам.
Возможно автор хотел выразить какую-то иную мысль, в этом случае надо бы данную фразу как-то переформулировать. Например, что нельзя заинлайнить отдельный конкретный метод? Или что javac этого не делает.
Maccimo Автор
В [языке программирования] Java, как известно, inline-методов нет.
Автор посчитал такую тавтологию излишней, так как то, что речь идёт о языковой конструкции уточняется в следующем же предложении.
sshikov
Ну, я согласен что это терминологическая придирка (по большей части возражения к формулировке «как известно», без уточнения, кому известно и откуда очень часто ошибочной), но технология-то есть. И методы заинлайненные вполне можно продемонстрировать. Даже когда-то давно обсуждалось введение в OpenJDK аннотации Inline, но вроде отказались реализовывать. Видимо, JIT таки лучше знает, что инлайнить.
Maccimo Автор
Разработчик этот процесс почти никак не контролирует, так что это не то же самое. Пользователь подкрутит параметры запуска JVM и всё пойдёт прахом.
Заинлайнены будут только те вызовы, которые JIT посчитает достаточно горячими и если при этом метод будет достаточно небольшим. И тут всё довольно шатко, любая пролетевшая мимо бабочка может изменить картину.
Как пример, одна из недавних регрессий: JDK-8300002
Ну и происходит это на уровне сгенерированного машинного кода, а не байткода.
В Lombok это реализовывать не стали. Для OpenJDK такой JSR не нашёл.