Kotlin: продвинутые техники функционального программирования

Введение

Сфера разработки ПО постоянно меняется, от разработчиков требуется не просто адаптироваться, а развиваться. Kotlin с его выразительным синтаксисом и надежным функционалом быстро стал для многих верным союзником.

Хотя привлекательность его обусловлена лаконичным синтаксисом и совместимостью с Java, реальная сила Kotlin кроется в глубоких возможностях функционального программирования.

С освоением этих техник у разработчика меняется подход к проблемам, проработка решений и даже восприятие кода.

Подробно рассмотрим продвинутое функциональное программирование на Kotlin, реальными примерами прокачаем навыки написания кода, начинающих же заинтересуем задачами и устремлениями современных разработчиков.

Функциональный базис Kotlin

Функциональное программирование Kotlin основывается на концепциях неизменяемости и отношения к функциям как объектам первого класса.

1. Неизменяемые структуры данных

Базовый синтаксис

В Kotlin ключевым словом val обозначается переменная только для чтения. Сама переменная неизменяемая, а вот данные, на которые ею указывается, могут быть изменяемыми. Поэтому в Kotlin имеются и неизменяемые коллекции:

val readOnlyList = listOf("a", "b", "c")

Реальный пример

Рассмотрим типичное приложение для электронной коммерции. Чтобы предотвратить случайные изменения при отображении списка прошлых заказов, безопаснее сделать его неизменяемым:

data class Order(val orderId: Int, val product: String, val price: Double)

// Извлекаем это из базы данных или API
val userOrders: List<Order> = fetchOrdersFromDatabase()

// Чтобы предоставить пользователю скидку, вместо изменения исходного списка
// создаем новый с обновленными ценами.
val discountedOrders = userOrders.map { order ->
if (order.price > 100.0) {
order.copy(price = order.price * 0.9) // скидка 10 %
} else {
order
}
}

2. Функции первого класса

Базовый синтаксис

В Kotlin функции первого класса присваиваются переменным, передаются как аргументы или возвращаются из других функций:

fun greet(name: String) = "Hello, $name!"
val greetingFunction: (String) -> String = ::greet
println(greetingFunction("Bob")) // Выводится: «Hello, Bob!»

Реальный пример

В ПО графической визуализации к изображению применяются различные эффекты: размытие, резкость, цветовая инверсия. Поскольку функции считаются объектами первого класса, эти эффекты представляются функциями и по-разному сочетаются:

fun blur(image: Image): Image = ...
fun sharpen(image: Image): Image = ...
fun invertColors(image: Image): Image = ...

val effects = listOf(::blur, ::sharpen, ::invertColors)

// Применяем к изображению один за другим все эффекты
val processedImage = effects.fold(originalImage) { img, effect -> effect(img) }

Продвинутые функции для работы с коллекциями

Для коллекций в Kotlin имеется разнообразный функционал. От применения нюансов этих функций значительно повышается ясность и эффективность кода.

1. Преобразования с «map» и «flatMap»

Базовый синтаксис

С map и функцией преобразования преобразовывается каждый элемент коллекции, с flatMap коллекции преобразовываются и упрощаются.

val numbers = listOf(1, 2, 3)
val squared = numbers.map { it * it } // [1, 4, 9]

Реальный пример

Доменные имена из списка строк с потенциальными URL-адресами  —  при том, что не каждая строка является допустимым URL-адресом,  —  извлекаются с помощью flatMap:

val potentialUrls = listOf("https://example.com/page", "invalid-url", "https://another-example.com/resource")

val domains = potentialUrls.flatMap { url ->
runCatching { URL(url).host }.getOrNull()?.let { listOf(it) } ?: emptyList()
}
// Результат: ["example.com", "another-example.com"]

2. Фильтрация с «filter» и «filterNot»

Базовый синтаксис

В filter возвращается список элементов, соответствующих заданному предикату, в filterNot  —  не соответствующих.

val numbers = listOf(1, 2, 3, 4, 5)
val evens = numbers.filterNot { it % 2 == 0 } // [1, 3, 5]

Реальный пример

Отфильтруем продукты не по одному, а по нескольким динамическим условиям: ценовому диапазону, рейтингу, доступности.

data class Product(val id: Int, val price: Double, val rating: Int, val isAvailable: Boolean)

