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

28 KiB
Raw Blame History

title, description, tags
title description tags
Kotlin из Java Аннотации @JvmStatic, @JvmOverloads, @JvmName, @JvmField, @Throws и то, как конструкции Kotlin выглядят из Java
kotlin
java-interop

Обратное направление сложнее: в Kotlin есть конструкции, которых нет в JVM, и компилятор представляет их по-своему. Без аннотаций ваш API окажется неудобным для Java-кода.

class Greeter(val name: String) {
    fun greet(greeting: String = "Привет") = "$greeting, $name!"
}

fun main() {
    println(Greeter("Мир").greet())        // Привет, Мир!
}

Из Java этот же класс выглядит так:

// Java
Greeter g = new Greeter("Мир");
System.out.println(g.greet("Привет"));      // параметр обязателен!
// g.greet();                                // не скомпилируется

Значение по умолчанию существует только для Kotlin: в байт-коде есть greet(String) и синтетический greet$default(Greeter, String, int, Object), вызывать который вручную нельзя.

Карта проблем и аннотаций

Конструкция Kotlin Из Java без аннотации Аннотация
значение по умолчанию один метод со всеми параметрами @JvmOverloads
companion object Cls.Companion.method() @JvmStatic
object Singleton.INSTANCE.method() @JvmStatic
val в companion object Cls.Companion.getX() @JvmField
const val уже поле - ничего не нужно -
функция верхнего уровня FileNameKt.fn() @JvmName на файле
свойство getX() / setX() @get:JvmName
suspend fun лишний параметр Continuation не решается
value class манглированное имя не решается
исключение не видно в throws @Throws
перегрузки с дженериками конфликт стирания @JvmName
graph TD
    A["Kotlin API"] --> B{"Вызывается из Java?"}
    B -->|"нет"| C["аннотации не нужны"]
    B -->|"да"| D["проверить каждый пункт"]
    D --> E["значения по умолчанию<br/>@JvmOverloads"]
    D --> F["companion / object<br/>@JvmStatic"]
    D --> G["константы<br/>@JvmField или const"]
    D --> H["исключения<br/>@Throws"]
    D --> I["имена<br/>@JvmName"]

@JvmOverloads

Генерирует перегрузку для каждого значения по умолчанию:

class Greeter(val name: String) {
    @JvmOverloads
    fun greet(greeting: String = "Привет", punct: String = "!") = "$greeting, $name$punct"
}

fun main() {
    val g = Greeter("Мир")
    println(g.greet())                     // Привет, Мир!
    println(g.greet("Здравствуй"))          // Здравствуй, Мир!
    println(g.greet("Hi", "."))              // Hi, Мир.
}

В байт-коде появляются три метода:

public final String greet(String, String)      // полный
public final String greet(String)               // punct по умолчанию
public final String greet()                      // оба по умолчанию

Перегрузки создаются справа налево: параметры отбрасываются с конца. Поэтому greet(punct = ".") из Java невозможно - такой перегрузки нет. Это ограничение влияет на порядок параметров: самые часто переопределяемые ставьте первыми.

Для конструктора аннотация работает так же:

class Config @JvmOverloads constructor(
    val host: String = "localhost",
    val port: Int = 8080,
) {
    override fun toString() = "$host:$port"
}

fun main() {
    println(Config())                      // localhost:8080
    println(Config("example.com"))           // example.com:8080
    println(Config("example.com", 443))       // example.com:443
}

@JvmStatic

Без неё члены companion object доступны только через .Companion:

class Repository private constructor(val id: String) {
    companion object {
        @JvmStatic
        fun create(id: String) = Repository(id)

        fun createNoStatic(id: String) = Repository(id)
    }
}

fun main() {
    println(Repository.create("1").id)             // 1
    println(Repository.createNoStatic("2").id)      // 2
}

Из Kotlin обе формы одинаковы. Из Java - нет:

// Java
Repository.create("1");                        // работает: статический метод
Repository.Companion.createNoStatic("2");       // без @JvmStatic только так

