JavaRush /Курси /Kotlin SELF /Міні‑API поверх розширень і операторів

Міні‑API поверх розширень і операторів

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

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 не допомагає, а заважає. Хороший шар зручності лишається прозорим: він скорочує шум, але не приховує сенс.

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