val products = fetchProducts() // Так извлекается список продуктов

val filteredProducts = products.filter { product ->
product.price in 10.0..50.0 && product.rating >= 4 && product.isAvailable
}

3. Аккумулирование с «fold» и «reduce»

fold и reduce применяются в операциях аккумулирования, но в разных целях и сценариях.

Начнем с fold:

  • Цель  —  выполнение операции над элементами коллекции, принимаются значение исходного аккумулятора и операция объединения. Возможна работа с коллекциями любого типа, не только числового.
  • Базовый синтаксис
val numbers = listOf(1, 2, 3, 4)
val sumStartingFrom10 = numbers.fold(10) { acc, number -> acc + number } // Результат: 20
  • Пример  —  конкатенация строк с исходным значением:
val words = listOf("apple", "banana", "cherry")
val concatenated = words.fold("Fruits:") { acc, word -> "$acc $word" }
// Результат: "Fruits: apple banana cherry"

Теперь reduce:

  • Цель  —  аналогична fold, но значение исходного аккумулятора не требуется. Исходный аккумулятор  —  первый элемент коллекции.
  • Базовый синтаксис
val numbers = listOf(1, 2, 3, 4)
val product = numbers.reduce { acc, number -> acc * number } // Результат: 24
  • Пример  —  объединение пользовательских структур данных, например, в сценарии объединения диапазонов:
val ranges = listOf(1..5, 3..8, 6..10)
val combinedRange = ranges.reduce { acc, range -> acc.union(range) }
// Результат: 1..10

Ключевые отличия

  1. Исходное значение
  • В fold принимается явное значение исходного аккумулятора.
  • В reduce за исходное значение принимается первый элемент коллекции.

2. Применимость

  • fold применим к коллекциям любого размера, в том числе пустым  —  из-за значения исходного аккумулятора.
  • В reduce выбрасывается исключение в пустых коллекциях  —  ввиду отсутствия исходного значения для начала операции.

3. Гибкость

  • fold гибче: им определяется исходное значение, которое может отличаться от элементов коллекции по типу.
  • В reduce имеется ограничение типа: аккумулятор и элементы коллекции должны быть одного типа.

4. Разделение с «groupBy» и «associateBy»

Базовый синтаксис

В groupBy возвращается карта с группировкой элементов по результатам функции селектора ключа, а в associateBy  —  карта, где каждый элемент является ключом в соответствии с предоставленным селектором ключей.

val words = listOf("apple", "banana", "cherry")
val byLength = words.groupBy { it.length } // {5=[apple], 6=[banana, cherry]}

Реальный пример

data class Student(val id: String, val name: String, val course: String)

val students = fetchStudents()

// В «students» содержится:
// Student("101", "Alice", "Math"), Student("101", "Eve", "History"), Student("102", "Bob", "Science")

val studentsById = students.associateBy { it.id }
// В итоговой карте будет:
// {"101"=Student("101", "Eve", "History"), "102"=Student("102", "Bob", "Science")}

Здесь в Eve перезаписана Alice, так как у обеих имеется идентификатор "101". В итоговой карте сохраняются только сведения об Eve  —  последней записи с этим идентификатором в списке.

Ключевые отличия

  • В groupBy создается Map, где каждым ключом указывается на список List элементов исходной коллекции.
  • В associateBy создается Map, где каждым ключом указывается на один элемент исходной коллекции. Имеющиеся дубли перезаписываются последним.

В groupBy сохраняются все элементы с одинаковым ключом, в associateBy  —  только последний.

Композиция функций в Kotlin

Представьте сборочную линию фабрики игрушек, где на каждом участке игрушка подвергается определенным изменениям.

В программировании, особенно на Kotlin, здесь две функции соединяются в цепочку: результат первой становится входными данными для второй.

На фабрике всего три участка:

  1. Участок покраски А.
  2. Участок B, где к покрашенной игрушке приделываются колесики.
  3. Участок C, где на игрушку с колесиками наклеивается стикер.

Представим эти участки функциями Kotlin, каждой из которых последовательно выполняется конкретная задача:

fun paint(toy: Toy): Toy { /* окрашивание игрушки и ее возвращение */ }
fun attachWheels(toy: Toy): Toy { /* приделывание к игрушке колесиков и ее возвращение */ }
fun placeSticker(toy: Toy): Toy { /* наклеивание на игрушку стикера и ее возвращение */ }