То же для object:

object Registry {
    private val items = mutableListOf<String>()

    @JvmStatic
    fun register(item: String) { items += item }

    fun size() = items.size
}

fun main() {
    Registry.register("a")
    println(Registry.size())        // 1
}

Из Java: Registry.register("a") против Registry.INSTANCE.size().

@JvmField и const

Свойство в companion object компилируется в приватное поле с геттером на Companion. Три варианта:

class Limits {
    companion object {
        const val MAX = 100                  // примитив/String, compile-time
        @JvmField val DEFAULT = "значение"     // поле, доступно напрямую
        val COMPUTED = "вычисляется".uppercase()   // только через геттер
    }
}

fun main() {
    println(Limits.MAX)           // 100
    println(Limits.DEFAULT)        // значение
    println(Limits.COMPUTED)        // ВЫЧИСЛЯЕТСЯ
}

Из Java:

// Java
Limits.MAX;                              // public static final int
Limits.DEFAULT;                           // public static final String
Limits.Companion.getCOMPUTED();            // только геттер

const val требует примитив или String и инициализацию константным выражением - зато встраивается в место вызова (и потому меняет ABI при изменении значения). @JvmField работает с любым типом, но несовместим с private, open, override, lateinit и делегированными свойствами.

@JvmName

Задаёт имя в байт-коде. Три точки применения.

Файл. Функции верхнего уровня попадают в класс ИмяФайлаKt:

@file:JvmName("StringUtils")

fun String.slugify() = lowercase().replace(" ", "-")

fun main() {
    println("Привет Мир".slugify())        // привет-мир
}

Без аннотации Java-код обращался бы к SlugifyKt.slugify(s), с ней - StringUtils.slugify(s). Расширения становятся статическими методами с получателем первым параметром.

Аксессор свойства:

class Account(private val raw: String) {
    @get:JvmName("fetchBalance")
    val balance: String get() = raw
}

fun main() {
    println(Account("1000").balance)        // 1000
}

Из Java метод называется fetchBalance(), а не getBalance().

Обход конфликта стирания. Две функции, различающиеся только параметром дженерика, в байт-коде имеют одинаковую сигнатуру:

@JvmName("sumInts")
fun List<Int>.sumAll() = sum()

@JvmName("sumDoubles")
fun List<Double>.sumAll() = sum()

fun main() {
    println(listOf(1, 2, 3).sumAll())            // 6
    println(listOf(1.5, 2.5).sumAll())            // 4.0
}

Без @JvmName компилятор откажется: «platform declaration clash». Это одна из немногих ситуаций, когда аннотация нужна даже для чисто Kotlin-кода. Подробнее: Reified и inline

@Throws

В Kotlin нет проверяемых исключений, поэтому в байт-коде нет throws:

import java.io.IOException

class Loader {
    @Throws(IOException::class)
    fun load(path: String): String {
        if (path.isEmpty()) throw IOException("пустой путь")
        return "данные из $path"
    }

    fun loadNoThrows(path: String): String {
        if (path.isEmpty()) throw IOException("пустой путь")
        return "данные из $path"
    }
}

fun main() {
    val loader = Loader()
    println(loader.load("/tmp/file"))                          // данные из /tmp/file
    println(runCatching { loader.loadNoThrows("") }.isFailure)  // true
}

Из Java разница принципиальна:

// Java
try { loader.load(""); }
catch (IOException e) { }                  // компилируется

try { loader.loadNoThrows(""); }
catch (IOException e) { }                  // ОШИБКА: exception is never thrown

Java-компилятор запрещает ловить проверяемое исключение, которое метод не объявляет. Исключение всё равно вылетит в рантайме - просто обработать его можно только через catch (Exception e).

Как выглядят остальные конструкции

Nullability

class Validator {
    fun check(input: String): Boolean = input.isNotEmpty()
    fun find(key: String): String? = if (key == "a") "найдено" else null
}

