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 не помогает, а мешает. Хороший слой удобства остаётся прозрачным: он сокращает шум, но не скрывает смысл.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