Чтобы автоматизировать процесс плавного перемещения игрушки от одного участка к другому, определим функцию compose:

infix fun <A, B, C> ((B) -> C).compose(g: (A) -> B): (A) -> C {
return { x -> this(g(x)) }
}

Функцией compose объединяется два участка-функции, так что выходные данные одной становятся входными следующей.

Определим функцией compose автоматизированную сборочную линию игрушек:

val completeToyProcess = ::placeSticker compose ::attachWheels compose ::paint

Помещая необработанную игрушку в этот completeToyProcess, мы ее автоматически окрашиваем, приделываем колесики, наклеиваем стикер.

Пример в действии

val rawToy = Toy()
val finishedToy = completeToyProcess(rawToy)

Здесь из необработанной игрушки rawToy по завершении всего процесса получается готовая finishedToy: окрашенная, с колесиками и наклейкой  —  и все это в одной плавной операции.

Преимущества такого подхода

  1. Наглядность аналогии фабрики игрушек: весь процесс здесь как на ладони.
  2. Гибкость: если нужен другой результат, последовательность изменений легко поменять или добавить/удалить участок-функцию.
  3. Эффективность: не нужно сохранять игрушку после каждого изменения, она просто движется дальше по сборочной линии.

Что важно?

  1. Последовательность: стикер не наклеивается на игрушку до ее покраски, так же важна и последовательность соединяемых функций.
  2. Простота: если сборочная линия или цепочка функций слишком длинная, ее трудно контролировать. Важен баланс.

Каррирование и поэтапное принятие решений

Теперь мы в кофейне с многоступенчатым выбором: вместо готового напитка сначала выбираются кофейные зерна, затем молоко или его альтернатива и, наконец, вкусовые/ароматические добавки или топпинги.

Что, если мы предпочитаем арабику, а остальное  —  по настроению? В кофейне, согласно этому подходу, наше предпочтение запоминается: так снижается усталость от принятия решений, экономится время на что-то важное.

Это как каррирование в программировании.

Разберем подробно

  1. Упрощение сложных решений: подобно кофе с многоэтапным выбором, имеются многопараметрические функции. Каррированием они разбиваются на цепочку функций попроще, каждой из которых принимается один аргумент и возвращается следующая, вызываемая с другим аргументом функция.
  2. Запоминание предпочтений: каррированием «запоминаются» определенные решения, то есть аргументы функции. В примере с кофе запоминается предпочтение арабики, остается выбрать остальное.
  3. Фокус на важном: иногда заранее известна не вся информация, с каррированием решения принимаются по мере ее доступности. Это все равно что, выбрав зерна за неделю до, определяться с молоком и ароматом кофе уже на кассе.

В коде

Это функция заказа кофе:

fun orderCoffee(bean: String, milk: String, flavor: String): Coffee { ... }

С каррированием она становится такой:

fun orderCoffee(bean: String): (String) -> (String) -> Coffee { ... }

В каррированной функции сначала выбирается только арабика:

val arabicaOrder = orderCoffee("Arabica")

Затем молоко и ароматы:

val myCoffee = arabicaOrder("Almond Milk")("Vanilla")

Чем хорошо каррирование?

  1. Модульность: каррированием обеспечивается модульный дизайн, а значит, возможность сфокусироваться на одной части логики.
  2. Переиспользуемость: по аналогии с предпочтением арабики, каррированием «запоминаются» решения, обеспечивается переиспользуемость кода.
  3. Динамическое создание функций: по контексту или пользовательским предпочтениям, «на лету» создаются специализированные функции.

Благодаря каррированию разработчики создают более адаптируемые, модульные, пользователе-центричные системы.

Подобно кофейне, где процесс адаптируется под клиента, каррированием процесс вызова функций подстраивается под задачи разработчика.

Монады  —  система безопасности программирования

Теперь мы собираем мебель, каждый этап инструкции зависит от предыдущего. Не всегда все гладко: случаются ошибки, недостает деталей.

Что, если бы в инструкции имелась система безопасности? Например, если что-то не так закрепить, мгновенно срабатывает предупреждение. Или, если недостает детали, предлагается решение.

В программировании такая система безопасности  —  монады.

