
О чём статья
В первой части статьи я рассказал о своём знакомстве с функциональным программированием и о сути этой парадигмы. Сегодня вы узнаете о функциональных типах данных и их структурах. Мы:
узнаем подробнее о принципе неизменности данных;
исследуем понятие State Machine и способы его применения в программировании;
покопаемся в функциональной библиотеке Kotlin Arrow;
обсудим поддержку Null, поток данных и функциональную обработку ошибок;
начнём проектировать Data и Domain слои демонстрационного приложения.
Принцип неизменности данных (расширенная версия)
Уверен, некоторые Android-разработчики слышали: «не используй var там, где это не нужно». Но почему это важно, никто так и не объяснил.
Давайте рассмотрим тип данных в формате диапазона дат действия. Например, абонемент в тренажёрный зал. На практике он может выглядеть так:
class ValidityDataRange(
var start: LocalDateTime,
var end: LocalDateTime
) {
fun isInEffect(date: LocalDateTime): Boolean {
return (start.isBefore(date) || start.isEqual(date)) &&
(end.isAfter(date) || end.isEqual(date))
}
}
На первый взгляд, всё в порядке, но это далеко не так. После создания класса, представляющего диапазон, нам потребуется создать набросок класса SubscribtionCard
. Следуя стилю программирования, продемонстрированному в типе диапазона, мы просто определяем два свойства с открытыми геттерами и сеттерами. Теперь давайте напишем клиентский код:
val card = SubscriptionCard(
serialNumber = UUID.randomUUID().toString(),
validityDataRange = ValidityDataRange(
start = LocalDateTime.parse("2024-10-01T10:15:30"),
end = LocalDateTime.parse("2024-11-01T10:15:30")
)
)
val date = LocalDateTime.parse("2024-10-05T10:15:30")
println(card.validityDataRange.isInEffect(date)) // true
Для начала создадим объект типа Card
. Дата начала его действия — 1 октября 2024 года, а окончания — 1 ноября 2024 года. Теперь мы можем проверить, действительна ли карта на конкретную дату.
Запустив этот код, мы увидим, что карта активна. Результат будет true. Но поскольку тип диапазона изменяемый, мы можем легко изменить конечное свойство.
val date = LocalDateTime.parse("2024-10-10T10:15:30")
println(card.validityDataRange.isInEffect(date)) //true
card.validityDataRange.end = LocalDateTime.parse("2024-10-06T10:15:30")
println(card.validityDataRange.isInEffect(date)) //false
Теперь результат равен false. Всё работает как надо. Но так будет до тех пор, пока мы не увеличим объём кода. Имея десятки классов, работающих с одними и теми же данными, испортить их просто.
Одна часть вашего приложения может не знать об изменениях, которые внесла другая его часть в один и тот же объект. В результате возникнет ошибка. Наш пример — именно такой случай, когда вероятность несоответствия конечного результата ожидаемому очень высока. Одно и то же значение, кстати, можно поменять из разных потоков. Тогда не будет вообще никаких гарантий того, что ожидаемое значение совпадёт с реальным.
Обратите внимание: начальный результат теряет актуальность сразу после изменения состояния диапазона. И второй вызов это только подтверждает. Такое поведение объекта — красный флаг для любого функционального программиста с опытом. Он говорит о том, что в коде проблемы. И это далеко не все проблемы кода выше. Он запросто может, например, сделать конечное значение даты меньше начального, хотя это можно пофиксить и без ФП, грамотно инкапсулировав тип. Конечно, пример может показаться вымышленным, но дальше мы рассмотрим более близкие к реальным кейсы.
Попробуем исправить проблемы с кодом выше:
class ValidityDataRange(
start: LocalDateTime,
end: LocalDateTime
) {
init {
require(!start.isAfter(end)) { "End should be ahead of Start or equal" }
}
var start: LocalDateTime = start
private set
var end: LocalDateTime = end
private set
fun isInEffect(date: LocalDateTime): Boolean {
return (start.isBefore(date) || start.isEqual(date)) &&
(end.isAfter(date) || end.isEqual(date))
}
}
Первое, что приходит в голову, — изменить объявления свойств, сделав сеттеры приватными. Это первый шаг к тому, чтобы сделать данные неизменяемыми. Теперь клиентский код не может пропустить изменения или явного обращения к свойствам.
Но как клиенту теперь изменить состояние объекта? Для получения нового результата ему нужно создать новый объект и вызвать на нём isInEffect
.
val date = LocalDateTime.parse("2024-10-10T10:15:30")
println(card.validityDataRange.isInEffect(date)) //true
card.validityDataRange.end = LocalDateTime.parse("2024-10-06T10:15:30") // ошибка компилятора
val newDate = LocalDateTime.parse("2024-10-06T10:15:30")
println(card.validityDataRange.isInEffect(date)) //false
Код определённо стал лучше. Теперь нельзя изменить исходный диапазон. Вероятность испортить данные стала ниже. Внешний код больше не может изменить скрытые данные, а значит мы достигли их «внешней неизменяемости».
Однако осталась проблема, которая скрыта в разрабатываемом типе: изнутри тип диапазона по-прежнему изменяемый. Как это возможно?
Допустим, нам нужно создать новый метод, который расширяет допустимый диапазон:
class ValidityDataRange(
start: LocalDateTime,
end: LocalDateTime
) {
init {
require(!start.isAfter(end)) { "End should be ahead of Start or equal" }
}
var start: LocalDateTime = start
private set
var end: LocalDateTime = end
private set
fun isInEffect(date: LocalDateTime): Boolean {
return (start.isBefore(date) || start.isEqual(date)) &&
(end.isAfter(date) || end.isEqual(date))
}
fun extend(days: Long) {
end = end.plusDays(days)
}
}
Из реализации этого метода видно, что наш тип всё ещё изменяем изнутри. Это позволяет любому программисту выкатить новый метод, изменяющий внутреннее состояние объекта. Например, мы сейчас создали новый метод и наш тип потерял свойство внешней неизменяемости.
Вернёмся к абонементам в спортзал. Мы определили даты начала и окончания их действия. Теперь представим, что у нас несколько абонементов, а за дополнительную плату мы можем продлить срок действия одного из них.
fun main() {
val cert1 = SubscriptionCard(
"Certificate A",
ValidityDataRange(
LocalDateTime.of(2024, 10, 1, 0, 0),
LocalDateTime.of(2024, 11, 1, 0, 0)
)
)
val cert2 = SubscriptionCard(
"Certificate B",
ValidityDataRange(
LocalDateTime.of(2024, 11, 1, 0, 0),
LocalDateTime.of(2024, 12, 1, 0, 0)
)
)
// Создаём каталог с абонементами
val catalog = listOf(cert1, cert2)
println("Original Validity Ranges:")
catalog.forEach { println("${it.serialNumber}: ${it.validityDateRange.start} to ${it.validityDateRange.end}") }
/*
Выведет Original Validity Ranges:
Certificate A: 2024-10-01T00:00 to 2024-11-01T00:00
Certificate B: 2024-11-01T00:00 to 2024-12-01T00:00
*/
// Продлеваем срок действия первого абонемента
cert1.validityDateRange.extend(5)
println("\nUpdated Validity Ranges:")
catalog.forEach { println("${it.serialNumber}: ${it.validityDateRange.start} to ${it.validityDateRange.end}") }
/*
Выведет Updated Validity Ranges:
Certificate A: 2024-10-01T00:00 to 2024-11-06T00:00
Certificate B: 2024-11-01T00:00 to 2024-12-01T00:00
*/
}
Пока код работает корректно, но что будет, если мы выпустим несколько абонементов в один день? Разработчик может создать единый объект ValidityDataRange
для повторного использования кода и присвоить диапазон всем абонементам. Но для каждого элемента используется одна и та же ссылка на объект диапазона, а значит при вызове метода extend для одного абонемента мы столкнёмся с изменением срока действия для всех и сразу:
val monthlyRange = ValidityDataRange(
LocalDateTime.of(2024, 11, 1, 0, 0),
LocalDateTime.of(2024, 12, 1, 0, 0)
)
val cert1 = SubscriptionCard("Certificate A", monthlyRange)
val cert2 = SubscriptionCard("Certificate B", monthlyRange)
// Продлеваем срок действия первого абонемента
cert1.validity.extend(5)
Original Validity Ranges:
Certificate A: 2024-11-01T00:00 to 2024-12-01T00:00
Certificate B: 2024-11-01T00:00 to 2024-12-01T00:00
Updated Validity Ranges:
Certificate A: 2024-11-01T00:00 to 2024-12-06T00:00
Certificate B: 2024-11-01T00:00 to 2024-12-06T00:00
То есть... Мы снова пришли к той же проблеме, связанной с изменяемостью данных, а значит можем встретиться и со всеми остальными. Как можно решить эту проблему?
Можно попробовать с помощью встроенных в Kotlin инструментов. Он из коробки позволяет объявить свойства start
и end
как readOnly:
class ValidityDataRange(
val start: LocalDateTime,
val end: LocalDateTime
) {
init {
require(!start.isAfter(end)) { "End should be ahead of Start or equal" }
}
fun isInEffect(date: LocalDateTime): Boolean {
return (start.isBefore(date) || start.isEqual(date)) &&
(end.isAfter(date) || end.isEqual(date))
}
fun extend(days: Int): ValidityDataRange = ValidityDataRange(
start = start,
end = end.plusDays(days.toLong())
)
}
Original Validity Ranges:
Certificate A: 2024-11-01T00:00 to 2024-12-01T00:00
Certificate B: 2024-11-01T00:00 to 2024-12-01T00:00
Updated Validity Ranges:
Certificate A: 2024-11-01T00:00 to 2024-12-01T00:00
Certificate B: 2024-11-01T00:00 to 2024-12-01T00:00
Также эту задачу можно решить с помощью data class. Он изначально иммутабельный:
data class ValidityDataRange(
val start: LocalDateTime,
val end: LocalDateTime
) {
fun isInEffect(date: LocalDateTime): Boolean {
return (start.isBefore(date) || start.isEqual(date)) &&
(end.isAfter(date) || end.isEqual(date))
}
fun extend(days: Int): ValidityDataRange = this.copy(end = end.plusDays(days.toLong()))
}
Как и в предыдущем примере, будут сгенерированы свойства, которые можно установить только при создании записи. Так мы сможем достигнуть состояния внутренней неизменяемости типа. Если в команду придёт новый специалист, изменить тип он не сможет и задумается перед тем, как переводить его в изменяемое состояние.
«А как это относится к функциональному программированию? — спросите вы — Мы и без твоей помощи освоили работу с val
и data class
.»
Дело в том, что иммутабельность накладывает определённые ограничения на наше приложение. Возникает вопрос: как построить динамичную систему, способную обновлять состояние, переключать экраны, отображать диалоги и продлевать абонементы в спортзале?
Особенно актуальным наличие такой системы становится, если мы используем функциональное программирование. Да, у него множество преимуществ, но оно требует и более тщательного подхода к управлению состоянием.
Чтобы интерфейс пользователей был максимально удобным и интуитивно понятным, нам нужно найти баланс между иммутабельностью и возможностью изменять данные в реальном времени.
Функциональные структуры данных для работы с неизменяемыми данными
Для работы с иммутабельными структурами в функциональном программировании существуют типы-помощники.
Если мы работаем со вложенными структурами данных, хотим получить доступ и изменять значения в этих структурах, сохраняя неизменяемость данных, нам помогут Линзы.
Они состоят из двух функций:
геттер извлекает значение из структуры данных;
сеттер обновляет значение в структуре, возвращая новую версию этой структуры с изменённым значением.
Линзы можно комбинировать и работать с глубокими вложенными структурами данных. Например, если у вас есть структура, которая содержит в себе другие структуры, вы можете создать линзу для каждой из них и комбинировать их для доступа к более глубоким уровням.
Линзы полезны при работе с иммутабельными данными, изменение структуры которых подразумевает создание новой копии. Линзы позволяют избежать многократного копирования и упрощают процесс обновления данных.
data class User(val name: String, val address: Address)
data class Address(val city: String, val street: String)
// Линза для доступа к адресу пользователя
class Lens<S, A>(val get: (S) -> A, val set: (S, A) -> S)
// Линза для User
val userAddressLens = Lens<User, Address>(
get = { it.address },
set = { user, address -> user.copy(address = address) }
)
// Линза для Address
val addressStreetLens = Lens<Address, String>(
get = { it.street },
set = { address, street -> address.copy(street = street) }
)
// Функция для изменения улицы пользователя
fun User.changeStreet(newStreet: String): User {
val address = userAddressLens.get(this)
val newAddress = addressStreetLens.set(address, newStreet)
return userAddressLens.set(this, newAddress)
}
fun main() {
val user = User(name = "Ivan", address = Address(city = "Moscow", street = "Lenina"))
val updatedUser = user.changeStreet("Mira")
println(updatedUser) // User(name=Ivan, address=Address(city=Moscow, street=Mira))
}

