Files
obsidian/Kotlin/Взаимодействие с Java/Взаимодействие с Java.md
T

17 KiB
Raw Blame History

title, description, tags
title description tags
Взаимодействие с Java Зачем нужен interop, что работает без усилий, где возникает трение и как устроен раздел
kotlin
java-interop

Kotlin проектировался как язык, который должен без потерь жить в существующей Java-экосистеме. Это не побочная возможность, а требование: без доступа к Spring, Jackson, JDBC и остальной инфраструктуре JVM язык был бы бесполезен.

import java.time.LocalDate
import java.util.UUID

fun main() {
    val id = UUID.randomUUID()
    val today = LocalDate.of(2026, 1, 15)

    println(id.toString().length)        // 36
    println(today.plusDays(30))           // 2026-02-14
    println(today.dayOfWeek)              // THURSDAY
}

Это обычный Java API, вызванный как родной Kotlin-код: никаких обёрток, конвертеров и объявлений внешних типов.

Что происходит без усилий

Что Как работает
вызов Java-метода из Kotlin как обычный метод
геттеры/сеттеры Java как свойства Kotlin
Java-коллекции как MutableList, MutableMap и т.д.
наследование от Java-класса как от Kotlin-класса
реализация Java-интерфейса SAM-конверсия из лямбды
Java-аннотации применяются к Kotlin-коду
смешанная компиляция в одном модуле поддерживается Gradle-плагином
import java.util.concurrent.Executors

fun main() {
    val executor = Executors.newSingleThreadExecutor()
    executor.submit { println("Runnable из лямбды") }       // SAM-конверсия
    executor.shutdown()

    val list = java.util.ArrayList<String>()
    list.add("элемент")                                      // как MutableList
    println(list.size)                                        // 1

    val builder = StringBuilder()
    builder.append("a").append("b")
    println(builder.length)                                   // 2
    // Runnable из лямбды
}

Где возникает трение

Три системы типов Kotlin не имеют соответствия в Java, и именно на этих границах появляются сложности.

1. Null-безопасность. Java не различает String и String?, поэтому любое значение из Java - платформенный тип String!:

fun main() {
    // Java-метод вернул null, а Kotlin считал тип ненулевым
    val value: String = System.getProperty("несуществующее.свойство")
        ?: "значение по умолчанию"
    println(value)        // значение по умолчанию
}

Без ?: здесь был бы NullPointerException в момент присваивания. Подробнее: Платформенные типы

2. Изменяемость коллекций. В Kotlin List и MutableList - разные типы, в Java это один java.util.List:

fun main() {
    val immutable: List<String> = listOf("a", "b")
    // передача в Java-метод, который может вызвать add()
    val asJava: java.util.List<String> = immutable as java.util.List<String>
    val error = runCatching { asJava.add("c") }.exceptionOrNull()
    println("неизменяемый список отверг изменение: ${error != null}")      // неизменяемый список отверг изменение: true
}

Java-код не видит разницы и может попытаться изменить список, который Kotlin считает неизменяемым. Подробнее: Изменяемость

3. Возможности Kotlin, которых нет в Java. Значения по умолчанию, функции-расширения, suspend, value-классы, object - всё это компилируется в конструкции JVM, но из Java выглядит иначе, чем задумано. Для управления этим есть аннотации @JvmStatic, @JvmOverloads, @JvmName и другие. Подробнее: Kotlin из Java

Асимметрия направлений

Направления не равноценны - и это главное, что нужно понять про interop.

graph LR
    A["Java-код"] -->|"почти без трения:<br/>только null-безопасность"| B["Kotlin-код"]
    B -->|"требует аннотаций:<br/>@JvmStatic, @JvmOverloads,<br/>@JvmName, @JvmField"| A

Java из Kotlin работает почти прозрачно: язык создавался именно для этого. Единственная серьёзная проблема - платформенные типы, то есть риск NullPointerException там, где компилятор уверен в ненулевом значении.

Kotlin из Java требует внимания: без аннотаций ваш API окажется неудобным - значения по умолчанию превратятся в один метод с лишними параметрами, companion object потребует обращения через .Companion, а suspend-функция получит параметр Continuation.

Практический вывод: если ваш Kotlin-код вызывается из Java, его публичный API нужно проектировать осознанно. Если Kotlin-код вызывается только из Kotlin, аннотации не нужны вообще.

Карта раздела

Заметка О чём
Java из Kotlin платформенные типы, SAM, массивы, статика, синтетические свойства
Kotlin из Java @JvmStatic, @JvmOverloads, @JvmName, @JvmField, @Throws
Kotlin в байт-коде во что компилируются свойства, лямбды, data class, suspend

Смешанный проект

Gradle-плагин Kotlin компилирует Java и Kotlin вместе, разрешая циклические зависимости между языками:

// build.gradle.kts
plugins {
    kotlin("jvm") version "2.4.0"
    java
}

kotlin {
    jvmToolchain(21)
}

// Kotlin и Java в одном модуле:
// src/main/kotlin/ и src/main/java/ — компилируются совместно

Порядок компиляции: сначала Kotlin видит Java-исходники (через стабы), затем javac компилирует Java, уже видя готовые Kotlin-классы. Поэтому Java-класс может наследоваться от Kotlin-класса, который реализует Java-интерфейс. Подробнее: Структура проекта

