JavaRush /Курси /Kotlin SELF /Мінімально «дружній до Java» Kotlin‑API — @file:JvmName і...

Мінімально «дружній до Java» Kotlin‑API — @file:JvmName і @JvmField

Kotlin SELF
Рівень 29 , Лекція 4
Відкрита

1. Як Java бачить Kotlin на JVM

Сьогодні логічно «перевернути стрілку»: не «Kotlin викликає Java», а «Java викликає Kotlin». Ми не писатимемо Java‑код як окрему практику. Натомість розберемо, як Java побачить Kotlin‑код, і навчимося двома невеликими анотаціями робити Kotlin‑API суттєво зручнішим для Java‑споживача.

Навіщо взагалі думати про Java‑представлення

Уявіть, що ви написали акуратний Kotlin‑код: функції верхнього рівня, константи, форматування звітів. А потім приходить колега (або ви самі за пів року) й питає: «А можна я це викличу з Java?». І тут починається невелика драма: Java не знає про багато Kotlin‑зручностей і бачить ваш код крізь призму JVM. Тому важливо розуміти, які імена й форми опиняються в байткоді та де можна трохи «підсолодити» interop.

У нашій консольній програмі (нехай це буде мінітрекер витрат) уже є чимало утилітних функцій: парсинг команд, форматування рядків, підготовка звітів. Це чудовий кандидат на «невелике API», яким теоретично може користуватися й Java‑код (наприклад, інший модуль у вашій компанії, який до Kotlin ще морально не дійшов — і це нормально).

Чому Java бачить дивний SomethingKt

До цього моменту ми спокійно писали функції «просто у файлі»:


fun formatMoney(amount: Int): String = "$amount ₴ "

З погляду Kotlin це природно: файл — цілком нормальне місце для функцій‑утиліт. Але на JVM усе влаштовано так, що функції мають «жити» в якомусь класі. Тому компілятор створює для вас технічний клас‑обгортку: file facade (його ще часто називають «класом файлу»).

Якщо ваш файл називається ReportUtils.kt, то Java зазвичай побачить щось на кшталт ReportUtilsKt і статичні методи всередині нього. Тобто Java‑виклик (умовно) виглядав би як ReportUtilsKt.formatMoney(10).

Це не «погано» і не «помилка». Це просто реальність JVM: їй потрібно кудись покласти статичні методи — тож вона кладе їх у клас, імʼя якого ви інколи навіть не планували «виносити в люди».

2. Людське імʼя для класу файлу: @file:JvmName

Коли ви робите бібліотеку, утилітний модуль або просто код, яким хтось користуватиметься ззовні, раптом виявляється важливим, як саме виглядає імʼя в Java. Називати API‑точку входу ReportUtilsKt — терпимо, але трохи «пахне компілятором». Kotlin дозволяє це виправити однією анотацією: @file:JvmName("...").

Ключова ідея проста: ми керуємо тим, як JVM‑клас файлу називатиметься для зовнішнього світу. Водночас Kotlin‑код усередині проєкту зазвичай узагалі не страждає: у Kotlin ви, як і раніше, викликаєте функції за їхніми звичайними іменами.

Важливо й те, де саме писати анотації рівня файлу. За угодами оформлення Kotlin‑коду такі анотації розміщують після коментаря файлу (якщо він є) і до package, а також відокремлюють порожнім рядком. Так візуально одразу видно, що анотація належить саме файлу, а не пакету.

Приклад файлу «експортного API»

Припустімо, ми хочемо зібрати в одному місці функції, які формують рядки звіту. Створімо файл ExpenseApi.kt:

@file:JvmName("ExpenseApi")

package app.expenses

fun formatMoney(amount: Int): String {
    return "$amount ₴ "
}

fun formatExpenseLine(title: String, amount: Int): String {
    return "$title: ${formatMoney(amount)}"
}

Зверніть увагу на дві речі.

По‑перше, @file:JvmName("ExpenseApi") стоїть на самому верху і задає JVM‑імʼя класу файлу. Саме такий стиль розміщення анотацій рівня файлу вважається правильним і очікуваним.

По‑друге, ми не створювали жодних класів вручну. Ми все ще в частині курсу «до ООП»: лише функції та колекції.

Як це виглядає на рівні ідеї

Якщо уявити, як Java бачитиме цей файл, то ідея така:

flowchart TD
    A["ExpenseApi.kt top-level fun"] --> B["JVM class (facade)"]
    B --> C["Java calls static methods"]
    B["JVM class name = ExpenseApi"] --> C

Без @file:JvmName JVM‑клас майже напевно називався б ExpenseApiKt (бо файл ExpenseApi.kt). З анотацією він стає просто ExpenseApi.

І так, це рівно той випадок, коли «ми не пишемо Java», але робимо так, щоб Java‑колезі не доводилося щодня бачити суфікс Kt і щоразу згадувати, що це взагалі таке.

3. Коли властивість стає полем: @JvmField

Чому val зазвичай не «поле»

Коли ви пишете:

val appName: String = "Budget CLI"

у Kotlin це виглядає як «змінна/константа». Але на JVM властивість зазвичай перетворюється на:

1) поле (де зберігається значення),
2) метод‑гетер getAppName() (а для var ще й сетер setAppName(...)).

Тобто Java за замовчуванням взаємодіє з властивістю через методи, а не через прямий доступ до поля. Це добре узгоджується з інкапсуляцією та з тим, що Kotlin‑властивості можуть мати кастомні гетери/сетери.

І тут виникає нюанс interop: якщо ви хочете, щоб Java бачила ваше значення як «просте поле» (наприклад, як константу або як публічний прапорець), то доступ через getX() може здаватися зайво багатослівним. А інколи він ще й ламає стиль коду.

@JvmField і межі застосування

Іноді ви справді хочете: «нехай це буде публічне поле, без гетерів і сетерів». Для цього існує анотація @JvmField.

Її сенс: експортувати Kotlin‑властивість як JVM‑поле, щоб Java могла звертатися до нього напряму, без getX().

Важливо одразу зафіксувати межу: @JvmField має сенс здебільшого заради Java‑споживача. У Kotlin‑коді ви все одно пишете API_VERSION, DEFAULT_CURRENCY, і для Kotlin‑розробника майже нічого не змінюється. Але для Java змінюється форма доступу.

Приклад: версія API та «банер» застосунку

Додаймо в пакет app.expenses файл InteropConstants.kt:

package app.expenses

import kotlin.jvm.JvmField

@JvmField
val API_VERSION: Int = 1

val banner: String
    get() = "Expense CLI (api=$API_VERSION)"

Тут навмисно два різні випадки.

API_VERSION — це значення, що зберігається. У нього є backing field (тобто реальне місце зберігання). Його можна експортувати як поле.

banner — обчислювана властивість: вона щоразу «збирається» гетером. У неї немає окремого поля зберігання, отже «експортувати як поле» просто нічого. Це важлива ідея: @JvmField працює лише там, де реально є поле.

Як Java буде це бачити

Kotlin оголошення Що зазвичай генерується на JVM Що буде при @JvmField
val X = 1
поле + getX() публічне поле X (гетера немає)
var X = 1
поле + getX() + setX(...) публічне поле X (без аксесорів)
val X get() = ...
тільки getX() не можна (немає backing field)

Це не «домовленість на словах», а прямий наслідок того, як Kotlin розкладає властивості на JVM.

4. Мініінтеграція в CLI‑проєкт: Java‑friendly API без магії

Зараз буде важливий момент. Ми не перетворюватимемо наш проєкт на бібліотеку світового рівня й не обговорюватимемо публікації в Gradle та інші «дорослі слова». Але зробимо так, щоб код виглядав акуратно й був «експортопридатним»: окремий файл API, зрозуміле JVM‑імʼя та пара значень, які Java може читати як поля.

Нехай наш проєкт — це консольний трекер витрат, який зберігає витрати в списку MutableList<Triple<String, Int, String>>, де лежить (категорія, сума, коментар).

Файл з API‑функціями: @file:JvmName

Створімо ExpenseInterop.kt:

@file:JvmName("ExpenseInterop")

package app.expenses

fun makeExpense(category: String, amount: Int, note: String): Triple<String, Int, String> {
    return Triple(category, amount, note)
}

fun expenseAmount(expense: Triple<String, Int, String>): Int {
    return expense.second
}

Так, це прості функції, і Kotlin‑розробник міг би писати expense.second прямо всюди. Але сенс «API‑файлу» в тому, що ви формуєте поверхню, якою можуть користуватися інші мови та модулі.

