Если null-safety — главный аргумент «за» Kotlin, то работа с функциями и коллекциями — то, что делает повседневный код короче. Java-разработчику здесь знакомо многое (лямбды и Stream API появились ещё в Java 8), но Kotlin убирает церемонии: без функциональных интерфейсов, без .stream().collect(...), плюс две конструкции, которых в Java нет, — extension-функции и scope-функции.
Extension-функции: добавить метод чужому классу
fun String.toSlug(): String =
lowercase().replace(' ', '-').filter { it.isLetterOrDigit() || it == '-' }
val slug = "Kotlin для Java разработчика".toSlug() // kotlin-для-java-разработчика
Класс String не менялся — extension-функция компилируется в обычный статический метод, но вызывается как метод объекта. Это убивает целый жанр Java-кода: классы StringUtils, DateUtils, CollectionUtils со статическими методами превращаются в функции, которые IDE подсказывает прямо на объекте.
На результат посмотрите внимательно: isLetterOrDigit() считает буквой и кириллицу, поэтому русские слова останутся как есть. Для настоящего slug к такой функции добавляют ещё и транслитерацию — но чтобы показать сам механизм extension-функции, это неважно.
Важно понимать границы: extension не влезает в класс (private-члены недоступны), не участвует в полиморфизме (выбирается по объявленному типу на этапе компиляции) и живёт в области видимости импорта — двум командам с разными расширениями одного класса они не мешают.
И следствие того же порядка, из-за которого расширения иногда «перестают работать»: если у класса есть собственный метод с такой же сигнатурой, побеждает всегда он. Ваше расширение просто перестанет вызываться — молча, без предупреждения, — как только одноимённый метод появится в очередной версии библиотеки.
Лямбды без обёрток
val isEven: (Int) -> Boolean = { it % 2 == 0 }
fun retry(times: Int, action: () -> Unit) { /* ... */ }
retry(3) {
sendEvent()
}
Тип функции пишется прямо: (Int) -> Boolean — не нужен Predicate, Function или собственный интерфейс. Единственный параметр лямбды доступен как it. И конвенция, которая делает Kotlin-код узнаваемым: если лямбда — последний аргумент, она выносится за скобки. Так retry(3) { ... } читается как встроенная языковая конструкция, хотя это обычная функция.
Scope-функции: let, apply, run, also
Пять маленьких функций для локальных преобразований — let, run, with, apply, also. Повседневный код держится на первых трёх из листинга ниже, но встретить в чужом коде можно все пять. Делают они одно: берут объект, отдают его блоку кода и что-то возвращают. Разница в двух вопросах — как объект виден внутри блока (it или this) и что вернётся наружу (результат блока или сам объект). let и run возвращают результат блока и годятся для преобразований; apply и also возвращают сам объект и годятся для настройки и побочных действий; with — тот же run, только объект передают аргументом.
private val log = LoggerFactory.getLogger(OrderService::class.java)
// let — преобразовать значение или обработать не-null
val length = name?.let { it.trim().length }
// apply — настроить объект и вернуть его же
val props = Properties().apply {
setProperty("user", "app")
setProperty("ssl", "true")
}
// also — побочное действие «по дороге», объект вернётся как был
val order = createOrder().also { log.info("created {}", it.id) }
// run — как let, но объект внутри доступен как this, а не как it
val url = config.run { "$scheme://$host:$port" }
// with — то же, что run, только объект передают аргументом, а не через точку
val summary = with(order) { "$id на сумму $total" }
Первая строка листинга — не мелочь: статического поля у класса в Kotlin нет, поэтому логгер объявляют либо свойством верхнего уровня файла, как здесь, либо внутри companion object.
Ловушка — злоупотребление: вложенные let внутри apply внутри run превращают код в головоломку. Правило: одна scope-функция на выражение; сомневаетесь — пишите обычный if или локальную переменную.
Коллекции: read-only по умолчанию
val tags = listOf("kotlin", "jvm") // List<String> — только чтение
val counts = mutableMapOf("a" to 1) // изменяемая — по явному запросу
"a" to 1 — это не особый синтаксис мапы, а обычная инфиксная функция to: она делает из двух значений пару Pair, а mutableMapOf принимает пары. Записать её можно и через точку — "a".to(1), — просто инфиксная форма короче и читается как стрелка.
Интерфейсы разделены: у List нет метода add, изменяемость — отдельный тип MutableList. Это то же «val по умолчанию», но для коллекций: функция, принимающая List, сама его не поменяет — компилятор не даст.
Только «не поменяет сама» и «неизменяемый» — разные вещи, и путать их дорого. List в Kotlin — это read-only представление, а не неизменяемая коллекция. Тот же самый объект кто-то другой может держать как MutableList и менять прямо под вашей функцией; а на уровне JVM List — это обычный java.util.List, и Java-код правит его свободно.
Неизменяемых коллекций в стандартной библиотеке Kotlin нет вовсе: когда гарантия нужна настоящая, берут kotlinx.collections.immutable или заворачивают список в Collections.unmodifiableList. Обычная же дисциплина проще — не отдавать наружу ссылку на изменяемый список: внутри MutableList, наружу toList().
Операции доступны прямо на коллекции, без .stream():
val names = users
.filter { it.active }
.sortedBy { it.createdAt }
.map { it.name }
Каждый шаг здесь создаёт промежуточный список — для сотен элементов это неважно, для миллионов есть ленивые sequences: users.asSequence().filter{...}.map{...}.toList() прогоняет каждый элемент через всю цепочку сразу, не собирая промежуточные списки, — как Stream в Java.
Сходство при этом не полное: параллельного режима вроде parallelStream() у sequence нет вообще, а сама ленивость не бесплатна — на каждый элемент добавляется проход через итератор, и на короткой цепочке или маленькой коллекции обычные операции окажутся быстрее. Отсюда правило: начинайте с обычных коллекций, переходите на sequence, когда цепочка длинная, а элементов десятки тысяч и больше.
Полезная мелочь: mapNotNull — map и отбрасывание null одним шагом, идеально в паре с nullable-типами: orders.mapNotNull { it.trackingNumber } даёт список только тех номеров, что уже присвоены.
Верхний ряд на обычных коллекциях собирает новый список после каждого шага, нижний через asSequence прогоняет один элемент через filter и map сразу в результат: смотрите, сколько списков появляется в каждом случае.
Операции, за которые коллекции Kotlin и хвалят
filter, map и sortedBy есть и в Stream API — на них разница между языками не видна. Видна она там, где в Java пришлось бы собирать результат через Collectors или писать цикл.
| Операция | Что делает | В Java на том же месте |
|---|---|---|
groupBy { it.status } | Map<Status, List<Order>> | Collectors.groupingBy |
associateBy { it.id } | Map<Long, Order> — по ключу один объект | Collectors.toMap с функцией-слиянием |
associateWith { calc(it) } | ключ это элемент, значение — вычисленное | нет прямого аналога |
partition { it.isPaid } | пара списков: подошедшие и остальные | Collectors.partitioningBy |
sumOf { it.total } | сумма по вычисленному значению | mapToLong(...).sum() |
flatMap { it.items } | склеить вложенные коллекции в одну | flatMap со stream() внутри |
firstOrNull { it.isPaid } | первый подошедший или null | findFirst().orElse(null) |
chunked(500) | нарезать на куски по 500 | руками циклом |
windowed(3) | скользящее окно по три элемента | руками циклом |
zip(prices) | склеить два списка в список пар | руками по индексу |
distinctBy { it.email } | убрать дубли по вычисленному ключу | Collectors.toMap или множество ключей |
maxByOrNull { it.total } | элемент с максимумом по значению | max(Comparator.comparing(...)) |
joinToString(", ") { it.name } | строка из элементов | Collectors.joining с map |
Два примера, где выигрыш заметнее всего. Отчёт по статусам с подсчётом:
val byStatus: Map<OrderStatus, Int> = orders.groupingBy { it.status }.eachCount()
val totals: Map<OrderStatus, BigDecimal> = orders
.groupBy { it.status }
.mapValues { (_, list) -> list.sumOf { it.total } }
Нарезка на пачки для отправки в базу или во внешний сервис:
orders.chunked(500).forEach { batch -> repository.saveAll(batch) }
Три вещи, которые стоит отметить отдельно, потому что они меняют привычки:
associateByмолча оставляет последний элемент при повторяющемся ключе. В JavaCollectors.toMapв той же ситуации бросает исключение, и это разница в поведении, а не в синтаксисе: дубликаты ключей в Kotlin надо ловить самому, если они означают ошибку.- Результат — обычная коллекция, а не поток, поэтому цепочку можно прервать в любом месте, положить в переменную и посмотреть в отладчике. Это одна из причин, по которой код на коллекциях читается проще, чем на потоках.
- Для
Mapесть парные операции:filterKeys,filterValues,mapKeys,mapValues,getOrElse,getOrPut. Последняя особенно полезна: она возвращает значение по ключу, а если его нет — вычисляет, кладёт и возвращает, одним вызовом.
Array, List и специализированные массивы
Точка стыка с Java-библиотеками, где путаница возникает сразу.
val list: List<Int> = listOf(1, 2, 3) // интерфейс, размер не меняется, элементы читаются
val array: Array<Int> = arrayOf(1, 2, 3) // класс, размер фиксирован, элементы можно менять
val ints: IntArray = intArrayOf(1, 2, 3) // это int[] в байткоде, без упаковки
Разница по сути:
List— интерфейс, у него нет присваивания по индексу (для этого естьMutableList), зато есть вся богатая часть стандартной библиотеки. Это выбор по умолчанию для прикладного кода.Array<T>— класс с фиксированным размером, элементы менять можно всегда (array[0] = 5), независимо отvalу самой переменной. В байткоде этоT[].IntArray,LongArray,DoubleArray,BooleanArrayи остальные — отдельные типы, компилирующиеся в примитивные массивы Java (int[],long[]). Отличие отArray<Int>принципиальное:Array<Int>этоInteger[]с упаковкой каждого числа,IntArray— без упаковки вовсе.
Где массивы встречаются в жизни, если их специально не завести: параметр vararg внутри функции — это массив; Java-метод, возвращающий int[], приходит как IntArray; аннотации принимают массивы. Переходы между формами делаются явно:
val back: List<Int> = ints.toList()
val typed: Array<Order> = orders.toTypedArray() // для Java-API, ждущего Order[]
val primitives: IntArray = list.toIntArray()
javaApi.process(*typed) // звёздочка раскладывает массив в vararg
Правило простое: в своём коде — List; массив появляется только на границе с Java-API, которое его требует, или на горячем пути с числами, где упаковка действительно мешает.
Как отдавать коллекцию наружу
Самая частая ошибка с коллекциями в Kotlin выглядит безобидно:
class Cart {
val items: MutableList<Item> = mutableListOf()
fun add(item: Item) {
require(items.size < 50) { "в корзине не больше 50 позиций" }
items.add(item)
}
}
Проверка в add выглядит как защита инварианта, но не защищает ничего:
cart.items.clear() // обошли класс целиком
cart.items.add(Item("что угодно")) // проверку на 50 позиций тоже
Причина в том, что val items — это неизменяемая ссылка на изменяемый список. Наружу отдан сам список, и с ним можно делать что угодно, минуя все методы класса.
Правильная форма — два разных типа на одно хранилище:
class Cart {
private val _items = mutableListOf<Item>()
val items: List<Item> get() = _items
fun add(item: Item) {
require(_items.size < 50) { "в корзине не больше 50 позиций" }
_items.add(item)
}
}
Изменяемый список приватный, наружу видно только чтение. Имя с подчёркиванием — распространённая договорённость именно для этой пары «приватное хранилище и публичное представление».
Одна оговорка, которая отличает это от настоящей защиты: items возвращает то же самое хранилище, просто через интерфейс только для чтения. Вызывающий из Kotlin поменять его не сможет, а вот привести обратно к MutableList технически возможно, и Java-код видит обычный java.util.List и правит его свободно. Когда нужна гарантия, а не дисциплина, отдают копию:
val items: List<Item> get() = _items.toList()
Копия стоит прохода по списку на каждое обращение, поэтому её берут там, где коллекция небольшая и цена ошибки высока. Правило выбора: представление — по умолчанию, копия — на границе модуля или публичного API.
operator: свои имена для привычных операций
Расширения добавляют классу метод, а операторы — синтаксис. В Kotlin перегрузка операторов есть, но не произвольная: набор имён фиксирован, и каждое привязано к своему знаку.
data class Money(val cents: Long) {
operator fun plus(other: Money) = Money(cents + other.cents)
operator fun times(n: Int) = Money(cents * n)
operator fun compareTo(other: Money) = cents.compareTo(other.cents)
operator fun unaryMinus() = Money(-cents)
}
val total = Money(1000) + Money(250) * 2
if (total > Money(1000)) applyDiscount()
Самые полезные в прикладном коде:
| Оператор | Функция | Зачем |
|---|---|---|
a + b, a - b, a * b | plus, minus, times | деньги, количества, векторы |
a > b, a <= b | compareTo | сравнение значений без явного вызова |
a in b | contains | проверка попадания в свой диапазон или коллекцию |
a[i] | get, set | доступ по ключу у своей обёртки |
a(x) | invoke | объект, который вызывают как функцию |
a..b | rangeTo | свой диапазон, например по датам |
for (x in a) | iterator | обход своего типа циклом |
Два ограничения и одно правило. Ограничения: новых знаков придумать нельзя — можно только привязать смысл к существующим, и equals среди них особый случай (он и так есть у каждого класса). Правило: оператор оправдан только когда операция действительно арифметическая или контейнерная по смыслу. order + item выглядит остроумно и читается плохо, потому что складывать заказ с позицией — не сложение. Деньги, единицы измерения, интервалы дат, матрицы — да; сущности домена — нет.
Отдельно стоит заметить, что почти всё привычное в языке устроено этим же механизмом: in у коллекций — это contains, map["key"] — это get, разбор data class на части — это функции component1 и component2, а делегирование свойства через by — это getValue и setValue. Никакого особого синтаксиса под них нет: те же операторные функции, объявленные в стандартной библиотеке.
Инфиксная запись из раздела про коллекции — соседний механизм, но другой: to не оператор, а обычная функция с модификатором infix, поэтому её и можно вызвать через точку. Разница практическая: имена операторов фиксированы и работают со знаками, инфиксных функций можно объявить сколько угодно, но вызываются они по имени.
Глубже: inline, reified и нелокальный return: почему лямбды дешёвыерасширенное
«Лямбды без обёрток» выше это обещание, и держится оно на одном модификаторе. В Java лямбда это объект: вызов list.filter { it > 0 } создал бы экземпляр функционального интерфейса и виртуальный вызов на каждый элемент. В Kotlin filter, map, forEach, let, apply и почти вся стандартная библиотека объявлены как inline fun: компилятор подставляет тело функции и тело лямбды прямо в место вызова, и объекта нет, как нет и вызова. Цена это размер байткода, поэтому inline ставят на маленькие функции, принимающие лямбды, а не на всё подряд.
Из подстановки следуют две вещи, которых в Java нет. Нелокальный return. Раз тело лямбды вставлено в вызывающую функцию, return внутри неё это выход из вызывающей функции, а не из лямбды: orders.forEach { if (it.isPaid) return } завершает метод, в котором стоит forEach. Чтобы выйти только из лямбды, пишут return@forEach. В не-inline лямбдах (переданных в обычную функцию) return без метки запрещён компилятором, и это первое, обо что спотыкаются после Java. Параметр, который нельзя подставлять (лямбда сохраняется или вызывается в другом потоке), помечают noinline; crossinline разрешает подстановку, но запрещает нелокальный return.
reified. В Java тип-параметр стирается, и T::class или x is T внутри обобщённого метода невозможны. Inline-функция знает конкретный T в каждом месте вызова, и модификатор reified делает его доступным:
inline fun <reified T> Any.asOrNull(): T? = this as? T
val order = payload.asOrNull<Order>() // без Class<T> параметром
val mapper = jacksonObjectMapper()
val list: List<Order> = mapper.readValue(json) // readValue с reified знает List<Order>
Так работают readValue в Jackson для Kotlin, getBean<T>() в расширениях Spring и filterIsInstance<T>() в стандартной библиотеке. Ограничение то же, что у inline: функция с reified не может быть вызвана из Java и не может быть виртуальной. Это и есть главный выигрыш над дженериками Java: не новая система типов, а возможность не стирать тип там, где функция всё равно подставляется.
Глубже: делегирование: by lazy, интерфейс через by и делегаты свойстврасширенное
Слово by в Kotlin означает «поручить другому», и стоит за тремя разными приёмами, которые в Java пишут руками.
Ленивое свойство. val config: Config by lazy { loadConfig() } вычисляет значение при первом обращении и запоминает; по умолчанию потокобезопасно (два потока получат одно значение), режим можно ослабить LazyThreadSafetyMode.NONE там, где обращения из одного потока. Это замена приватному полю с проверкой на null и двойной блокировкой из Java.
Делегирование интерфейса. class CachingRepository(private val inner: OrderRepository) : OrderRepository by inner реализует все методы интерфейса пересылкой в inner, а переопределяют только те, что нужны. Декоратор без десяти методов-пересылок: кэш, журналирование, метрики вокруг репозитория пишутся в пять строк. Ограничение: делегировать можно интерфейс, а не класс, и делегат фиксируется при создании.
Делегаты свойств. За by может стоять любой объект с операторами getValue и setValue: Delegates.observable вызывает обработчик при каждом изменении, Delegates.vetoable позволяет отклонить новое значение, Delegates.notNull даёт свойство, которое обязательно заполнят до чтения (аналог lateinit для примитивов), by map читает свойства из Map<String, Any?> по имени. Свой делегат это класс на пять строк, и через него делают, например, свойство, которое читается из настроек и перечитывается при их смене.
Что видит Java: делегат хранится в поле с именем вроде config$delegate, а свойство отдаётся обычным геттером, так что для вызывающего кода делегирование невидимо.
Коротко
- Extension-функции добавляют методы чужим классам без наследования: конец эпохи Utils-классов; но это статика — без полиморфизма и доступа к private, и одноимённый метод самого класса всегда побеждает расширение.
- Типы функций пишутся напрямую (
(Int) -> Boolean), лямбда последним аргументом выносится за скобки. Scope-функции:let— преобразование/не-null,apply— настройка объекта,also— побочное действие,runиwith— то же, чтоlet, но объект внутри доступен какthis; больше одной на выражение — уже перебор. - Коллекции read-only по умолчанию, изменяемость — отдельные Mutable-типы. Но read-only — это представление, а не гарантия: тот же объект могут менять через
MutableListили из Java. - Для больших данных и длинных цепочек —
asSequence(): ленивость как у Stream, но без параллельного режима и с накладными расходами на коротких цепочках. inlineподставляет функцию и лямбду в место вызова: нет объекта и вызова,returnвнутри лямбды выходит из вызывающей функции (return@labelдля выхода из лямбды),reified Tдаёт тип без стирания.byпоручает другому:by lazyдля ленивого значения,: Interface by innerдля декоратора без пересылок, делегаты свойств (observable,vetoable,notNull,map) черезgetValue/setValue.- Настоящий выигрыш над Stream API дают
groupBy,associateBy,partition,sumOf,flatMap,chunked,zip,distinctBy,joinToStringи парные операции для отображений; результат — обычная коллекция, а не поток. associateByпри повторяющемся ключе молча оставляет последний элемент, тогда какCollectors.toMapв Java бросает исключение — разница в поведении, а не в записи.List— интерфейс без записи по индексу,Array<T>— класс фиксированного размера с изменяемыми элементами,IntArrayи родственники компилируются в примитивные массивы без упаковки; массив заводят только для границы с Java или для чисел на горячем пути.- Коллекцию наружу отдают через приватное изменяемое хранилище и публичное
List-представление, иначеcart.items.clear()обходит все проверки класса; где нужна гарантия, а не дисциплина, отдают копию. - Перегрузка операторов работает по фиксированному списку имён (
plus,compareTo,contains,get,invoke,rangeTo,iterator) и оправдана для денег, единиц и интервалов, а не для сущностей домена.
Что почитать дальше
- Корутины и virtual threads — следующий шаг маршрута.
- Лямбды и Stream API в Java — функциональная Java для сравнения.
- Коллекции Java — устройство ArrayList и HashMap: в Kotlin под капотом те же классы.