Стратегия миграции

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

  1. Новый код на Kotlin, старый не трогать. Interop позволяет это без прослоек.
  2. Конвертировать по файлу, начиная с листовых классов (DTO, утилиты) - у них меньше зависимостей.
  3. Проверять результат автоконвертера. IDE конвертирует Java в Kotlin, но результат получается «Java, написанная на Kotlin»: платформенные типы вместо nullable, !! вместо ?., классы вместо data class.
  4. Публичный API, вызываемый из Java, аннотировать сразу - иначе придётся менять его потом.
  5. Тесты конвертировать последними или не конвертировать - они и так работают.
// типичный результат автоконвертера — работает, но это не идиоматичный Kotlin
class UserDtoConverted {
    var name: String? = null
    var age: Int? = null
    fun getDisplayName(): String {
        return if (name != null) name!! else "аноним"
    }
}

// идиоматичная версия
data class UserDto(val name: String?, val age: Int?) {
    val displayName: String get() = name ?: "аноним"
}

fun main() {
    println(UserDtoConverted().getDisplayName())        // аноним
    println(UserDto(null, null).displayName)             // аноним
}

[!info] Отличия от Java

Java Kotlin
один язык, одна система типов две системы, граница между ними
String может быть null String и String? различаются
List один тип List и MutableList
int и Integer различаются Int и Int?
статические методы companion object + @JvmStatic
нет аналога платформенные типы String!
checked exceptions обязательны проверяемых исключений нет

Interop работает, потому что Kotlin компилируется в тот же байт-код. Kotlin-класс - это обычный класс JVM: у него те же модификаторы доступа, то же наследование, тот же формат .class. Никакой прослойки, маршалинга или рефлексии в runtime нет, поэтому вызов Kotlin-метода из Java стоит ровно столько же, сколько вызов Java-метода. Это принципиально отличает ситуацию от interop между языками разных платформ (JNI, JNA), где границу приходится пересекать с накладными расходами.

Цена - необходимость представлять конструкции Kotlin средствами JVM. В байт-коде нет ни nullable-типов, ни значений по умолчанию, ни функций-расширений. Kotlin эмулирует их: nullable-информацию хранит в аннотациях и метаданных, значения по умолчанию - в синтетическом методе greet$default, расширения - в статических методах. Из Kotlin это видно как родные конструкции (компилятор читает метаданные), из Java - как реализация. Подробнее: Kotlin в байт-коде

Проверяемые исключения - односторонняя потеря. В Kotlin их нет: throws IOException из Java-метода не обязывает ни к try/catch, ни к объявлению. Это упрощает Kotlin-код, но Java-код, вызывающий Kotlin-метод, не узнает о возможном исключении без @Throws. Подробнее: Система исключений

Null-безопасность Kotlin не распространяется на Java-код. Компилятор не может проверить то, чего нет в типах Java. Поэтому аннотации @Nullable/@NotNull в Java-коде - не документация, а реальная информация для компилятора Kotlin: с ними платформенные типы становятся обычными nullable/non-null.

Сравнить: JVM, Байт-код

[!warning] Подводные камни Платформенные типы отключают проверки компилятора. Значение из Java имеет тип String!, который компилятор позволяет использовать и как String, и как String?. Ошибка проявится как NullPointerException в рантайме, в точке присваивания:

fun main() {
    val error = runCatching {
        val value: String = System.getProperty("нет.такого")       // вернёт null
        value.length
    }.exceptionOrNull()
    println("NPE от платформенного типа: ${error is NullPointerException}")
    // NPE от платформенного типа: true
}

Автоконвертер даёт работающий, но плохой код. Типичные следы: !! вместо ?., var x: T? = null вместо конструктора, геттеры вместо свойств, обычные классы вместо data class. После конвертации файл нужно переписывать вручную, иначе Kotlin-проект унаследует все недостатки Java-кода и не получит преимуществ.

Java-код может нарушить неизменяемость Kotlin-коллекции. List<String> из Kotlin приходит в Java как java.util.List, у которого есть add(). Вызов упадёт с UnsupportedOperationException в рантайме, а компилятор об этом не предупредит. Передавайте в Java копию (toList()) или Collections.unmodifiableList.

Kotlin-код без аннотаций неудобен из Java. Значения по умолчанию, companion object, object, suspend-функции и функции верхнего уровня выглядят из Java не так, как задумано. Если API публичный, это нужно решать до релиза - добавление @JvmStatic позже меняет сигнатуры и ломает совместимость.

internal в Kotlin из Java доступен. Он компилируется в public с манглированным именем (internalFun$main), поэтому Java-код в том же проекте технически может его вызвать. Это не защита, а соглашение.

Проверяемые исключения из Kotlin не видны Java. Kotlin-метод, бросающий IOException, из Java выглядит как не бросающий ничего - catch (IOException e) вокруг него не скомпилируется («exception is never thrown»). Нужна аннотация @Throws.

Смешанная компиляция требует правильного плагина. В Gradle нужен kotlin("jvm") и, при наличии Java-исходников, корректно настроенный sourceSets. Без этого Java-код не увидит Kotlin-классы, а ошибка выглядит как «cannot find symbol».

Generics Java стираются и в Kotlin. List<String> и List<Int> в рантайме неразличимы по обе стороны границы, поэтому проверка типа элементов коллекции из Java невозможна. Подробнее: Reified и inline

Далее: Java из Kotlin