Однако линзы в функциональном программировании — не панацея. В Kotlin у них есть недостаток: они могут вызвать проблемы с распределением памяти, особенно при работе с неизменяемыми структурами данных. При вызове метода set для свойств неизменяемых классов данных каждое изменение приводит к созданию новых экземпляров. Это цена иммутабельности в функциональном программировании.
Так что в случае с Kotlin и Android необходимо проводить замеры и профилирование. Если изменения неизменяемых полей происходят часто, стоит подумать об отказе от иммутабельности или о реализации пула часто используемых объектов.
Так вы сможете повторно использовать объекты, а не создавать новые. Память будете расходовать медленнее, а сборщик мусора вызывать реже.
С помощью sealed class в Kotlin можно безопасно извлекать и изменять данные, когда их структура принимает одну из нескольких возможных форм. Для этого в функциональном программировании есть призма.
Эта структура позволяет эффективно работать с различными вариантами данных, обеспечивая безопасный доступ к ним и их модификацию. Код получается более предсказуемым и удобным для сопровождения.

Призма не сильно отличается от Линзы, за одним исключением — геттер-функция призмы извлекает значение, если оно соответствует определённому типу.
sealed class Result<out T> {
data class Success<out T>(val value: T) : Result<T>()
data class Error(val message: String) : Result<Nothing>()
}
// Призма для работы с Result
class Prism<S, A>(val get: (S) -> A?, val set: (S, A) -> S)
// Призма для Result
val successPrism = Prism<Result<*>, Any>(
get = { result -> (result as? Result.Success<*>)?.value },
set = { _, value -> Result.Success(value) }
)
val errorPrism = Prism<Result<*>, String>(
get = { result -> (result as? Result.Error)?.message },
set = { _, message -> Result.Error(message) }
)
// Функция для обработки результата
fun handleResult(result: Result<Int>): String {
return when (result) {
is Result.Success -> "Success with value: ${successPrism.get(result)}"
is Result.Error -> "Error: ${errorPrism.get(result)}"
}
}
fun main() {
val successResult = Result.Success(42)
val errorResult = Result.Error("Something went wrong")
println(handleResult(successResult)) // Success with value: 42
println(handleResult(errorResult)) // Error: Something went wrong
}
Мы определили запечатанный класс Result
. Он может быть либо Success
, либо Error
.
Создали призмы для работы с двумя видами результатов: Success
и Error
. Каждая призма имеет геттер и сеттер для доступа к соответствующим значениям.
За обработку результата отвечает функция handleResult
. Она использует призмы для извлечения значений из результата и возвращает строку с информацией о нём.
Неизменяемость и её роль в управлении данными
Неизменяемость — фундаментальный принцип функционального программирования, который повышает предсказуемость и надёжность приложений. Когда мы проектируем неизменяемые структуры данных, мы гарантируем, что после создания объект нельзя будет изменить. Эта характеристика особенно выгодна по нескольким причинам:
неизменяемые объекты поддерживают согласованное состояние на протяжении всего жизненного цикла. Это упрощает анализ поведения приложения;
в многопоточной среде неизменяемые объекты могут совместно использоваться между потоками без риска повреждения данных, поскольку их состояние не может измениться;
в приложениях со сложными переходами состояний неизменность упрощает отслеживание изменений. Каждое состояние может быть представлено как новый объект. Это упрощает управление историей состояний и переходами.
Рассмотрим код, который показывает, как изменить тип двигателя объекта Car
с использованием неизменяемости:
data class Car(val color: Int, val model: Model)
data class Model(val engine: Engine,val name: String, val country: String)
data class Engine(val type: EngineType, val numberValves: Int)
enum class EngineType {
INJECTOR,
CARBURETOR
}
fun Car.changeEngineType(): Car =
this.copy(
model = model.copy(
engine = model.engine.copy(
type = when(model.engine.type) {
EngineType.INJECTOR -> EngineType.CARBURETOR
EngineType.CARBURETOR -> EngineType.INJECTOR
}
)
)
)
Этот подход обеспечивает неизменность, но он же может привести и к созданию многословного кода, особенно при работе с глубоко вложенными структурами. Для таких случаев и нужны линзы и призмы. Они предоставляют более элегантный способ доступа и изменения вложенных свойств без чрезмерного копирования:
fun Car.changeEngineType(): Car {
// Get the current engine type using the lens
val currentEngineType = carLens.get(this).let(modelLens.get).let(engineLens.get)
// Determine the new engine type
val newEngineType = when (currentEngineType) {
EngineType.INJECTOR -> EngineType.CARBURETOR
EngineType.CARBURETOR -> EngineType.INJECTOR
}
// Use lenses to set the new engine type
return carLens.set(this, modelLens.set(carLens.get(this), engineLens.set(modelLens.get(carLens.get(this)), newEngineType)))
}
Я не вижу смысла изобретать велосипед. Всё, что приводилось в качестве примеров выше, да и в целом всё необходимое для функционального программирования в Kotlin, уже есть в Open Source библиотеке. Поэтому, Arrow — ваш выход. В документации по ссылке вы сможете найти примеры использования функциональных приёмов на практике.
С помощью неизменяемости мы можем легко управлять состоянием объектов и их изменениями. Но в более сложных системах, где нужно управлять множеством состояний и переходов между ними, требуется более структурированный подход. Тут на помощь приходят State Machine.
Изменение состояний пользовательского интерфейса и State Machine
Идея State Machine или Конечного автомата в программировании не нова. Finite-state machine (FSM) — это математическая абстракция, модель, которая может находиться только в одном из конечного числа состояний в каждый конкретный момент времени.

