Функциональная обработка ошибок в Kotlin с помощью Arrow

    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.: Оно того стоит:)
    Поделиться публикацией

    Комментарии 10

      +4

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


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

        +3
        Честно говоря, совершенно не убедительно. Профита не видно. Какой смысл оборачивать в 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
          тоже самое, только из коробки

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

          В статье не самым лучшим образом отражено, а вообще это нужно для того, чтобы можно было делать цепочку из монад с дальнейшей обработкой.
            –1
            Спасибо за комментарий!
            Наверное, главная цель статьи — это, чтобы на хабре Arrow хотя бы упоминался (не нашел по нему статей). А чтобы это упоминание не отпугивало обилием информации (а библиотека ведь очень богатая) — статья должна была быть поверхностной. Поэтому абзац про цепочки монад был написан — и вырезан перед отправкой.
              0
              Нет, не тоже самое. Вы просто проглотили исключение. В Try же exception есть и в конце статьи написано как оно может быть обработано.
              ну да, если его надо обработать выше, надо просто этот же код написать выше, в чем тут проблема-то? и `try` надо только в одном месте написать.
              цепочку из монад с дальнейшей обработкой

              вызов метдов с `try-catch` это разве не цепочка с обработкой? в чем профит-то?
              Усложнение всё-таки должно быть чем-то оправдано, того, что я увидел в статье явно не хватает, чтобы взять это в использование в каких-то реальных кейзах, которые я использую. Я использую цепочки обработки для коллекций, например, там понятно, что мы получаем, декларативность, скрываем не относящийся к происходящему и повторяющийся код, а тут что? Заменили один механизм из коробки на другой из библиотеки, при этом декларативности особенной или переиспользования или понятности не получили, только усложнение и дополнительные зависимости.
                +1
                вызов метдов с `try-catch` это разве не цепочка с обработкой? в чем профит-то?

                Ну принцип тот же, что и в Stream API — это практически цепочка монад. Удобно будет например внутрь map или filter засовывать обработку try-catch?
                  0
                  если для каждого элемента важно значение или исключение, то это может быть удобно, да, как например это сделано в CompletableFuture. Но там это немного более уместно, потому что предполагает выполнение какого-то кода, который может свалиться. В концепции мэпа больше предполагается, что этот код будет падать только если совсем. Если код для каждого элемента может падать, то чаще для этих методов будет полезнее что-то вроде:
                  inline fun <R> tryOrNull(block: () -> R?): R? = try {
                      block()
                  } catch (e: Exception) {
                      null
                  }
                  
                  fun aaa() {
                      listOf(1, 2, 3).mapNotNull { tryOrNull { it / 0 } }
                  }
                  
            +6
            По моему это очень смешно.
            Сначала придумывают checked exceptions в Java.
            Для того, чтобы
            любой кто будет читать этот код после тебя (а такие скорее всего будут) уже по сигнатуре сможет понять, что исполнение кода может привести к ошибке — и написать код её обработки. Тем более, что компайлер заругается, если это не будет сделано.


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

            Потом говорят — как же так? Мы же не знаем какой может тут вылететь эксепшн!
            Давайте сделаем Try в Котлине и скале, чтобы
            любой кто будет читать этот код после тебя (а такие скорее всего будут) уже по сигнатуре сможет понять, что исполнение кода может привести к ошибке — и написать код её обработки. Тем более, что компайлер заругается, если это не будет сделано.
              +1
              Это как в анекдоте про карандаши
              Завод делает карандаши. В какой-то момент один из работяг приходит с рацухой к начальству: «Карандаши до конца не исписывают люди. 2см грифеля сверху можно смело отрезать.» Начальство подумало, посчитало — карандаши вагонами ежедневно отгружают, и эти 2см солидной экономией получаются. Работяге премию, рацуху внедрили.
              Через некоторое время начальство поменялось, приходит второй работяга, тоже с рацухой: «в карандашах 2см дерева без грифеля — отрежем, на дереве будет экономия.» Снова посчитали, и правда экономия выходит, да и кому нужен этот кусочек без грифеля? Работяге премию, рацуху внедрили.
                0
                При дизайне языка С# было принято решение отказаться от checked exceptions. Андреас Хейлсберг (дизайнер языка) объяснил это тем, что преимущества от checked exceptions полностью невелируюся их недостатками. Обычно исключениям (обычным) ставят в вину, что их можно и не обрабатывать (в отличие от), но при этом не говорится, что часто их и не нужно обрабатывать, по крайней мере, не во всех слоях программы, в то время как любая принудительная обработка ошибок (как, например, в статье) приводит к бессмысленному коду, просто чтобы перекинуть исключение выше. Для функционального подхода это еще можно понять, но в ООП это просто замена одного геморроя другим.

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

              Самое читаемое