fun main() {
    val v = Validator()
    println(v.check("текст"))        // true
    println(v.find("b"))              // null
}

check(String) получает аннотацию @NotNull на параметре и проверку Intrinsics.checkNotNullParameter в начале тела. Java-код, передавший null, получит NullPointerException с сообщением «Parameter specified as non-null is null» - сразу, а не при первом использовании. find возвращает @Nullable String, и IDE предупредит Java-код.

Свойства

class User {
    var name: String = "аноним"
    val id: Int = 1
    var internalState: String = "скрыто"
        private set
}

fun main() {
    val u = User()
    u.name = "Иван"
    println("${u.name} ${u.id} ${u.internalState}")        // Иван 1 скрыто
}

Из Java: getName()/setName(), getId() без сеттера, getInternalState() без сеттера (приватный сеттер в байт-коде приватен). Само поле всегда приватно - прямого доступа из Java нет.

Функции-расширения

fun List<String>.firstOrDefault(default: String) = firstOrNull() ?: default

fun main() {
    println(listOf("a").firstOrDefault("нет"))        // a
    println(emptyList<String>().firstOrDefault("нет"))  // нет
}

Из Java это статический метод с получателем первым аргументом: FileNameKt.firstOrDefault(list, "нет"). Никакого синтаксического сахара - расширение существует только в Kotlin.

suspend-функции

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
}

В байт-коде сигнатура становится Object fetch(int, Continuation), возвращая либо результат, либо маркер COROUTINE_SUSPENDED. Вызвать такое из Java практически невозможно. Для Java-клиентов делают обёртку:

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.future.future
import java.util.concurrent.CompletableFuture

class Api(private val scope: CoroutineScope) {
    suspend fun fetch(id: Int): String {
        delay(10)
        return "ответ для $id"
    }

    // для Java: CompletableFuture вместо suspend
    fun fetchAsync(id: Int): CompletableFuture<String> =
        scope.future { fetch(id) }
}

fun main() {
    val scope = CoroutineScope(Dispatchers.Default)
    println(Api(scope).fetchAsync(7).get())        // ответ для 7
}

Нужна зависимость kotlinx-coroutines-jdk8. Подробнее: Suspend-функции

value class

@JvmInline
value class UserId(val value: Int)

fun process(id: UserId) = "обработан ${id.value}"

fun main() {
    println(process(UserId(42)))        // обработан 42
}

В байт-коде process получает имя process-4ZD5Yi0(int) - суффикс нужен, чтобы отличать перегрузки с разными value-классами над одним примитивом. Вызвать такое имя из Java нельзя: дефис недопустим в идентификаторе. Для Java-API добавляют обычную перегрузку:

@JvmInline
value class Money(val cents: Long)

fun charge(amount: Money) = "списано ${amount.cents}"

@JvmName("chargeCents")
fun charge(cents: Long) = charge(Money(cents))

fun main() {
    println(charge(Money(500)))        // списано 500
    println(charge(500L))               // списано 500
}

Подробнее: Value class

internal

class Service {
    internal fun helper() = "вспомогательное"
    fun public() = helper()
}

fun main() {
    println(Service().public())        // вспомогательное
}

internal компилируется в public с манглированным именем вида helper$main (суффикс - имя модуля). Java-код технически может вызвать helper$main(). Это не изоляция, а соглашение, которое соблюдает только Kotlin-компилятор. Подробнее: Модификаторы видимости

data class

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)
    val (x, y) = p
    println("$x $y")                   // 1 2
}

Из Java доступны getX(), getY(), equals, hashCode, toString, component1(), component2() и copy(int, int). Но copy с одним аргументом недоступна - это те же значения по умолчанию, и @JvmOverloads на сгенерированном copy поставить нельзя. Деструктуризации в Java нет. Подробнее: Data class

sealed

sealed interface Result
data class Ok(val value: String) : Result
data class Err(val message: String) : Result

fun describe(r: Result) = when (r) {
    is Ok -> "успех: ${r.value}"
    is Err -> "ошибка: ${r.message}"
}

