image

Привет, Хабр!

Все любят runtime exceptions. Нет лучшего способа узнать о том, что что-то не было учтено при написании кода. Особенно — если исключения обваливают приложение у миллионов пользователей, и эта новость приходит паническим email'ом с портала аналитики. В субботу утром. Когда ты в загородной поездке.

После подобного всерьез задумываешься о обработке ошибок — и какие же возможности предоставляет нам Kotlin?

Первым на ум приходит try-catch. По мне — отличный вариант, но у него есть две проблемы:

  1. Это как-никак лишний код (вынужденная обертка вокруг кода, не лучшим образом сказывается на читаемости).
  2. Не всегда (особенно при использовании сторонних библиотек) из блока catch возможно получить информативное сообщение о том, что конкретно вызвало ошибку.

Давайте посмотрим во что try-catch превращает код при попытке решения вышеозвученных проблем.

Например, простейшая функция выполнения сетевого запроса

fun makeRequest(request: RequestBody): List<ResponseData>? {
    val response = httpClient.newCall(request).execute()
    return if (response.isSuccessful) {
        val body = response.body()?.string()
        val json = ObjectMapper().readValue(body, MyCustomResponse::class.java)
        json?.data
    } else {
        null
    }
}

становится похожа на

fun makeRequest(request: RequestBody): List<ResponseData>? {
    try {
        val response = httpClient.newCall(request).execute()
        return if (response.isSuccessful) {
            val body = response.body()?.string()
            val json = ObjectMapper().readValue(body, MyCustomResponse::class.java)
            json?.data
        } else {
            null
        }
    } catch (e: Exception) {
        log.error("SON YOU DISSAPOINT: ", e.message)
        return null
    }
}

«Не так уж и плохо», может сказать кто-то, «вам с вашим котлином всё кодового сахарку хочется», добавит он (это цитата) — и будет… дважды прав. Нет, холиваров сегодня не будет — каждый решает за себя. Я лично правил код самописного json парсера, где парсинг каждого поля был завернут в try-catch, при этом каждый из блоков catch был пустым. Если кого-то устраивает подобное положение вещей — флаг в руки. Я же хочу предложить способ лучше.

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

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

fun makeRequest(request: RequestBody): Try<List<ResponseData>> = Try {
    val response = httpClient.newCall(request).execute()
    if (response.isSuccessful) {
        val body = response.body()?.string()
        val json = ObjectMapper().readValue(body, MyCustomResponse::class.java)
        json?.data
    } else {
        emptyList()
    }
}

Чем этот подход отличается от использования try-catch?

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

Во-вторых, появляется гибкость в том, как ошибка может быть обработана.

Внутри Try ошибка или успех исполнения представлены в виде классов Failure и Success соответственно. Если мы хотим, чтобы функция всегда что-то возвращала при ошибке, можно задать дефолтное значение:

makeRequest(request).getOrElse { emptyList() }

Если требуется обработка ошибики посложнее, на помощь приходит fold:

makeRequest(request).fold(
    {ex ->
        // делаем что-то с ошибкой и возвращаем дефолтное значение
        emptyList()
    },
    { data -> /* используем полученные данные */ }
)

Можно воспользоваться функцией recover — ее содержимое будет полностью проигнорировано, если Try вернет Success.

makeRequest(request).recover { emptyList() }

Можно исопользовать for comprehensions (позаимствованные создателями Arrow из Scala), если требуется обработка результата Success с помощью последовательности команд, путем вызова фабрики .monad() на Try:

Try.monad().binding {
    val r = httpclient.makeRequest(request)
    val data = r.recoverWith { Try.pure(emptyList()) }.bind()
    val result: MutableList<Data> = data.toMutableList()
    result.add(Data())
    yields(result)
}

Вариант выше можно написать без использования binding, но тогда он будет по-другому читаться:

httpcilent.makeRequest(request)
    .recoverWith { Try.pure(emptyList()) }
    .flatMap { data ->
    	val result: MutableList<Data> = data.toMutableList()
        result.add(Data())
        Try.pure(result)
    }

В конце концов, результат функции можно обработать с помощью when:

when(response) {
    is Try.Success -> response.data.toString()
    is Try.Failure -> response.exception.message
}

Таким образом с помощью Arrow можно заменить далеко не идеальную конструкцию try-catch на что-то гибкое и очень удобное. Дополонительным плюсом использования Arrow является то, что не смотря на то, что библиотека позиционирует себя как функциональная — отдельные абстракции оттуда (например, тот же Try) можно использовать, продолжая писать старый добрый ООП код. Но предупреждаю — может понравиться и втянетесь, через пару недель начнете изучать Haskell, а ваши коллеги очень скоро перестанут понимать ваши рассуждения о структуре кода.

P.S.: Оно того стоит:)

Комментарии (5)


  1. impwx
    06.01.2019 18:06
    +3

    Преимущества предлагаемого решения так и не понял. Классический вариант с try/catch примерно одинаково выглядит и работает во всех императивных языках, от C++/Java/C# до PHP/JS/Ruby, поэтому большинству будет понятен интуитивно. Здесь же появляются — Try, monad, binding, pure, recover, recoverWith, flatMap, bind, и в другом языке\другой библиотеке они будут называться по-другому.


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


  1. fogone
    06.01.2019 19:43
    +1

    Честно говоря, совершенно не убедительно. Профита не видно. Какой смысл оборачивать в Try всё подряд, если тоже самое делает обычный try-catch и поддержка nullability, типа

    fun makeRequest(request: String): List<Any>? = try {
        listOf()
    } catch (e: Exception) {
        null
    }
    
    fun main(args: Array<String>) {
        makeRequest("body")?.let { 
            it.map { it.toString() } 
        } ?: emptyList()
    }
    

    тоже самое, только из коробки, без доп-оберток и матчинг эксепшена по типу


    1. maxzh83
      06.01.2019 20:36

      тоже самое, только из коробки

      Нет, не тоже самое. Вы просто проглотили исключение. В Try же exception есть и в конце статьи написано как оно может быть обработано.
      Профита не видно

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


      1. artem_dobrovinskiy Автор
        07.01.2019 12:30

        Спасибо за комментарий!
        Наверное, главная цель статьи — это, чтобы на хабре Arrow хотя бы упоминался (не нашел по нему статей). А чтобы это упоминание не отпугивало обилием информации (а библиотека ведь очень богатая) — статья должна была быть поверхностной. Поэтому абзац про цепочки монад был написан — и вырезан перед отправкой.


  1. javax
    07.01.2019 23:37
    +1

    По моему это очень смешно.
    Сначала придумывают checked exceptions в Java.
    Для того, чтобы

    любой кто будет читать этот код после тебя (а такие скорее всего будут) уже по сигнатуре сможет понять, что исполнение кода может привести к ошибке — и написать код её обработки. Тем более, что компайлер заругается, если это не будет сделано.


    Потом говорят — не, не хотим возиться и всюду загрязнять сигнатуры методов, и перестают их использовать и вообще не делают такое в Котлине и Скале.

    Потом говорят — как же так? Мы же не знаем какой может тут вылететь эксепшн!
    Давайте сделаем Try в Котлине и скале, чтобы
    любой кто будет читать этот код после тебя (а такие скорее всего будут) уже по сигнатуре сможет понять, что исполнение кода может привести к ошибке — и написать код её обработки. Тем более, что компайлер заругается, если это не будет сделано.