1. Вступ
Міні‑API — це невеликий набір функцій, розширень і (інколи) операторів, який наближає код до предметної області. Не «до Kotlin», не «до модних DSL», а до того, що ви справді робите: додаєте витрати, рахуєте підсумок, будуєте звіт, форматуєте виведення. Це шар, який прибирає повторення й робить main схожим на сценарій, а не на звалище деталей.
Дуже легко сплутати міні‑API з ідеєю «пишемо власну мову програмування на Kotlin». Але мета значно скромніша: щоб наступна людина (і ви за два тижні) могла відкрити файл і без здогадок зрозуміти, що відбувається. Без вгадувань на кшталт «цей оператор плюс додає числа чи запускає ракету». Тому ми весь час перевірятимемо себе запитанням: за рядком коду зрозуміло, що станеться?
Уявімо, що ми продовжуємо навчальний консольний застосунок для обліку витрат (умовний ExpenseTracker). Раніше в нас, імовірно, був список MutableList<Expense>, команди add/list/remove і кілька звітів. Тепер ми хочемо зробити так, щоб звіти збиралися читабельно й однаково, — і тут міні‑API дуже доречний.
Три правила: «дешево», «без сюрпризів», «за назвою видно сенс»
Коли ви починаєте додавати розширення й оператори, зʼявляється спокуса «сховати» деталі так глибоко, що код стає гарним, але перестає бути чесним. Kotlin‑конвенції дають простий орієнтир: якщо щось виглядає як властивість, воно має бути дешевим і без несподіванок; якщо дія помітна — краще нехай буде функцією. Це правило особливо корисне саме тут, бо міні‑API майже завжди будується на «дрібницях», які викликаються часто.
Оформімо ці три правила як чек‑лист:
| Інструмент | Коли доречний | Очікування читача | Типові «погані сюрпризи» |
|---|---|---|---|
| Extension‑функція fun X.foo() | Повторюваний крок обробки, форматування, невелика бізнес‑операція | Це дія — її можна читати як дієслово | foo робить надто багато, лізе в I/O, мутує все підряд |
| Extension‑властивість val X.bar | Похідне значення, легка перевірка | Це «як поле»: швидко, стабільно, без побічних ефектів | get() сортує список, друкує в консоль або ходить у мережу |
| Оператор operator fun plus/compareTo/get/set | Загальноприйнятий сенс: додавання, порівняння, індексація | Символ має читатися однозначно | + мутує обʼєкт, [] робить «додати, якщо немає», compareTo порівнює «як заманеться» |
| infix fun a foo b | Рідко й точково: дві ролі «майже рівні» (як to) | Схоже на міні‑фразу | Використовувати для мутації або там, де звичайна функція зрозуміліша |
Про infix окремо корисно памʼятати формальні обмеження: в infix‑функції має бути рівно один параметр — без vararg і без значення за замовчуванням. Також у infix‑викликів є свій пріоритет, тож вирази без дужок інколи читаються не так, як виконуються.
2. Міні‑модель і звіт
Модель: Expense і Money
Щоб приклади не були розрізненими, домовімося про невеликі типи, навколо яких будуватимемо міні‑API. Ми не заглиблюємося в архітектуру й шари — просто тримаємо код акуратним і предметним.
Почнемо з грошей. Із попередніх тем ми вже знаємо value class, тож нехай гроші зберігаються в центах, щоб не страждати від Double у фінансах:
@JvmInline
value class Money(val cents: Long)
І витрата:
data class Expense(
val title: String,
val amount: Money,
val category: String
)
Зараз категорія — рядок, і це свідомий вибір. Так, у нас уже є enum, але сьогодні ми не про модель, а про міні‑API поверх виведення. Категорії можна буде поліпшувати окремо, а наш шар зручності має пережити і рядок, і enum.
Тепер вирішімо, як ми будуватимемо звіт. Найпростіший варіант — накопичувати рядки, а потім joinToString("\n"). Але нам хочеться, щоб додавання рядка виглядало однаково всюди, а форматування було узгодженим. Для цього заведемо невеликий клас‑накопичувач Report.
Report як накопичувач: де доречний +=
Оператор += часто сприймається як «це точно про зміну». І справді: за правилами операторних угод a += b перетворюється на a.plusAssign(b), якщо такий метод є, і компілятор очікує результат Unit. Це дуже добре підходить для накопичувача: він існує для того, щоб у нього додавали.
Зробімо Report, який накопичує рядки:
class Report {
private val lines = mutableListOf<String>()
operator fun plusAssign(line: String) {
lines.add(line)
}
override fun toString(): String = lines.joinToString("\n")
}
Тут важливий момент — передбачуваність. Report — мутабельний обʼєкт‑контейнер. Тому += справді означає «додай усередину». Це схоже на те, як += працює в MutableList: для мутабельних колекцій він додає елементи «на місці».
Перевіримо на міні‑прикладі:
fun main() {
val r = Report()
r += "РАЗОМ: 350"
r += "ТОП: їжа"
println(r)
// РАЗОМ: 350
// ТОП: їжа
}
Якби ми зробили так, що r + "..." мутує r, це було б гірше: + зазвичай читається як «створити нове значення» (як у List + element, де повертається нова колекція). Тому правило просте: мутація — plusAssign або явний метод; створення нового — plus.
Розширення для Report: менше копіпасту, більше єдиного стилю
Коли зʼявляється Report, наступна спокуса — писати всюди рядки вручну: "TOTAL: ${...}", "COUNT: ${...}" тощо. Проблема не в тому, що це складно, а в тому, що дуже швидко починає діяти принцип «кожен як хоче»: десь двокрапка, десь тире, десь зайві пробіли, десь різні регістри. Міні‑API потрібен для того, щоб стиль був єдиним і відтворюваним.
Додамо extension‑функцію, яка друкує пару ключ–значення в одному стилі:
fun Report.kv(key: String, value: Any?) {
this += "$key: $value"
}
Зверніть увагу: ми свідомо не робимо infix і не робимо оператор. Це звичайна дія «додай рядок», і назва kv (key-value) коротка, але в контексті звіту все одно читабельна.
Використання:
fun main() {
val r = Report()
r.kv("РАЗОМ", 350)
r.kv("КІЛЬКІСТЬ", 12)
println(r)
// РАЗОМ: 350
// КІЛЬКІСТЬ: 12
}
А тепер додамо трохи «доменних» функцій. Наприклад, друк грошей: ми не хочемо щоразу памʼятати про центи.
fun Money.format(): String = "%.2f".format(cents / 100.0)
І використаємо це в Report:
fun Report.money(key: String, value: Money) {
this += "$key: ${value.format()}"
}
Тепер виведення грошей стабільне, і main не містить зайвої «арифметики на решту».
3. Форматування й зручний синтаксис
StringBuilder‑міні‑API: ланцюжок без магії
Іноді зручніше збирати звіт не через окремий клас, а просто в рядок. Kotlin для цього зазвичай використовує StringBuilder і підхід «зібрали → повернули рядок». Для великих текстів StringBuilder корисніший, ніж конкатенація в циклі. Це хороший момент, щоб зробити міні‑API на ньому.
Зробімо extension‑функцію, яка додає рядок із переходом на новий рядок і повертає this для ланцюжка:
fun StringBuilder.line(text: String): StringBuilder =
append(text).append('\n')
Тепер збирання виглядає як «потік рядків»:
fun main() {
val text = StringBuilder()
.line("Звіт про витрати")
.line("--------------")
.line("РАЗОМ: 350")
.toString()
print(text)
// Звіт про витрати
// --------------
// РАЗОМ: 350
}
Чому це хороший міні‑API, а не «перебір»? Бо сенс очевидний: line("...") додає рядок. Немає прихованої логіки, немає побічних ефектів, немає несподіваної мутації чогось поза StringBuilder. Ланцюжок читається зліва направо як сценарій, але лишається чесним.
infix точково: коли це справді читабельно
infix виглядає гарно, але підступний тим, що його хочеться застосовувати «всюди». Конвенції радять оголошувати infix лише тоді, коли дві сторони «схожі за роллю», як у to або zip, і уникати infix для мутації.
Нам інколи зручно записувати пари «ключ–значення», особливо якщо ми хочемо потім перетворити їх на Map. Наприклад, у звітах ми можемо спочатку зібрати дані, а потім вивести.
Зробімо infix eq, який будує Pair:
infix fun String.eq(value: Any?): Pair<String, Any?> =
this to value
Використання:
fun main() {
val p = "РАЗОМ" eq 350
println(p) // (РАЗОМ, 350)
}
Тут infix виправданий: рядок ліворуч — це «ключ», значення праворуч — це «значення», ролі доволі симетричні. До того ж це не мутація, а створення значення.
Практичний бонус: з масиву або списку Pair можна зробити Map через toMap(). Але важливо памʼятати: ключі мають бути унікальними (останній перезапише перший).
Міні‑API для ExpenseTracker: звіт зі списку витрат
Тепер зберемо все в одну невелику історію: у нас є список витрат, і ми хочемо побудувати короткий текстовий звіт. Не «мега‑система», а акуратна функція, яку можна викликати з main.
Зробімо кілька маленьких extension‑функцій для списку витрат. Так, це «API поверх колекцій», і Kotlin сам активно так робить: величезна частина стандартної бібліотеки — це extension‑функції.
Порахуємо підсумок за всіма витратами:
fun List<Expense>.total(): Money {
var sum = 0L
for (e in this) sum += e.amount.cents
return Money(sum)
}
І зробімо звіт:
fun List<Expense>.toReport(): Report {
val r = Report()
r += "Звіт про витрати"
r.money("РАЗОМ", this.total())
r.kv("КІЛЬКІСТЬ", this.size)
return r
}
Перевіримо:
fun main() {
val items = listOf(
Expense("Кава", Money(350), "їжа"),
Expense("Таксі", Money(1200), "транспорт")
)
println(items.toReport())
// Звіт про витрати
// РАЗОМ: 15.50
// КІЛЬКІСТЬ: 2
}
Чому це хороший шар міні‑API?
- По‑перше, main майже не містить деталей: він каже «у мене є витрати — зроби звіт».
- По‑друге, toReport() — промовиста функція, і з назви зрозуміло, що повернеться обʼєкт Report.
- По‑третє, міні‑API невеликий: total(), toReport(), Report.kv(), Report.money(), Money.format().
Це не «універсальна бібліотека всього», а точкові зручності під конкретну задачу.
4. Небезпечні місця й як обирати інструмент
Де міні‑API стає небезпечним
Коли ви відчуєте смак, зʼявиться бажання: «а давайте зробимо так, щоб будь‑яка дія виглядала як одна кнопка». І тут Kotlin починає перевіряти вашу дорослість як розробника.
Наприклад, погана ідея — зробити extension‑властивість val List<Expense>.report: String, яка всередині будує звіт, сортує, групує й форматує. Вона виглядає як поле, але робить багато роботи. Конвенції рекомендують: властивість доречна, якщо обчислення дешеве, не кидає винятків і дає стабільний результат за того самого стану. Якщо ваш звіт може бути дорогим — це має бути функція (fun buildReport()), щоб читач очікував «виконання роботи».
Ще один тонкий момент — оператори колекцій. Багато хто бачив list + element і думає, що це «додай у список». Але для read‑only колекцій + повертає новий список, а += для var‑змінної може бути переприсвоєнням результату, а для MutableList — мутацією на місці. Це не проблема, якщо ви це розумієте, але в міні‑API краще не змушувати читача здогадуватися. Тому ми й зробили Report накопичувачем із plusAssign, де сенс однозначний.
І нарешті, infix. Так, a foo b виглядає гарно. Але в infix є пріоритет, і вирази без дужок інколи читаються не так, як виконуються. Тому infix варто тримати для дуже простих «парних» операцій, а все інше залишати звичайним функціям.
Як обрати: extension / operator / infix
Щоб не тримати все це в голові як «пʼятдесят правил», зручно мати простий мисленнєвий алгоритм. Уявіть, що ви хочете додати зручність, і поставте собі запитання в такому порядку:
1) Це виглядає як "читання властивості"?
Так → роби val (або extension-val), але лише якщо дешево і без побічних ефектів.
Ні → йди далі.
2) Це дія зі зрозумілим дієсловом?
Так → функція (звичайна або extension).
Ні → йди далі.
3) Для цього є загальноприйнятий символ (+, [], порівняння)?
Так → operator, але лише якщо сенс однозначний.
Ні → йди далі.
4) Дві сторони "майже рівні" й читатиметься як фраза?
Так → infix (рідко й акуратно).
Ні → звичайна функція, і не муч себе.
Це не «закон Kotlin», а практична звичка, яка рятує від коду, схожого на магію. А магія в продакшені погана тим, що вона працює рівно до першого бага. Потім ви раптово дізнаєтеся, що «чарівна паличка» насправді була палицею з двома кінцями.
5. Типові помилки
Помилка № 1: «Зроблю оператор, бо можу».
Оператори потрібні не для краси, а для передбачуваності. Якщо a + b не читається як «додати», а obj[i] не читається як «дістати за індексом/ключем», ви робите код гіршим. У таких місцях звичайний метод (addExpense, appendLine) майже завжди виграє.
Помилка № 2: ховати важку роботу в extension‑властивість.
Властивість виглядає як швидкий доступ до даних, тому читач не очікує сортування, групування, побудови звіту й тим більше — винятків. Конвенції рекомендують використовувати властивість лише для дешевих, стабільних обчислень. Якщо робота помітна — робіть функцією.
Помилка № 3: не розрізняти + і += за сенсом «нове значення» проти «мутації».
У Kotlin += компілюється за окремими правилами: або plusAssign, або «створення наново» через a = a + b. Якщо ви перевантажуєте оператори у своєму типі, тримайте семантику кристально ясною: plus зазвичай створює нове значення, plusAssign — змінює наявне.
Помилка № 4: робити infix для мутації або для дій із різними ролями.
infix добре виглядає там, де дві сторони логічно симетричні (як ключ–значення або обʼєднання множин), і погано — там, де це звичайна команда «зроби». Конвенції прямо радять не робити infix‑методи, які мутують отримувача.
Помилка № 5: роздувати міні‑API до «звалища розширень».
Якщо ви складаєте всі розширення в один файл Extensions.kt на тисячу рядків, ви отримаєте не міні‑API, а міні‑хаос. Розширення краще тримати тематично поруч із тим, що вони обслуговують (наприклад, ReportExtensions.kt, MoneyFormatting.kt), і не плодити однакові назви в різних місцях, щоб потім не воювати з імпортами.
Помилка № 6: робити міні‑API, яке не можна «прочитати очима».
Найнеприємніша поломка читабельності — коли рядок коду перестає бути самодостатнім. Якщо без знання внутрішніх домовленостей не можна зрозуміти, мутуємо ми обʼєкт чи створюємо новий, чи важкий це виклик, чи є побічні ефекти, — міні‑API не допомагає, а заважає. Хороший шар зручності лишається прозорим: він скорочує шум, але не приховує сенс.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