Что такое «монады»?

  1. Подобно сборке мебели с последовательностью зависимых этапов, операции в программировании  —  это обычно цепочка, каждое звено которой зависит от предыдущей.
  2. Монады  —  механизм безопасности, которым обеспечивается, что, если этап не пройден или в нем не выдается допустимое значение, последующие этапы «знают» об этом и соответственно «реагируют».
  3. Инкапсулирование проблем: монадами значения объединяются с контекстом того, как эти значения выданы  —  успешно, с ошибками или через побочные эффекты.

На практике

Монада в Kotlin  —  это Optional. Запросим в базе данных профиль пользователя:

fun findUserProfile(id: Int): Optional<UserProfile> {
// Логика для извлечения профиля
}

Получим электронную почту пользователя:

val emailOpt = findUserProfile(123).flatMap { profile -> profile.email }

Если с findUserProfile профиль не найден, возвращается пустой Optional. Операция flatMap не завершается аварийно, вместо ошибки выдается другой пустой Optional.

Это аналог системы безопасности инструкции: если этап не завершается, весь процесс от этого не останавливается, а безопасно продолжается.

Преимущества монад

  1. С монадами возможен корректный отказ функций: вместо резких аварийных завершений или остановок обеспечивается продолжение процесса, пусть и с ошибкой.
  2. С монадами последовательность выполнения кода интуитивно понятнее, в ней отражаются реальные процессы принятия решений.
  3. Выше компонуемость: благодаря цепочечной природе монад код с ними модульнее и адаптируемее.

Отложенные вычисления и последовательности, эффективность операций

Берете в буфете только те блюда, которые обязательно съедаете, ничего не оставляя и не выбрасывая? Так съедается то, что нужно и когда нужно, обеспечивается максимальное удовольствие при минимальных затратах.

Отложенными вычислениями в программировании применяется аналогичная стратегия: вычисляется не все сразу, а только то, что необходимо и когда необходимо. В Kotlin это достигается в основном последовательностями.

Что такое «отложенные вычисления»?

Отложенные вычисления  —  стратегия, при которой выражения вычисляются, только когда их результат необходим. Ее преимущества  —  эффективнее расходуется память, быстрее выполнение, особенно при работе с большими коллекциями.

Последовательности Kotlin

В Kotlin последовательности Sequence<T>  —  это коллекция с отложенными вычислениями. В отличие от списков, в последовательностях данные не содержатся; последовательностями описываются вычисления для выдачи запрашиваемых элементов данных.

Последовательности против списков

Найдем в списке первое число, которое после возведения в квадрат делится на пять.

С помощью списка

val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
val result = numbers.map { it * it } // все числа возводятся в квадрат
.filter { it % 5 == 0 } // фильтруются все возведенные в квадрат числа, кратные пяти
.first() // извлекается первый элемент
println(result) // 25

При таком подходе возводятся в квадрат и фильтруются все числа, а используется только одно значение. Это неэффективно.

С помощью последовательности

val numbersSeq = numbers.asSequence()

val resultSeq = numbersSeq.map { it * it }
.filter { it % 5 == 0 }
.first()

println(resultSeq) // 25

С последовательностями каждое число возводится в квадрат, проверяется, кратно ли оно пяти. Затем, когда находится первое такое число, процесс останавливается.

В этом случае последовательность возводится в квадрат и фильтруется только до нахождения числа 5. А это эффективно.

Преимущества отложенных вычислений с последовательностями

  1. Эффективность: вычисляется только то, что необходимо.
  2. Гибкость: ими представляются бесконечные структуры данных.
  3. Экономия памяти: особенно важно при работе с большими наборами данных.

Применение последовательностей и отложенных вычислений в Kotlin аналогично подходу consume as you go («Потребляй по мере необходимости»). С ними разработчики пишут эффективный, масштабируемый код  —  особенно в сценариях, насыщенных операциями с данными.

Хвостовая рекурсия  —  Kotlin для эффективной рекурсии

Представьте  —  вы на первом этаже высотного здания, впереди  —  бесконечная лестница. Поднимаясь на каждую ступеньку, быстро устанешь.

Что, если бы можно было преодолеть несколько этажей одним прыжком, затратив столько же энергии, сколько при подъеме на одну ступеньку? В этом волшебство хвостовой рекурсии Kotlin.

Что такое «рекурсия»?

