Если 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 } даёт список только тех номеров, что уже присвоены.

список filter список №1 map список №2 sequence элемент filter map в итог

Верхний ряд на обычных коллекциях собирает новый список после каждого шага, нижний через 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 }первый подошедший или nullfindFirst().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 молча оставляет последний элемент при повторяющемся ключе. В Java Collectors.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 * bplus, minus, timesденьги, количества, векторы
a > b, a <= bcompareToсравнение значений без явного вызова
a in bcontainsпроверка попадания в свой диапазон или коллекцию
a[i]get, setдоступ по ключу у своей обёртки
a(x)invokeобъект, который вызывают как функцию
a..brangeToсвой диапазон, например по датам
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) и оправдана для денег, единиц и интервалов, а не для сущностей домена.

Что почитать дальше