fun main() {
    println(describe(Ok("данные")))        // успех: данные
    println(describe(Err("сбой")))          // ошибка: сбой
}

Из Java sealed interface Kotlin - обычный интерфейс (при JVM-таргете 17+ может компилироваться в настоящий sealed). Исчерпывающего switch Java не получит - гарантия полноты обеспечивается только Kotlin-компилятором. Подробнее: Sealed class и interface

Чеклист публичного API для Java-клиентов

  1. @JvmOverloads на всех функциях со значениями по умолчанию.
  2. @JvmStatic на членах companion object и object.
  3. const val или @JvmField для констант.
  4. @Throws там, где Java-код должен ловить проверяемое исключение.
  5. @file:JvmName для файлов с функциями верхнего уровня.
  6. Нет suspend в публичном API - только обёртки (CompletableFuture, колбэки).
  7. Нет value class в сигнатурах - или перегрузка с @JvmName.
  8. Возвращать List, а не MutableList, и отдавать копию, если Java может изменить.
  9. Nullable-типы объявлять явно - Java-IDE прочитает аннотации.
  10. Проверить результат через javap на реальном классе.
// пример API, готового к вызову из Java
@file:JvmName("UserApi")

class UserService @JvmOverloads constructor(
    private val baseUrl: String = "https://api.example.com",
) {
    @Throws(IllegalArgumentException::class)
    @JvmOverloads
    fun find(id: String, includeDeleted: Boolean = false): String {
        require(id.isNotBlank()) { "id не может быть пустым" }
        return "$baseUrl/users/$id?deleted=$includeDeleted"
    }

    companion object {
        const val DEFAULT_TIMEOUT_MS = 5_000L

        @JvmStatic
        fun default() = UserService()
    }
}

fun main() {
    println(UserService.default().find("42"))
    println(UserService.DEFAULT_TIMEOUT_MS)
    // https://api.example.com/users/42?deleted=false
    // 5000
}

Проверка результата

Единственный надёжный способ увидеть реальный API - посмотреть байт-код:

kotlinc Api.kt -d out
javap -cp out com.example.UserApi
javap -cp out 'com.example.UserService$Companion'

javap покажет точные сигнатуры, включая синтетические методы $default и манглированные имена. Это стоит делать перед релизом библиотеки. Подробнее: Kotlin в байт-коде

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

Java Kotlin из Java
static метод @JvmStatic в companion object
public static final поле const val или @JvmField
перегрузки пишутся руками @JvmOverloads генерирует
throws в сигнатуре @Throws
имя класса = имя файла @file:JvmName
утилитный класс со static функции верхнего уровня
геттер пишется руками генерируется из свойства

Аннотации @Jvm* - это не костыли, а явное управление ABI. В Java представление в байт-коде однозначно: что написано, то и скомпилируется. В Kotlin между исходником и байт-кодом есть зазор, и аннотации дают над ним контроль. Плата за выразительность языка: @JvmOverloads существует потому, что в JVM нет значений по умолчанию, а не из-за недоработки.

Значения по умолчанию в Kotlin устроены иначе, чем перегрузки в Java. В Java N необязательных параметров требуют N перегрузок, каждую с делегированием. В Kotlin - один метод и синтетический $default, принимающий битовую маску переданных аргументов. Отсюда и ограничение @JvmOverloads: перегрузки генерируются только отбрасыванием с конца, потому что произвольные комбинации дали бы экспоненциальное число методов.

companion object - настоящий объект, а не пространство имён. В Java static - модификатор: метод принадлежит классу. В Kotlin companion - отдельный экземпляр класса Cls$Companion, у него есть тип, он может реализовывать интерфейсы и быть получателем расширений. Поэтому без @JvmStatic вызов идёт через Cls.Companion. Подробнее: Object и companion object