Рекурсия  —  это техника программирования, при которой, чтобы разбить сложные задачи на простые, функцией вызывается сама функция.

Однако при стандартной рекурсии быстро расходуется много памяти, особенно на больших входных данных. Каждый вызов функции добавляется в стек вызовов, при глубокой рекурсии это чревато ошибкой переполнения стека.

Хвостовая рекурсия

Хвостовая рекурсия  —  это особая форма рекурсии, при которой рекурсивный вызов выполняется в функции последним.

Чтобы за счет использования постоянного пространства стека предотвратить ошибки переполнения стека, компилятором Kotlin оптимизируются хвостовые рекурсивные функции.

Простой пример  —  факториал

Без хвостовой рекурсии:

fun factorial(n: Int): Int {
if (n == 1) return 1
return n * factorial(n - 1)
}

С хвостовой рекурсией:

fun factorial(n: Int, accumulator: Int = 1): Int {
if (n == 1) return accumulator
return factorial(n - 1, n * accumulator)
}

В версии с хвостовой рекурсией результат рекурсивного вызова вместе с текущей операцией передается как аккумулятор. Так обеспечивается отсутствие после рекурсивного вызова дополнительных отложенных операций, это допустимый хвостовой вызов.

Преимущества хвостовой рекурсии

  1. Эффективность: ею предотвращается переполнение стека за счет использования постоянного его пространства.
  2. Ясность: рекурсивные решения некоторых задач интуитивно понятнее.
  3. Поддержка Kotlin: оптимизации простым добавлением модификатора tailrec.

Важное замечание

Необходимо убедиться, что рекурсия действительно находится в «хвосте». Если после рекурсивного вызова имеются отложенные операции, функция уже не хвостовая рекурсивная и компилятором Kotlin не оптимизируется.

Что происходит при хвостовой рекурсии?

При традиционной рекурсии каждый вызов функции добавляется в стек в ожидании завершения следующего, после чего завершаются его собственные вычисления. По мере заполнения стека вызовами функции, расход памяти увеличивается, особенно при больших объемах ввода.

Посмотрим на версию с хвостовой рекурсией. Вот что здесь происходит:

  1. Каждый рекурсивный вызов оптимизируется для переиспользования стекового фрейма текущей функции, поскольку после рекурсивного вызова не остается вычислений, например умножения в случае факториала.
  2. Аккумулятор  —  это такой нарастающий итог с промежуточным результатом. То есть, когда мы доберемся до базового сценария n == 1, в аккумуляторе уже будет ответ и необходимости «пробиваться обратно» не будет.
  3. Функция распознается компилятором Kotlin как хвостовая рекурсивная по модификатору tailrec. Затем, чтобы функцией независимо от размера входных данных гарантированно использовался постоянный объем стековой памяти, компилятором оптимизируется байт-код.

Факториальной функцией, вызываемой с помощью factorial(5), вычисление фактически преобразуется из 5 * 4 * 3 * 2 * 1 в (((5 * 1) * 4) * 3) * 2.

Этим преобразованием обеспечивается готовность результата к моменту достижения базового сценария при использовании постоянного пространства стека.

Другое замечание

Оптимизация хвостовой рекурсии  —  мощный функционал Kotlin, но не уникальный. Имеется он и в функциональных языках программирования вроде Haskell, и языках общего назначения вроде Scala.

Однако способы его реализации и оптимизации отличаются. Всегда учитывайте это при переходе между языками или обсуждении этой темы среди разработчиков с разным опытом.

Заключение

Изучая продвинутое функциональное программирование в Kotlin, мы убедились в его глубине и универсальности. Для разработчиков здесь имеется мощный инструментарий: от нюансов функций для работы с коллекциями, элегантности композиции функций и до эффективности хвостовой рекурсии.

Эти концепции, хотя и выделяются в Kotlin, являются столпами более широкого мира функционального программирования. Освоив их, вы не только прокачаете навыки работы с Kotlin, но и приобщитесь к вечным принципам программирования. С этими инструментами и техниками в Kotlin создается более эффективный, чистый, сопровождаемый код.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Nirbhay Pherwani: Evolving with Kotlin — Advanced Functional Programming Techniques

Предыдущая статьяВ чем разница между ListView и RecyclerView?
Следующая статьяРаскрываем силу JavaScript: сокращение размера пакета NPM на 99%