Изменение состояния автомата называется переходом. Он совершает их в ответ на вводимые данные. FSM определяется списком его состояний, начальным состоянием и инпутами, запускающими переходы. В этой статье мы не будем углубляться в математическую составляющую State Machine, а всем желающим это сделать я бы советовал сначала открыть для себя мир дискретной математики. Я же постараюсь объяснить суть State Machine на примере нашего пет-проекта:

Чтобы лучше понять принцип работы медиапроигрывателя и алгоритм обработки событий, рассмотрим его состояния, возможные и невозможные переходы между ними. Состояния медиапроигрывателя:
Idle
— ожидание. Медиапроигрыватель не выполняет никаких действий;loading
— загрузка. Медиапроигрыватель загружает медиафайлы;playing
— воспроизведение. Медиапроигрыватель воспроизводит медиафайл;paused
— пауза. Воспроизведение приостановлено;stopped
— остановка. Воспроизведение завершено;error
— ошибка. При воспроизведении произошла ошибка.
События:
onPlayClick
— запуск воспроизведения;onPauseClick
— приостановка воспроизведения;onStop
— остановка воспроизведения;onSeekForwardClick
— перемотка вперёд;onSeekBackwardClick
— перемотка назад;onAddToFavoritesClick
— добавление в избранное;onDownloadClick
— загрузка медиафайла.
Рассмотрим все возможные переходы между состояниями медиапроигрывателя в зависимости от разных событий:

