Kotlin guide done. (20.10.26)

This commit is contained in:
2026-10-20 12:54:25 +03:00
parent da3ad9dd1a
commit 6853ada348
81 changed files with 30112 additions and 0 deletions
@@ -0,0 +1,434 @@
---
title: Kotlin в байт-коде
description: Во что компилируются свойства, лямбды, data class, inline, suspend, value class и null-проверки
tags:
- kotlin
- java-interop
- jvm
---
Понимание представления Kotlin в байт-коде отвечает на три практических вопроса: почему API выглядит из Java именно так, откуда берутся накладные расходы и что происходит при `NullPointerException` внутри сгенерированного кода.
Инструмент один - `javap`:
```
kotlinc Demo.kt -d out # компиляция в .class
javap -p -cp out DemoKt # сигнатуры, включая приватные
javap -c -p -cp out DemoKt # с байт-кодом
javap -v -p -cp out DemoKt # с аннотациями и константным пулом
```
```kotlin
fun add(a: Int, b: Int) = a + b
fun main() {
println(add(2, 3)) // 5
}
```
Эта функция компилируется в `public static final int add(int, int)` внутри класса `DemoKt`. Никакой обёртки нет - сигнатура такая же, как у Java-метода.
## Где живёт код
| Конструкция Kotlin | Класс в байт-коде |
|---|---|
| `class Foo` | `Foo` |
| функции верхнего уровня в `Demo.kt` | `DemoKt` |
| `object Bar` | `Bar` с полем `INSTANCE` |
| `companion object` в `Foo` | `Foo$Companion` + поле `Foo.Companion` |
| вложенный класс | `Foo$Inner` |
| анонимный объект | `Foo$method$1` |
| лямбда | `invokedynamic`, без отдельного класса |
| `enum class Color` | `Color extends java.lang.Enum` |
```kotlin
object Registry {
private val items = mutableListOf<String>()
fun add(item: String) { items += item }
fun size() = items.size
}
fun main() {
Registry.add("a")
println(Registry.size()) // 1
}
```
`object` становится классом с приватным конструктором, статическим полем `INSTANCE` и статическим инициализатором. Это классический singleton, и его потокобезопасность обеспечена гарантиями JVM на загрузку класса.
## Свойства
Свойство - это всегда приватное поле плюс аксессоры:
```kotlin
class Holder {
var mutable: String = "a"
val immutable: Int = 1
private var hidden = 2
lateinit var late: String
fun touch() = hidden++
}
fun main() {
val h = Holder()
h.mutable = "b"
println("${h.mutable} ${h.immutable} ${h.touch()}") // b 1 2
}
```
В байт-коде:
```
private java.lang.String mutable;
private final int immutable;
private int hidden;
public java.lang.String late; // lateinit — поле публично!
public final java.lang.String getMutable();
public final void setMutable(java.lang.String);
public final int getImmutable();
public final java.lang.String getLate();
public final void setLate(java.lang.String);
```
Три наблюдения:
**`val` даёт `final` поле и только геттер.** Это не константа - значение может вычисляться в конструкторе.
**`lateinit var` делает поле публичным.** Компилятор не может сделать его приватным, потому что геттер должен различать «не инициализировано» и «значение `null`» - для этого нужна прямая работа с полем, а `lateinit` исключает nullable-тип. Побочный эффект: поле видно из Java напрямую.
**Приватное свойство аксессоров не получает** - доступ идёт к полю, если нет кастомного геттера.
Свойство с кастомным геттером поля не создаёт вообще:
```kotlin
class Rect(val w: Int, val h: Int) {
val area: Int get() = w * h
}
fun main() {
println(Rect(3, 4).area) // 12
}
```
`area` - только метод `getArea()`, считающий значение каждый раз. Подробнее: [Свойства](../ООП/Свойства.md)
## Null-проверки
Для каждого ненулевого параметра публичной функции компилятор вставляет проверку:
```kotlin
fun nn(s: String) = s.length
fun nullable(s: String?) = s?.length
fun main() {
println(nn("текст")) // 5
println(nullable(null)) // null
}
```
Байт-код `nn`:
```
0: aload_0
1: ldc #9 // String s
3: invokestatic #15 // Intrinsics.checkNotNullParameter:(Object;String)V
6: aload_0
7: invokevirtual #21 // String.length:()I
10: ireturn
```
`checkNotNullParameter` бросает `NullPointerException` с сообщением «Parameter specified as non-null is null» - это и есть защита границы от Java-кода. Для `nullable` проверки нет, зато есть ветвление и упаковка в `Integer`:
```
public static final java.lang.Integer nullable(java.lang.String);
```
Возвращаемый тип `Int?` стал `Integer` - nullable-примитив всегда упакован.
Информация о nullability хранится в аннотациях:
```
public static final int nn(java.lang.String); @NotNull на параметре
public static final java.lang.Integer nullable(...); @Nullable на методе и параметре
```
Это `org.jetbrains.annotations.@NotNull`/`@Nullable` с `RuntimeInvisible`-удержанием. Их читают Java-IDE и другие JVM-языки. Подробнее: [Null-безопасность](../Синтаксис%20и%20типы/Null-безопасность/Null-безопасность.md)
```mermaid
graph LR
A["String в Kotlin"] --> B["java.lang.String<br/>+ @NotNull<br/>+ checkNotNullParameter"]
C["String? в Kotlin"] --> D["java.lang.String<br/>+ @Nullable<br/>без проверки"]
E["Int в Kotlin"] --> F["int"]
G["Int? в Kotlin"] --> H["java.lang.Integer"]
```
## data class
```kotlin
data class Point(val x: Int, val y: Int)
fun main() {
val p = Point(1, 2)
println(p) // Point(x=1, y=2)
println(p.copy(y = 5)) // Point(x=1, y=5)
println(p == Point(1, 2)) // true
}
```
Генерируется:
```
private final int x;
private final int y;
public Point(int, int);
public final int getX();
public final int getY();
public final int component1();
public final int component2();
public final Point copy(int, int);
public static Point copy$default(Point, int, int, int, java.lang.Object);
public java.lang.String toString();
public int hashCode();
public boolean equals(java.lang.Object);
```
`component1()`/`component2()` нужны для деструктуризации - компилятор вызывает их по позиции. `copy$default` реализует значения по умолчанию: четвёртый параметр `int` - битовая маска переданных аргументов, пятый `Object` - технический. Подробнее: [Data class](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Data%20class.md)
## Значения по умолчанию
```kotlin
class Service {
fun call(url: String, retries: Int = 3, timeout: Long = 1000L) =
"$url r=$retries t=$timeout"
}
fun main() {
println(Service().call("/api")) // /api r=3 t=1000
}
```
Два метода вместо одного:
```
public final java.lang.String call(java.lang.String, int, long);
public static java.lang.String call$default(Service, java.lang.String, int, long, int, java.lang.Object);
```
`$default` получает битовую маску: бит `i` установлен, если аргумент `i` **не** передан. Внутри он подставляет значение по умолчанию для каждого отсутствующего аргумента и вызывает основной метод. Отсюда два следствия: вызов со значениями по умолчанию стоит на один вызов дороже, а из Java `$default` недоступен (он синтетический и требует знания маски).
## Лямбды и функциональные типы
```kotlin
fun noCapture(): () -> Int = { 42 }
fun withCapture(n: Int): () -> Int = { n }
fun main() {
println(noCapture()()) // 42
println(withCapture(7)()) // 7
}
```
Функциональный тип `() -> Int` - это интерфейс `kotlin.jvm.functions.Function0<Integer>`. Их 23: `Function0` ... `Function22`.
```
public static final kotlin.jvm.functions.Function0<java.lang.Integer> noCapture();
0: invokedynamic #81, 0 // InvokeDynamic #0:invoke:()Lkotlin/jvm/functions/Function0;
```
Начиная с K2 лямбды компилируются через `invokedynamic` (как в Java), а не в отдельные классы `Foo$lambda$1`. Это уменьшает размер артефакта и ускоряет загрузку, но означает, что имя класса лямбды генерируется в рантайме - в стектрейсах появляются `$$Lambda$...`.
Важная деталь: `Function0<Integer>` работает с **упакованным** `Integer`. Лямбда, возвращающая `Int`, упаковывает результат на каждом вызове - если это горячий цикл, нужна `inline`-функция. Подробнее: [Лямбды и функциональные типы](../Синтаксис%20и%20типы/Работа%20с%20данными/Лямбды%20и%20функциональные%20типы.md)
## inline
```kotlin
inline fun measure(block: () -> Unit) {
block()
}
fun useInline() {
measure { println("тело") }
}
fun main() {
useInline() // тело
}
```
Байт-код `useInline` не содержит вызова `measure` вообще:
```
public static final void useInline();
0: iconst_0
1: istore_0
2: iconst_0
3: istore_1
4: ldc #49 // String тело
6: getstatic #55 // System.out
9: swap
10: invokevirtual #61 // println
13: nop
14: nop
15: return
```
Тело лямбды подставлено прямо в метод: ни объекта `Function0`, ни вызова по ссылке. Это основа `forEach`, `let`, `map` и остальных операций stdlib - они не создают объектов. Цена - код дублируется в каждой точке вызова. Подробнее: [Inline-функции](../Функциональный%20Kotlin/Inline-функции.md)
Сама `measure` всё равно существует как метод (для Java-клиентов и рефлексии):
```
public static final void measure(kotlin.jvm.functions.Function0<kotlin.Unit>);
```
## suspend
```kotlin
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
suspend fun fetch(id: Int): String {
delay(10)
return "ответ $id"
}
fun main() = runBlocking {
println(fetch(1)) // ответ 1
}
```
Сигнатура меняется радикально:
```
public static final java.lang.Object fetch(int, kotlin.coroutines.Continuation<? super java.lang.String>);
```
Три изменения: добавлен параметр `Continuation`, тип возврата стал `Object` (вместо `String`), и само тело превратилось в конечный автомат. Функция может вернуть либо результат, либо специальный маркер `COROUTINE_SUSPENDED`, означающий «я приостановилась, результат придёт через `Continuation`».
Конечный автомат - это анонимный класс с полем `label`, хранящим номер точки приостановки, и полями для всех локальных переменных, живущих через приостановку. При возобновлении функция вызывается снова, читает `label` и прыгает в нужную ветку `switch`. Это CPS-преобразование (continuation-passing style), выполняемое компилятором.
Отсюда свойства корутин: приостановка не блокирует поток (нет никакого потока - есть объект состояния), стектрейсы короткие, а Java-код вызвать `suspend`-функцию практически не может. Подробнее: [Suspend-функции](../Корутины/Suspend-функции.md)
## value class
```kotlin
@JvmInline
value class UserId(val value: Int)
fun process(id: UserId) = "обработан ${id.value}"
fun main() {
println(process(UserId(42))) // обработан 42
}
```
Класс `UserId` в байт-коде существует, но в сигнатурах заменяется на обёрнутый тип:
```
public static final java.lang.String process-4ZD5Yi0(int);
```
Параметр стал `int` - обёртки в рантайме нет. Суффикс `-4ZD5Yi0` (хеш от типов) нужен, чтобы `process(UserId)` и `process(OrderId)` не конфликтовали: после стирания у обеих сигнатура `process(int)`.
Обёртка всё-таки создаётся в трёх случаях: когда value-класс используется как дженерик-параметр (`List<UserId>`), как nullable (`UserId?`) и при обращении через супертип. Подробнее: [Value class](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Value%20class.md)
## Делегированные свойства
```kotlin
class Config {
val expensive: String by lazy { "вычислено" }
}
fun main() {
println(Config().expensive) // вычислено
}
```
```
private final kotlin.Lazy v$delegate;
public final java.lang.String getV();
```
Поле хранит не значение, а делегат. Геттер вызывает `delegate.getValue()`. Поэтому каждое делегированное свойство - это дополнительный объект и один лишний вызов на чтение. Для `by lazy` с дефолтным режимом внутри ещё и `synchronized`-блок при первом доступе. Подробнее: [Делегирование](../ООП/Делегирование.md)
## Дженерики и вариантность
```kotlin
class Box<T : Comparable<T>>(val value: T) {
fun max(other: T) = if (value > other) value else other
}
fun sizes(l: List<out Number>, m: MutableList<in Int>) = l.size + m.size
fun main() {
println(Box(3).max(7)) // 7
println(sizes(listOf(1, 2), mutableListOf(3))) // 3
}
```
```
public final class Box<T extends java.lang.Comparable<? super T>>
public static final int sizes(java.util.List<? extends java.lang.Number>, java.util.List<? super java.lang.Integer>);
```
Проекции Kotlin становятся wildcards Java: `out T` → `? extends T`, `in T` → `? super T`. Дженерики стираются так же, как в Java - в рантайме `Box<Int>` и `Box<String>` неразличимы. Обойти это позволяет только `reified` в `inline`-функциях, где тип подставляется при компиляции. Подробнее: [Вариантность](../Дженерики/Вариантность.md), [Reified и inline](../Дженерики/Reified%20и%20inline.md)
## Nothing и Unit
```kotlin
fun fail(): Nothing = throw IllegalStateException("всегда падает")
fun act(): Unit = println("действие")
fun main() {
act() // действие
println(runCatching { fail() }.isFailure) // true
}
```
`Unit` как возвращаемый тип становится `void`. `Nothing` - `java.lang.Void` (значение которого всегда `null`, потому что функция никогда не возвращается нормально). В обоих случаях Kotlin-компилятор знает больше, чем байт-код: `Nothing` делает код после вызова недостижимым, но Java-компилятор этого не увидит. Подробнее: [Any, Unit, Nothing](../Синтаксис%20и%20типы/Базовые%20типы%20ООП/Any,%20Unit,%20Nothing.md)
## Метаданные
Каждый Kotlin-класс несёт аннотацию `kotlin.Metadata` с закодированной информацией о nullable-типах, значениях по умолчанию, свойствах, `suspend`, вариантности, `inline`, `sealed` и прочем. Именно её читает Kotlin-компилятор, подключая скомпилированную библиотеку: `.class`-файла недостаточно, чтобы восстановить Kotlin-API.
Практические следствия:
**Обфускация ломает Kotlin-interop.** ProGuard/R8 должны сохранять `kotlin.Metadata`, иначе библиотека перестанет быть пригодной для Kotlin-клиентов (но останется пригодной для Java).
**Рефлексия Kotlin требует `kotlin-reflect`.** `KClass.memberProperties` читает метаданные, и для этого нужна отдельная зависимость весом ~2 МБ. Java-рефлексия (`javaClass.declaredFields`) работает без неё, но видит только байт-код. Подробнее: [Рефлексия](../Рефлексия/Рефлексия.md)
**Java-библиотека не знает про Kotlin-типы.** Jackson, Hibernate и подобные видят только `.class`: конструктор со значениями по умолчанию, nullable-поле как обычное. Для корректной работы нужны модули-адаптеры (`jackson-module-kotlin`), которые читают `kotlin.Metadata`.
## Сводная таблица
| Kotlin | Байт-код |
|---|---|
| `fun f()` на верхнем уровне | `static final` в `ФайлKt` |
| `object O` | класс + `static final O INSTANCE` |
| `companion object` | класс `Cls$Companion` + поле |
| `val x: T` | `private final T x` + `getX()` |
| `var x: T` | `private T x` + `getX()`/`setX()` |
| `lateinit var x` | `public T x` (поле публично) |
| `val x get() = ...` | только `getX()`, поля нет |
| `x: String` (параметр) | `String` + `@NotNull` + `checkNotNullParameter` |
| `x: String?` | `String` + `@Nullable` |
| `Int` | `int` |
| `Int?` | `java.lang.Integer` |
| `() -> Int` | `Function0<Integer>` |
| лямбда | `invokedynamic` |
| `inline fun` | тело подставлено + метод для Java |
| `suspend fun f(): T` | `Object f(Continuation)` + автомат |
| `@JvmInline value class` | обёрнутый тип + суффикс в имени |
| `by lazy` | поле `x$delegate: Lazy` |
| `out T` / `in T` | `? extends T` / `? super T` |
| `Unit` | `void` |
| `Nothing` | `java.lang.Void` |
| `internal fun f()` | `public final f$module()` |
> [!info] Отличия от Java
> | Java | Kotlin |
> |---|---|
> | исходник ≈ байт-код | есть зазор, есть метаданные |
> | поля публичны, если так написано | поля всегда приватны (кроме `lateinit`) |
> | геттер пишется руками | генерируется |
> | лямбда → `invokedynamic` | то же (с K2) |
> | нет значений по умолчанию | синтетический `$default` |
> | нет `inline` | тело подставляется |
> | `async` через библиотеку | `suspend` → конечный автомат |
> | `int` и `Integer` в коде | `Int` и `Int?` |
>
> **Главное отличие - наличие метаданных.** В Java `.class` полностью описывает API: что видно в сигнатурах, то и есть. В Kotlin часть системы типов (nullability, значения по умолчанию, `suspend`, `inline`, `sealed`, вариантность) существует только в аннотации `kotlin.Metadata`. Это значит, что Kotlin-компилятор и Java-компилятор видят **разные** API одной библиотеки: первый богаче. Отсюда и необходимость сохранять метаданные при обфускации, и зависимость `kotlin-reflect`, и адаптеры для Jackson.
>
> **Проверки на null стоят вызова метода на каждый параметр.** В Java такой проверки нет вообще - `NullPointerException` возникает при первом использовании. Kotlin переносит сбой на границу функции, что делает диагностику проще (понятно, какой параметр виноват), но добавляет `invokestatic` на каждый ненулевой параметр публичной функции. В приватных и `inline`-функциях проверок нет: там компилятор уже гарантировал тип.
>
> **`inline` - возможность, которой в Java нет на уровне языка.** JIT в Java тоже встраивает вызовы, но это решение рантайма, непредсказуемое и не применимое к лямбдам с захватом. Kotlin встраивает на этапе компиляции, гарантированно и вместе с телом лямбды. Это и позволяет `collection.map { }` не создавать объектов, и даёт `reified`, и делает возможными `return` из лямбды. Цена - рост байт-кода и невозможность изменить тело без перекомпиляции клиентов.
>
> **`suspend` реализован компилятором, а не рантаймом.** В Java асинхронность требует либо потоков, либо `CompletableFuture` с цепочками колбэков, либо виртуальных потоков (Loom), которые меняют JVM. Kotlin делает CPS-преобразование на этапе компиляции и работает на любой JVM 8+. Следствие: корутины не требуют поддержки платформы, но и не интегрируются с Java-кодом.
>
> **Value-классы ближе к будущим Project Valhalla, чем к Java сегодня.** В Java любая обёртка над `int` - это объект в куче. Kotlin убирает обёртку на этапе компиляции, заменяя тип в сигнатурах. Цена - манглирование имён и обёртка в дженериках, где стирание не позволяет обойтись примитивом.
>
> Сравнить: [JVM](../../Программирование%20на%20Java/JVM/JVM.md), [Байт-код](../../Программирование%20на%20Java/JVM/Байт-код.md)
> [!warning] Подводные камни
> **`lateinit var` создаёт публичное поле.** Это нарушает инкапсуляцию неожиданным образом: Java-код и рефлексия получают прямой доступ в обход геттера, минуя проверку инициализации. Если это важно, используйте `var x: T? = null` с приватным сеттером.
>
> **Nullable-примитив всегда упакован:**
> ```kotlin
> fun main() {
> val plain: Int = 1
> val nullable: Int? = 1
> println(plain == nullable) // true
> println(nullable!!::class.java.name) // java.lang.Integer
> val a: Int? = 1000
> val b: Int? = 1000
> println("identity: ${a === b}") // identity: false
> }
> ```
> `Int?` это `Integer`, поэтому `===` на значениях вне кеша `-128..127` даёт `false`. В горячем коде `Int?` в коллекциях и лямбдах создаёт объекты. Подробнее: [Числа и переполнение](../Синтаксис%20и%20типы/Работа%20с%20данными/Числа%20и%20переполнение.md)
>
> **Значения по умолчанию стоят лишнего вызова.** `call("/api")` идёт через `call$default`, который разбирает битовую маску и вызывает `call`. Для горячего пути с миллионами вызовов разница измерима - там пишут явные перегрузки.
>
> **`inline` раздувает байт-код.** Большая `inline`-функция, вызванная в сотне мест, копируется сто раз. Компилятор предупреждает про «expected performance impact... insignificant» для функций без лямбда-параметров - это намёк, что `inline` там не нужен.
>
> **Изменение тела `inline`-функции требует перекомпиляции клиентов.** Старое тело уже вкомпилировано в их байт-код. Для публичных библиотек это серьёзное ABI-ограничение: `inline` в публичном API фиксирует реализацию.
>
> **Делегированное свойство - это объект и вызов.** `by lazy` на каждом из сотни свойств даёт сотню объектов `Lazy` и сотню косвенных вызовов. Для простых случаев дешевле `private var x: T? = null` с проверкой в геттере.
>
> **Стектрейс корутины не показывает путь вызова.** Конечный автомат возобновляется из диспетчера, поэтому в стектрейсе видна только текущая точка приостановки. Для отладки включают `-Dkotlinx.coroutines.debug` или зависимость `kotlinx-coroutines-debug`. Подробнее: [Корутины](../Корутины/Корутины.md)
>
> **Обфускация без правил для `kotlin.Metadata` ломает библиотеку для Kotlin-клиентов.** Код продолжит работать, но исчезнет nullability, пропадут значения по умолчанию, `kotlin-reflect` перестанет видеть свойства. В правилах R8 нужно `-keep class kotlin.Metadata`.
>
> **`javap` не показывает Kotlin-API.** Он читает байт-код, не метаданные: `suspend`-функция выглядит как метод с `Continuation`, а `data class` - как обычный класс с кучей методов. Чтобы увидеть Kotlin-вид скомпилированного кода, нужен `kotlinp` или IDE («Show Kotlin Bytecode» → «Decompile»).
>
> **Синтетические методы видны рефлексии.** `component1()`, `copy$default`, `access$...`, `Foo$Companion` - всё это попадёт в `getDeclaredMethods()`. Код, перебирающий методы класса (сериализаторы, валидаторы), должен фильтровать по `isSynthetic()`.
>
> **`internal` не защищает.** Манглированное имя `helper$module` публично. Java-код в том же артефакте вызовет его без препятствий, как и любая рефлексия.
>
> **Проверки `checkNotNullParameter` можно отключить, но не стоит.** Флаг `-Xno-param-assertions` убирает их, давая небольшой выигрыш. Цена - `NullPointerException` будет возникать глубоко внутри вместо границы функции, и диагностика станет заметно хуже.
Далее: [Инструментарий](../Инструментарий/Инструментарий.md)