Отсутствие проверяемых исключений нельзя «включить» обратно. @Throws только добавляет запись в атрибут Exceptions метода - сам Kotlin-компилятор её не проверяет. Фактически это документация для Java-компилятора: без неё Java не разрешит catch, с ней - потребует обработки.

suspend принципиально несовместим с Java. Корутины реализованы через CPS-преобразование на этапе компиляции Kotlin: функция превращается в конечный автомат с параметром Continuation. Java-компилятор этого преобразования не делает, поэтому написать Java-вызывающий код для suspend-функции нельзя - только обёртки. Это самая жёсткая граница interop. Подробнее: Корутины против потоков

Сравнить: Инкапсуляция в Java, Система исключений в Java

[!warning] Подводные камни @JvmOverloads генерирует перегрузки только справа налево. Для fun f(a: Int = 1, b: Int = 2, c: Int = 3) появятся f(a,b,c), f(a,b), f(a), f() - но не f(c) и не f(a,c). Из Java нельзя передать только третий параметр. Ставьте чаще переопределяемые параметры раньше.

Добавление @JvmStatic или @JvmOverloads позже ломает совместимость. @JvmStatic не удаляет метод с Companion, но меняет рекомендуемую точку входа; снятие аннотации удаляет статический метод и роняет скомпилированный Java-код с NoSuchMethodError. Решайте до релиза.

Изменение const val требует перекомпиляции клиентов. Значение встраивается в место вызова, поэтому Java-код, собранный против старой версии, продолжит использовать старое число. Для значений, которые могут меняться, лучше @JvmField val.

@JvmField несовместим с половиной модификаторов:

class Limits {
    companion object {
        @JvmField val OK = "работает"
        // @JvmField private val BAD = ""        // ошибка: не может быть private
        // @JvmField lateinit var LATE: String    // ошибка: не для lateinit
    }
}

fun main() {
    println(Limits.OK)        // работает
}

Запрещено для private, open, override, const, lateinit и делегированных свойств.

copy() у data class из Java требует все аргументы. @JvmOverloads на сгенерированный copy поставить нельзя. Если Java-коду нужна частичная копия, пишите свой метод или билдер.

Манглированные имена value-классов недоступны из Java. fun takeId(id: UserId) компилируется в takeId-4ZD5Yi0(int); дефис делает имя невызываемым в Java. Это проявится только при попытке интеграции - Kotlin-компилятор не предупредит.

internal виден из Java. internalFun$main() - публичный метод в байт-коде. Если нужна настоящая закрытость для Java-кода в том же проекте, используйте private или выносите в отдельный модуль.

List<T> из Kotlin в Java имеет add(). Тип стирается до java.util.List, и Java-код может вызвать мутирующий метод. Падение будет в рантайме с UnsupportedOperationException. Для публичного API возвращайте Collections.unmodifiableList(...) или копию.

Платформенные объявления конфликтуют при стирании:

@JvmName("countStrings")
fun List<String>.count2() = size

@JvmName("countNumbers")
fun List<Int>.count2() = size

fun main() {
    println(listOf("a").count2())        // 1
    println(listOf(1, 2).count2())         // 2
}

Без @JvmName будет ошибка компиляции «platform declaration clash» - обе функции дают count2(List). Это проявляется и в чисто Kotlin-проекте.

@file:JvmName должна стоять до package. Файловые аннотации идут самой первой строкой файла, иначе компилятор их не примет.

@get:JvmName на свойстве в интерфейсе или open классе запрещена. Переименование аксессора сломало бы переопределение, поэтому компилятор его не допускает для open/override членов.

Делегированные свойства из Java выглядят как обычные. by lazy даёт getX(), но поле хранит Lazy<T> под именем x$delegate. Прямого доступа к значению нет, и @JvmField не применить. Подробнее: Делегирование

Расширения не переопределяются и не видны как методы. fun Foo.bar() из Java - это FileKt.bar(foo). Java-код не найдёт foo.bar(), а IDE не предложит. Если метод нужен Java-клиентам, делайте его членом класса.

Далее: Kotlin в байт-коде