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 += "TOTAL: 350"
    r += "TOP: food"

    println(r)
    // TOTAL: 350
    // TOP: food
}

Если бы мы сделали так, что 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("TOTAL", 350)
    r.kv("COUNT", 12)

    println(r)
    // TOTAL: 350
    // COUNT: 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("Expense report")
        .line("--------------")
        .line("TOTAL: 350")
        .toString()

    print(text)
    // Expense report
    // --------------
    // TOTAL: 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 = "TOTAL" eq 350
    println(p) // (TOTAL, 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 += "Expense report"
    r.money("TOTAL", this.total())
    r.kv("COUNT", this.size)
    return r
}

Проверим:

fun main() {
    val items = listOf(
        Expense("Coffee", Money(350), "food"),
        Expense("Taxi", Money(1200), "transport")
    )

    println(items.toReport())
    // Expense report
    // TOTAL: 15.50
    // COUNT: 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: Не различать + и += по смыслу «новое значение» vs «мутация».
В Kotlin += компилируется по отдельным правилам: либо plusAssign, либо “пересоздание” через a = a + b. Если вы перегружаете операторы в своём типе, держите семантику кристально ясной: plus обычно создаёт новое значение, plusAssign — меняет существующее.

Ошибка №4: Делать infix для мутации или для действий с разными ролями.
infix хорошо смотрится там, где две стороны логически симметричны (как ключ–значение или объединение множеств), и плохо — там, где это обычная команда “сделай”. Конвенции прямо советуют не делать infix‑методы, которые мутируют получателя.

Ошибка №5: Раздувать мини‑API до «свалки расширений».
Если вы складываете все расширения в один файл Extensions.kt на тысячу строк, вы получите не мини‑API, а мини‑хаос. Расширения лучше держать тематически рядом с тем, что они обслуживают (например, ReportExtensions.kt, MoneyFormatting.kt) и не плодить одинаковые имена в разных местах, чтобы потом не воевать с импортами.

Ошибка №6: Делать мини‑API, которое нельзя “прочитать глазами”.
Самая неприятная поломка читабельности — когда строка кода перестаёт быть самодостаточной. Если без знания внутренних соглашений нельзя понять, мутируем мы объект или создаём новый, тяжёлый ли это вызов, есть ли побочные эффекты — мини‑API не помогает, а мешает. Хороший слой удобства остаётся прозрачным: он сокращает шум, но не скрывает смысл.

1
Задача
Kotlin SELF, 38 уровень, 4 лекция
Недоступна
Письмо приветствия
Письмо приветствия
1
Задача
Kotlin SELF, 38 уровень, 4 лекция
Недоступна
Разбор суммы
Разбор суммы
1
Задача
Kotlin SELF, 38 уровень, 4 лекция
Недоступна
Отчёт пользователя
Отчёт пользователя
1
Задача
Kotlin SELF, 38 уровень, 4 лекция
Недоступна
Склад ячеек
Склад ячеек
1
Опрос
Extensions и операторы, 38 уровень, 4 лекция
Недоступен
Extensions и операторы
Extensions и операторы
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