І так, анотацію рівня файлу ми ставимо саме на початку файлу, до package. Це не просто «так заведено». Такий порядок справді покращує читабельність і не залишає сумнівів, до чого саме належить анотація.

Файл із «публічними значеннями»: @JvmField

Створімо ExpenseConfig.kt:

package app.expenses

import kotlin.jvm.JvmField

@JvmField
val DEFAULT_CURRENCY: String = "UAH"

@JvmField
val API_VERSION: Int = 1

fun formatMoney(amount: Int): String {
    return "$amount $DEFAULT_CURRENCY"
}

У Kotlin це буде зручно і як «конфіг», і як простий спосіб тримати спільні значення в одному місці.

Якщо колись Java‑код захоче дізнатися валюту або версію API, йому не доведеться викликати getDEFAULT_CURRENCY() (а потім пояснювати тімліду, чому константа виглядає як гетер). Він зможе звертатися до поля напряму.

Перевіряємо, що застосунок і далі працює

Оновімо main (спрощений фрагмент: ми показуємо ідею, а не збираємо весь проєкт на 200 рядків):

package app

import app.expenses.API_VERSION
import app.expenses.formatMoney
import app.expenses.makeExpense

fun main() {
    val expense = makeExpense("їжа", 350, "обід")
    println("API v$API_VERSION")                      // API v1
    println("Сума: ${formatMoney(expense.second)}")  // Сума: 350 UAH
}

Зверніть увагу: для Kotlin‑коду взагалі не важливо, що там стоїть @JvmField і @file:JvmName. Kotlin‑код читається як завжди. Водночас JVM‑представлення стає акуратнішим для зовнішнього споживача.

5. Типові помилки

У цій темі помилки особливо підступні: код може виглядати «логічно» в Kotlin, але бути незручним (або навіть неочікуваним) для Java‑світу. Тому краще одразу навчитися впізнавати типові граблі «на слух».

Помилка № 1: поставити @file:JvmName не туди — наприклад, після package.
Анотації рівня файлу належать усьому файлу, тому вони мають стояти до package. Якщо поставити їх нижче, компілятор не «здогадається, що ви мали на увазі», а чесно скаже, що так не можна. Тримайте просте правило: анотація рівня файлу — вгорі файлу, потім порожній рядок, потім package. Такий порядок прямо зафіксовано в загальних угодах оформлення Kotlin‑коду.

Помилка № 2: очікувати, що Java побачить top‑level функцію «просто за імʼям», без класу.
На JVM функції не висять у повітрі: вони будуть методами в певному класі. Якщо ви не керуєте імʼям класу файлу, Java майже напевно побачить суфікс Kt. Це нормально, але якщо ви будуєте API, краще одразу дати читабельне імʼя через @file:JvmName, щоб не тягнути технічні деталі в публічний інтерфейс.

Помилка № 3: спробувати повісити @JvmField на обчислювану властивість.
Якщо у властивості немає backing field, то «експортувати поле» фізично неможливо: немає місця зберігання, є лише логіка обчислення. Це часта пастка, бо в Kotlin val banner get() = ... виглядає як «змінна», але на JVM це чистий метод. У такому разі або залишайте звичайний гетер (і Java викликатиме getBanner()), або робіть значення, що зберігається.

Помилка № 4: застосовувати @JvmField «про всяк випадок», не розуміючи, навіщо.
@JvmField — не прикраса і не «прискорювач Kotlin». Це інструмент дизайну interop. Якщо ваш модуль не використовуватиметься з Java, то найчастіше вам не потрібно перетворювати властивості на поля: гетери/сетери — нормальна й безпечна форма. Анотація потрібна тоді, коли ви точно хочете дати Java простий прямий доступ.

Помилка № 5: змішати в одному файлі «все підряд», а потім намагатися красиво назвати його для Java.
Якщо ви хочете експортувати API‑поверхню, корисно тримати окремий файл (або кілька) під такі функції/значення. Тоді @file:JvmName("ExpenseApi") стає осмисленим: це справді «точка входу». А якщо у файлі вперемішку main, парсинг, звіти та випадкова утиліта trimAndNormalize(), то навіть ідеальне JVM‑імʼя не врятує: API виглядатиме як горище, де «ніби все корисне, але ходити страшно».

1
Опитування
Java interoperability, рівень 29, лекція 4
Недоступний
Java interoperability
Java interoperability
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