Некоторые переходы между состояниями невозможны:
Idle
не может перейти вPlaying
сразу — сначала нужно загрузить медиафайл (переход черезLoading
);Loading
не может перейти вPaused
илиStopped
; он может перейти только в Playing илиError
;Playing
не может перейти вLoading
. Если пользователь хочет загрузить новый файл, он должен сначала остановить текущее воспроизведение;Paused
не может перейти вLoading
. Необходимо сначала возобновить воспроизведение или остановить его;Stopped
не может перейти вPaused
. Нужно сначала загрузить медиафайл;Error
не может перейти вPlaying
илиPaused
. Сначала необходимо обработать ошибку и, возможно, перезапустить загрузку.
В идеальном мире мы предусмотрели бы все возможные состояния экрана и уверенно утверждали бы, что наша система стабильна. Но мы живём не в идеальном мире.
Представим предложение для заказы еды. В теории нам хватило бы трёх состояний: «выбор ресторана», «добавление блюд в корзину» и «оформление заказа». Но на практике ресторан может изменить меню, а то и вовсе закрыться в процессе оформления заказа. Тогда приложение просто перестанет работать, а пользователь будет недоволен. Поэтому мы должны предусмотреть все возможные на практике сценарии.
Важно тщательно проектировать State Machine, учитывая все непредсказуемые ситуации. Только так можно создать устойчивую и предсказуемую систему, которая будет справляться с реальными условиями использования.
Реализация FSM на практическом примере
Реализуем на практике то, о чём говорили выше:
// Состояния медиапроигрывателя
sealed class MediaPlayerState {
data object Idle : MediaPlayerState()
data object Loading : MediaPlayerState()
data object Playing : MediaPlayerState()
data object Paused : MediaPlayerState()
data object Stopped : MediaPlayerState()
data object Error : MediaPlayerState()
}
// События, которые могут произойти
sealed class MediaPlayerIntent {
data object onPlayClick : MediaPlayerIntent()
data object onPauseClick : MediaPlayerIntent()
data object onStop : MediaPlayerIntent()
data object onSeekForwardClick : MediaPlayerIntent()
data object onSeekBackwardClick : MediaPlayerIntent()
data object onAddToFavoritesClick : MediaPlayerIntent()
data object onDownloadClick : MediaPlayerIntent()
}
class MediaPlayerViewModel : ViewModel() {
private val _state = MutableStateFlow<MediaPlayerState>(MediaPlayerState.Idle)
val state: StateFlow<MediaPlayerState> = _state.asStateFlow()
init {
loadAudio()
}
private fun loadAudio() {
_state.value = MediaPlayerState.Playing
}
fun processIntent(intent: MediaPlayerIntent) {
when (intent) {
is MediaPlayerIntent.onPlayClick -> {
when (_state.value) {
is MediaPlayerState.Loading -> {
// Показ loader или shimer animation
}
is MediaPlayerState.Idle, is MediaPlayerState.Paused -> {
_state.value = MediaPlayerState.Playing
}
is MediaPlayerState.Playing -> {}
is MediaPlayerState.Stopped -> {}
is MediaPlayerState.Error -> {}
}
}
is MediaPlayerIntent.onPauseClick -> {
if (_state.value is MediaPlayerState.Playing) {
_state.value = MediaPlayerState.Paused
}
}
is MediaPlayerIntent.onStop -> {
if (_state.value is MediaPlayerState.Playing || _state.value is MediaPlayerState.Paused) {
_state.value = MediaPlayerState.Stopped
}
}
is MediaPlayerIntent.onSeekForwardClick -> {
// Логика перемотки вперёд на 15 секунд
}
is MediaPlayerIntent.onSeekBackwardClick -> {
// Логика перемотки назад на 15 секунд
}
is MediaPlayerIntent.onAddToFavoritesClick -> {
// Логика добавления в избранное
}
is MediaPlayerIntent.onDownloadClick -> {
// Логика скачивания аудио
}
}
}
}
Выше вы видите реализацию State Machine — набор поддерживаемых нами состояний State и допустимых переходов между ними Intent. В этом примере мы отделяем состояние и переходы, чтобы увидеть все возможные комбинации в одном месте — чем меньше информации держим в голове, тем проще с ней работать.
Поддержка Null, поток данных и функциональная обработка ошибок
Концепция State Machine и управления состояниями тесно связана с ситуациями, в которых данных может просто не быть. Такие задачи можно решить внедрением Nullable-объектов.
Если мы будем использовать null в функциональном программировании, чтобы указать на отсутствие значения, то мы столкнёмся с некоторыми проблемами:
NullPointerException
— одна из самых распространённых ошибок. Она возникает, когда код пытается получить доступ к методу или свойству объекта равного null, и может привести к сбоям в работе программы;неявные ошибки. Код, который не обрабатываем null, может вести себя непредсказуемо, затрудняя отладку и поддержку;
неявные контракты. Использование null не всегда явно указывает на отсутствующее значение, приводя к путаницам и ошибкам.
Если вы работали с Java, вы, наверняка, сталкивались с NullPointerException
. Он появляется из-за того, что какой-то метод неожиданно возвращает значение null, не учитывая ту или иную возможность в вашем клиентском коде. Null злоупотребляют, представляя отсутствующее необязательное значение. Kotlin пытается решить проблему, избавляясь от нулевых значений с помощью собственного специального синтаксиса, основанного на «?».
В функциональном программировании вместо null используют конструкцию Optional
. Она предоставляет более безопасный и явный способ работы с отсутствующими значениями.
С помощью такого типа Arrow моделирует отсутствие значений. Разберём на примере, написав простую функцию:
fun findElement(list: List<Int>, element: Int): Int? {
return if (element in list) {
element
} else {
null
}
}
fun main() {
val numbers = listOf(1, 2, 3, 4, 5)
val found = findElement(numbers, 3)
println(found) // Output: 3
val notFound = findElement(numbers, 6)
println(notFound) // Output: null
if (found != null) { println("Найден элемент: $found") }
else { println("Элемент не найден") }
}
В коде выше мы не увидим ничего критичного, пока не доберёмся до обработки состояний, когда значение может быть null. Конечно, можно использовать Kotlin Null Safety и жить в счастье. Но прежде я бы посмотрел, как мы можем улучшить работу с такими ситуациями с помощью Arrow.
fun findElement(list: List<Int>, element: Int): Option<Int> {
return if (element in list) {
element.some()
} else {
none()
}
}
fun main() {
val numbers = listOf(1, 2, 3, 4, 5)
val found = findElement(numbers, 3)
println(found) // Output: Some(3)
val notFound = findElement(numbers, 6)
println(notFound) // Output: None
found.fold(
{ println("Элемент не найден") }, // Обработка случая None
{ println("Найден элемент: $it") } // Обработка случая Some
)
}
Что изменилось? Появился какой-то «сом» и «нон».

