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 |
|---|---|---|
|
поле + getX() | публічне поле X (гетера немає) |
|
поле + getX() + setX(...) | публічне поле X (без аксесорів) |
|
тільки 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 виглядатиме як горище, де «ніби все корисне, але ходити страшно».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