Переосмысление обработки исключений с типом Result в Kotlin
Обработка исключений — это фундаментальный аспект создания надежного программного обеспечения. В Kotlin, хотя традиционные блоки try-catch
распространены, тип Result
предлагает альтернативу, инкапсулируя как успех, так и ошибку. Но всегда ли этот подход оптимален? Давайте рассмотрим плюсы и минусы использования Result
и подумаем, не злоупотребляем ли мы им в некоторых сценариях.
Введение в тип Result в Kotlin
Если вы знакомы с Kotlin, то наверняка сталкивались с Result
. Работа с кодом, который может выбрасывать исключения, — обычная ситуация. Традиционно мы пробрасываем эти исключения по кодовой базе и обрабатываем их с помощью try-catch
.
Тип Result
в Kotlin предоставляет структурированный способ инкапсуляции исключений в простой API. Вместо пробрасывания исключений мы перехватываем их и преобразуем результат в Result.success()
или Result.failure()
:
class UserDataSource(private val networkApi: NetworkApi) { fun getUser(): Result<NetworkUser> { return try { Result.success(networkApi.getUser()) } catch (e: Exception) { Result.failure(e) } } }
Этот подход дает нам лаконичный и понятный API, устраняя необходимость обработки исключений на более высоких уровнях:
class UserRepository(private val dataSource: UserDataSource) { fun getUser(): Result<User> = dataSource.getUser() .map { networkUser -> networkUser.toDomainUser() } } class UserViewModel(private val userRepository: UserRepository) { fun getUserState(): UserState = userRepository.getUser().fold( onSuccess = { user -> UserState.Authenticated(user = user) }, onFailure = { throwable -> UserState.Unauthenticated(throwable = throwable) } ) }
Преимущества использования Result
Если вы, как и я, любите Result
, то, наверное, давно используете его везде. Он напоминает о необходимости обработки ошибок. Блок .onSuccess()
выглядит аккуратно. Встроенные функции вроде .map()
и getOrNull()
добавляют удобства.
Я настолько любил Result
, что даже создал свою версию до его официального появления в Kotlin. Я даже написал пост о создании кастомного CallAdapter
для Retrofit, возвращающего Result
. И я не единственный, кто считает этот подход полезным.
С Result
мне не нужно было помнить о try-catch при вызовах API. Видя Result
, я знал, что исключения уже перехвачены. Я мог передавать Result
из слоя данных в репозиторий, затем во ViewModel
, где преобразовывал его в состояние для отображения.
Недостатки
Недавно коллега спросил, почему мы используем Result
для вызовов API. Почему бы не позволить исключениям выбрасываться? Мой первый ответ был: «Потому что так всегда делали». Но это неубедительно. Давайте переосмыслим.
Совместное использование нескольких Result
(например, Result<TypeA>
и Result<TypeB>
) для создания Result<TypeC>
может быть неудобным:
class RewardsRepository(private val dataSource: RewardsDataSource) { fun getRewards(user: User): Result<List<Reward>> = dataSource.getRewards(user.id) .map { networkRewards -> networkRewards.map { networkReward -> networkReward.toDomainReward() } } } class GetUserRewardsUseCase( private val userRepository: UserRepository, private val rewardsRepository: RewardsRepository ) { operator fun invoke(): Result<UserRewards> { val user = userRepository.getUser() // Result<User> val rewards = rewardsRepository.getRewards(user) // Result<List<Reward>> val userRewards = ??? } }
Комбинирование двух результатов может быть болезненным. Часто приходится писать вспомогательные функции:
class GetUserRewardsUseCase(...) { operator fun invoke(): Result<UserRewards> { return try { val user = userRepository.getUser().getOrThrow() val rewards = rewardsRepository.getRewards(user).getOrThrow() Result.success(UserRewards(user, rewards)) } catch (e: Exception) { Result.failure(e) } } }
Но зачем тогда вообще возвращать Result
из userRepository.getUser()
, если мы перехватываем исключения снова?
В чем проблема?
Несколько проблем при повсеместном использовании Result
:
1. Распространение Result повсюду: Потребители API вынуждены работать с Result
, что усложняет оркестрацию.
2. Повторение проверяемых исключений: Result
напоминает проверяемые исключения Java, от которых Kotlin избавился для краткости.
3. Избыточность синтаксиса: Try-catch
и runCatching()
становятся менее полезными.
4. Ложное чувство безопасности: Можно забыть обработать .onFailure()
, что приведет к неожиданным сбоям.
Пишите исключительный код
Решение простое: позвольте исключениям выбрасываться естественно.
class UserDataSource(private val networkApi: NetworkApi) { fun getUser(): NetworkUser = networkApi.getUser() } class UserRepository(private val dataSource: UserDataSource) { fun getUser(): User = dataSource.getUser().toDomainUser() }
«А если забудут перехватить исключение?» — спросите вы. Есть нюансы.
Где обрабатывать исключения?
В традиционном MVVM-приложении исключения стоит обрабатывать на определенных уровнях:
1. Репозиторий: Здесь можно преобразовывать сетевые исключения в доменные.
2. UseCase: Оркестрируйте логику и обрабатывайте исключения при необходимости.
3. ViewModel: Решайте, как показывать ошибки пользователю.
💡 Слишком ранний перехват исключений (например, в DataSource) добавляет лишний код.
Хороший код требует дисциплины
Любой внешний вызов может выбросить исключение. Даже функции, возвращающие Result
, могут выбросить неперехваченное исключение. Всегда учитывайте возможные ошибки, независимо от типа возвращаемого значения.
Заключение
Тип Result
в Kotlin — мощный инструмент, но важно правильно выбирать места его использования. В нижних слоях (например, DataSource) лучше позволять исключениям распространяться, сохраняя код простым. Используйте Result
на высоких уровнях, где это действительно упрощает логику.
💡 Выбор за вами, но не бойтесь обычных try-catch.