Option<A>
— контейнер для необязательного значения типа A. Если значение типа A присутствует, Option<A>
— экземпляр Some<A>
, содержащий текущее значение типа A
. Если значение отсутствует, Option <A>
— объект None
. Другими словами, мы имеем дело с двумя состояниями: значение есть, значения нет. Контейнер Option
может отражать одно из этих двух состояний. Если мы хотим извлечь значение из контейнера с помощью метода fold()
, нам придётся явным образом обработать оба этих случая, поскольку компилятор проверяет за нас, обрабатываются ли они. Полагаться на исключения больше не нужно. Кажется, такой код более предсказуемый и безопасный.
С разницей между Nullable
и Optional
подходами разобрались. Идём дальше.
Представьте, что вы — backend-разработчик. Ваша задача — реализовать обработку данных пользователя для прохождения регистрации. Предположим, на первом этапе нужно валидировать 3 состояния:
Имя должно содержать только буквы.
Корректный формат электронной почты.
Возраст пользователя от 0 до 120 лет.
Попробуем решить задачу в императивном стиле:
data class User(val name: String, val age: Int, val email: String)
fun String.isValidEmail(): Boolean {
val emailRegex = Regex(
"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"
)
return emailRegex.matches(this)
}
fun validateUser(user: User): String {
when {
!user.name.all { it.isLetter() } -> throw IllegalArgumentException("Ошибка: имя должно содержать только буквы")
user.age < 0 || user.age > 120 -> throw IllegalArgumentException("Ошибка: возраст должен быть от 0 до 120")
!user.email.isValidEmail() -> throw IllegalArgumentException("Ошибка: неверный формат электронной почты")
}
return "Пользователь зарегистрирован: ${user.name}"
}
fun main() {
val users = listOf(
User("Alice", 30, "alice@example.com"),
User("Bob123", 25, "bob@example.com"),
User("Charlie", -5, "charlie@example.com"),
User("David", 25, "davidexample.com")
)
users.forEach { user ->
try {
val result = validateUser(user)
println(result) // Успешный результат
} catch (e: IllegalArgumentException) {
println(e.message) // Обработка ошибки
}
}
}
На первый взгляд, всё ок. Если вдруг эту статью читают backend-разработчики, напишите, насколько этот код похож на тот, что пишете вы.
Теперь давайте реализуем тот же код в функциональном стиле и сравним оба подхода:
sealed class RegistrationError {
object InvalidName : RegistrationError() {
override fun toString() = "Ошибка: имя должно содержать только буквы"
}
object InvalidAge : RegistrationError() {
override fun toString() = "Ошибка: возраст должен быть от 0 до 120"
}
object InvalidEmail : RegistrationError() {
override fun toString() = "Ошибка: неверный формат электронной почты"
}
}
data class User(val name: String, val age: Int, val email: String)
fun validateUser(user: User): Either<RegistrationError, String> {
return when {
!user.name.all { it.isLetter() } -> Either.Left(RegistrationError.InvalidName)
user.age < 0 || user.age > 120 -> Either.Left(RegistrationError.InvalidAge)
!user.email.isValidEmail() -> Either.Left(RegistrationError.InvalidEmail)
else -> Either.Right("Пользователь зарегистрирован: ${user.name}")
}
}
fun main() {
val users = listOf(
User("Alice", 30, "alice@example.com"),
User("Bob123", 25, "bob@example.com"),
User("Charlie", -5, "charlie@example.com"),
User("David", 25, "davidexample.com")
)
users.forEach { user ->
when (val result = validateUser(user)) {
is Either.Left -> println(result.value) // Обработка ошибки
is Either.Right -> println(result.value) // Успешный результат
}
}
}
Что нового? Появилась структура Either
— стандартный элемент функционального программирования. Он используется для представления значения, которое может быть либо правильным результатом Right
, либо ошибкой Left
. Так можно обрабатывать ошибки без использования исключений, как в примере выше.
Кстати недавно на Kotlin Conf показали похожую конструкцию из коробки, но сейчас не об этом.
в примере с Either мы можем расширять список типов ошибок, просто добавляя новые объекты в RegistrationError. Так мы упрощаем обработку ошибок и масштабирование кода;
используя Either, мы можем избежать неявных ошибок и сделать код более безопасным. Разработчик должен явно обрабатывать каждую возможную ошибку.
А теперь немного перемоем косточки императивной реализации. Код с исключением может стать громоздким, особенно если мест, где может появиться ошибка, много. Например, у нас есть несколько функций, которые могут вызвать validateUser
. Каждая из них должна будет обрабатывать исключения:
fun someFunction() {
try {
validateUser(user)
} catch (e: IllegalArgumentException) {
println(e.message)
}
}
Исключения нужно правильно обработать в каждом месте вызова функции. Если мы забудем обработать исключения в одной из функций, в приложении могут возникнуть сбои:
fun anotherFunction() {
validateUser(user) // Здесь нет обработки ошибок
}
Что дальше
В этой статье мы узнали о принципе иммутабельности и функциональных структурах, упрощающих поддержку этого принципа. Ну и старались не уходить слишком глубоко в функциональное программирование...
В следующей статье мы обсудим влияние сайд-эффектов на стабильность приложений и DI в функциональном программировании. Разберёмся в теории категорий и рассмотрим следующие понятия: морфизм, мемоизация, моноид, функтор, частичное применение и каррирование.
Спасибо, что дочитали эту статью! Ставьте плюсики, если материал показался вам интересным, и делитесь им с друзьями. А о том, как мы развиваем IT в Додо, читайте в Telegram-канале Dodo Engineering. Там мы рассказываем о нашей жизни, культуре и последних разработках.