April 3

Переосмысление обработки исключений с типом 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.

Оригинальная статья