Неэффективная обработка бывает не только в программировании (фото автора).
Неэффективная обработка бывает не только в программировании (фото автора).

Во многих программах написанных на Kotlin, в том числе и доступных на GitHub, а также в некоторых статьях, особенно на Medium, я снова и снова вижу, что авторы обрабатывают ошибки, с моей точки зрения, неэффективно и неидиоматически. При этом результат работы программы может быть правильный. Неэффективность и «неидеоматичность» проявляется в этих случаях в том, что авторы либо используют дополнительные классы, либо городят ненужные костыли, либо делают код более сложным для чтения, понимания и поддержки, чем нужно.

И наш новый друг, ChatGPT, по крайней мере в моих экспериментах, видимо начитавшись плохих примеров, предлагает очень странные решения.

Не претендуя на истину в последней инстанции, я попытаюсь в этой статье описать систематический подход к оптимальной и идиоматической обработке ошибок в Kotlin.

Эта публикация должна быть интересна в первую очередь новичкам в Kotlin. Но я надеюсь, она будет небезинтересна и профессионалам.

Полностью исходные коды к этой статье вы найдёте здесь.

Должен сознаться, что приведённые выше нарекания касаются и некоторых моих собственных старых программ на Kotlin.

Думаю, причины проблем многих программистов, не‑идиоматически обрабатывающих ошибки на Kotlin те же, что и у меня:

  • Одно неправильное наименование одного параметра в документации, которое «отпугивает» многих пользователей, как это было и со мной (об этом — ниже),

  • Излишний лаконизм документации Kotlin, особенно — отсутствие описания мотивации и примеров использования,

  • Попытки новичков в Kotlin применить привычные паттерны из других языков программирования вместо изучения и последующего использования базовых средств Kotlin.

Совсем немного теории

Рассмотрим для простоты некоторую функцию, реализованную на некотором (пока не важно каком) языке программирования, которая вычисляет или меняет какое‑то значение. Если при исполнении кода этой функции произошла ошибка, то как правило, дальнейшая логика исполнения должна измениться по сравнению с нормальным случаем исполнения. Для этого надо как‑то известить «внешний мир» о произошедшей ошибке.

Лично мне известны следующие способы такого извещения, бывшие популярными в различных языках программирования в разные времена:

  1. Записать информацию об ошибке в какую‑то внешнюю (как правило — глобальную) переменную или объект.

  2. Использовать специальную языковую конструкцию исключения ( Exception), куда записывается информация об ошибке и которая, прерывая нормальную логику исполнения, начинает двигаться вверх по стеку вызовов до места, где она встретиться со своим перехватчиком.

  3. В каждой функции иметь специальный выходной параметре, в который записывается информация об ошибке.

  4. Суметь в качестве возвращаемого параметра выдавать либо вычисленный результат, либо информацию об ошибке.

Каждый из перечисленных подходов имеет свои преимущества и недостатки. Поговорим о недостатках.

В первом и и третьем случае после вызова каждой функции надо соответствующую переменную или объект проверять и потом вычищать. Это делает код «грязным».
Использование механизма Exception (второй подход) — базовый механизм обработки ошибок в Java. Интернет запружен стонами Java‑программистов на эту тему. Не вдаваясь в детали, приведу две публикации на эту тему: Checked Exceptions are Evil и Why are exceptions considered bad in many programming languages when they make your code cleaner?

Первые два подхода грешат также сторонними эффектами. Другими словами, состояние окружение точки вызова нашей условной функции в случае нормального её выполнения отличается от состояния, если в ней произошла ошибка. А это — причина следующих коварных ошибок, которые очень трудно отловить.

Третий подход такой проблемы не создаёт, но очень раздражает наличием дополнительного, необъяснимого с точки бизнес‑логики параметра.

В результате для многих практиков и теоретиков программирования наиболее приемлемым на сегодня представляется четвёртый подход.

Таким образом, нам надо исхитриться в возвращаемое значение функции суметь вставлять либо результат нормального вычисления, либо информацию об ошибке.

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

Хорошая новость состоит в том, что в стандарте языка Kotlin такой класс есть и он тоже называется Result.

Досадное недоразумение

При исследовании описания функций класса Result мне бросилось в глаза обстоятельство, изначально оттолкнувшее меня от использования этого класса. Сигнатура (включая имена) функции задания информации об ошибке выглядит так:

fun <T> failure(

    exception: Throwable

): Result<T>

Если верить имени входного параметра, то мы можем использовать эту функцию класса только с Exception, а не с нашими собственными ошибками типа ошибок валидирования входных параметров.

Но — не будем торопиться и разберёмся повнимательнее.

Прочитав описание класса Throwablel мы увидим там член класса для описания ошибки message и причины ошибки — cause. Оба члена опциональны. Кроме того имеется возможность прочитать или напечатать стэк вызова.

Важная информация содержится в конце описания класса:

Inheritors:

open class Error : Throwable

open class Exception : Throwable

Лично я интерпретирую это так: разработчики языка используют Exception для упаковки информации о системных ошибках и предлагают одновременно остальные ошибки паковать в класс Error или его наследников. При этом наследники обеих веток наследованная будут одинаково обрабатываться в функции failure(...) класса Result и, разумеется, во всех других его функциях.

Значит, мы можем использовать класс Result при работе с собственными классами ошибок!

Ну что же, попробуем использовать наши знания. Из моей многолетней практики программирования приложений в «кровавом энтерпрайзе» я знаю, что кроме сообщения об ошибке очень полезно ввести код или категорию ошибки а также некоторую информацию о деталях (например — некоторых значениях соседних переменных или состоянии окружения). Итак, создадим наш класс ошибки:

/**
 * Base class for representing business-specific errors.
 *
 * The `BusinessError` class provides a structured way to represent errors that occur within a business context.
 * It includes properties such as error `code`, `message`, `details`, and an optional `cause` throwable.
 *
 * @param code The error code associated with the business error (optional).
 * @param message A human-readable error message providing additional context (optional).
 * @param details Additional details or information about the error (optional).
 * @param cause The underlying cause of the error, such as an exception (optional).
 */
open class BusinessError(
    val code: String? = null,
    override val message: String? = null,
    val details: String? = null,
    override val cause: Throwable? = null
) : Error(message, cause)

А вот интересно, при задании инстанции (экземпляра) этого класса, будет ли автоматически внутри него задан стэк вызова? Ведь он может быть так полезен при разборе ошибочных ситуаций!
Проверяем (полный текст теста здесь):

assertTrue(error.stackTrace[0].toString().startsWith("eu.sirotin.kotlin.error.ErrorTest"))

Ура! Создаётся!

Перейдём теперь к систематическому рассмотрению возможностей класса Result.

Чего мы хотим и как этого добиться

Наше систематическое изучение функций этого класса мы начнём с того, что попытаемся понять, а что мы хотим от подобного класса?

Итак, предположим мы собираемся запрограммировать функцию на Kotlin, которая вычисляет и возвращает некоторое значение. При вычислении может произойти некоторая ошибка. (Пример функции, не возвращающей значение, мы рассмотри позже).

Думаю, нам хотелось бы мочь:

  • в теле функции удобно задавать нормальный или ошибочный результат,

  • в коде вызова функции удобно:

    • Узнавать, проработала она нормально или с с ошибкой?

    • Получать нормальный либо ошибочный результат для дальнейшего использования либо

    • Проводить обработку обеих ситуаций.

Функции класса Result, позволяющие задавать результат внутри некоторой функции и определять его «начинку» в вызывающей функции приведены в следующей таблице:

Таблица выбора функции задания и получения результата.
Таблица выбора функции задания и получения результата.

Остальные функции класса посвящены обработке упакованного в инстанцию класса результата (нормального либо ошибочного - в виде информации об ошибке). 

Эти функции в целом покрывают большинство мысленных потребностей такой обработки. Эти потребности выписаны в первом столбец таблицы внизу.

Если вы ищете функцию, удовлетворяющую эту потребность, вы должны в соответствующем ей столбце (в котором стоит указатель “¬") подобрать кандидатов, имеющих в соответствующей клетке в нижней части таблицы “Y”.

Таблица выбора функции обработки результата в зависимости от требования.
Таблица выбора функции обработки результата в зависимости от требования.

Далее мы рассмотрим характерные примеры обработки ошибок с помощью класса Result.

Задание результата в теле функции

В Kotlin есть унаследованная от Java функция-расширение String, вычисляющая целочисленное представление строки. Вызывается эта функция так:

val n = “4”.toInt()

Если исходная строка не является текстовым представлением целого числа, то функция выбрасывает Exception.

Давайте создадим функцию, которая так не безобразничает, а выдаёт в качестве результата либо целое число, либо информацию об исключении. А дальше на примере этой функции рассмотрим правила использования класса Result.

Новички в Kotlin, пришедшие из Java, напишут эту функцию наверное примерно так:

/**
 * Converts the current string to an integer safely, handling exceptions naively.
 *
 * This function attempts to convert the string to an integer using the [toInt] method. If successful,
 * it returns the integer as a [Result.success]. If an exception occurs during the conversion,
 * it returns a [Result.failure] containing the caught exception.
 *
 * @return A [Result] containing the integer value if the conversion is successful, or an error result
 * if an exception occurs.
 */
private fun String.toIntSafeNaive(): Result<Int>  {
    return try {
        // Attempt to convert the string to an integer.
        val x = this.toInt()
        // Return a success result with the integer value.
        Result.success(x)
    } catch (e: Exception) {
        // Return a failure result with the caught exception.
        Result.failure(e)
    }
}

Однако в Kotlin есть функция runCatching(...), которая позволяет сделать тоже самое элегантнее:

/**
 * Converts the current string to an integer safely using [runCatching].
 *
 * This function safely attempts to convert the string to an integer using [runCatching].
 * It returns a [Result.success] containing the integer value if the conversion is successful,
 * or a [Result.failure] containing the caught exception if an exception occurs.
 *
 * @return A [Result] containing the integer value if the conversion is successful, or an error result
 * if an exception occurs.
 */
private fun String.toIntSafe(): Result<Int>  = runCatching { this.toInt() }

Проверка и “распаковка” результата

Проверяем, действительно ли результат одинаков и заодно посмотрим, как работают функции, перечисленные в первой таблице вверху.

    /**
     * Test case for comparing two realizations of the `toIntSafe` function.
     *
     * This test compares the behavior of two different realizations of the `toIntSafe` function,
     * one using the `toIntSafeNaive` implementation and the other using the standard `toIntSafe` function.
     * It validates their results in terms of success, failure, and exception messages.
     *
     * - It obtains two results for successful conversions from "21" to an integer, one from each implementation.
     * - It compares the integer values obtained from both results.
     * - It verifies that both results indicate success.
     * - It checks that both results do not indicate failure.
     * - It obtains two results for failed conversions from "21.1" to an integer, one from each implementation.
     * - It compares the exception messages obtained from both results.
     * - It verifies that both results indicate failure.
     * - It checks that both results do not indicate success.
     */
    @Test
    fun `Compare realizations of Error toIntSafe`() {
        // Obtain two results for successful conversions from "21" to an integer.
        val resultSuccess1 = "21".toIntSafeNaive()
        val resultSuccess2 = "21".toIntSafe()

        // Compare the integer values obtained from both results.
        assertEquals(resultSuccess1.getOrNull(), resultSuccess2.getOrNull())

        // Verify that both results indicate success.
        assertTrue(resultSuccess1.isSuccess)
        assertTrue(resultSuccess2.isSuccess)

        // Check that both results do not indicate failure.
        assertFalse(resultSuccess1.isFailure)
        assertFalse(resultSuccess2.isFailure)

        // Check toString()
        assertEquals("Success(21)", resultSuccess1.toString())

        // Obtain two results for failed conversions from "21.1" to an integer.
        val resultFailure1 = "21.1".toIntSafeNaive()
        val resultFailure2 = "21.1".toIntSafe()

        // Compare the exception messages obtained from both results.
        assertEquals(resultFailure1.exceptionOrNull()?.message, resultFailure2.exceptionOrNull()?.message)

        // Verify that both results indicate failure.
        assertTrue(resultFailure1.isFailure)
        assertTrue(resultFailure2.isFailure)

        // Check that both results do not indicate success.
        assertFalse(resultFailure1.isSuccess)
        assertFalse(resultFailure2.isSuccess)

        // Check toString()
        assertEquals("Failure(java.lang.NumberFormatException: For input string: \"21.1\")", resultFailure1.toString())
    }

В этом примере мы не только рассмотрели, как действуют функции из первой таблицы, но и использовали также функции “прямой” распаковки результата с помощью функций getOrNull(...) и exceptionOrNull(...).

Исправление ошибочного результата

Очень часто бывает так, что любой ошибочный результат мы можем заменить неким стандартным значением. В этом случае наш лучший друг - функция  getOrDefault(…)

    /**
     * Test case for using the `getOrDefault` function with `toIntSafe`.
     *
     * This test demonstrates the usage of the `getOrDefault` function with the `toIntSafe` function to handle
     * default values in case of conversion failure.
     *
     * - It obtains a result for a successful conversion from "21" to an integer using `toIntSafe`.
     * - It uses `getOrDefault` to retrieve the value from the result and provide a default value (12).
     * - It verifies that the obtained value matches the expected integer (21).
     * - It obtains a result for a failed conversion from "21.3" to an integer using `toIntSafe`.
     * - It uses `getOrDefault` to retrieve the value from the result and provide a default value (12).
     * - It verifies that the obtained value matches the provided default value (12).
     */
    @Test
    fun `Using getOrDefault`() {
        // Obtain a result for a successful conversion from "21" to an integer using `toIntSafe`.
        val result1 = "21".toIntSafe()

        // Use `getOrDefault` to retrieve the value and provide a default value (12).
        val resultValue1 = result1.getOrDefault(12)

        // Verify that the obtained value matches the expected integer (21).
        assertEquals(21, resultValue1)

        // Obtain a result for a failed conversion from "21.3" to an integer using `toIntSafe`.
        val result2 = "21.3".toIntSafe()

        // Use `getOrDefault` to retrieve the value and provide a default value (12).
        val resultValue2 = result2.getOrDefault(12)

        // Verify that the obtained value matches the provided default value (12).
        assertEquals(12, resultValue2)
    }

Если в случае ошибки мы всегда знаем как задать в ошибочной ситуации новое значение, но логика эта не тривиальная, мы используем функцию getOrElse(…)


    /**
     * Returns an error message based on the provided exception [exception].
     *
     * @param exception The exception that occurred during the operation.
     * @return An error message based on the provided exception [exception].
     */
    private fun getFalseValue(exception: Throwable): String {
        //Expected format here: 'For input string: "1.1"'
        val arr = exception.message!!.split("\"")
        return "False format by ${arr[1]}"
    }

    /**
     * Adds two strings representing integers and returns the result as a string.
     *
     * @param a The first input string.
     * @param b The second input string.
     * @return A string representing the sum of the integer values in [a] and [b], or an error message
     * if the conversion or addition fails.
     */
    private fun addAsString(a: String, b: String): String {
        return runCatching {
            "${a.toInt() + b.toInt()}"}
            .getOrElse { e->getFalseValue(e) }
    }

    /**
     * Test case for using the `getOrElse` function with the `addAsString` function.
     *
     * This test demonstrates the usage of the `getOrElse` function with the `addAsString` function
     * to handle default values or alternative results.
     *
     * - It calls `addAsString` with valid inputs "1" and "2" and checks that the result is "3".
     * - It calls `addAsString` with an invalid input "1" and "1.1" and checks that the result is "False format by 1.1".
     */
    @Test
    fun `Using getOrElse`() {
        // Call `addAsString` with valid inputs "1" and "2" and check that the result is "3".
        val result1 = addAsString("1", "2")
        assertEquals("3", result1)

        // Call `addAsString` with an invalid input "1" and "1.1" and check that the result is "False format by 1.1".
        val result2 = addAsString("1", "1.1")
        assertEquals("False format by 1.1", result2)
    }

Один из самых распространённых сценариев состоит в том, что в случае ошибки мы выбрасываем исключение, а нормальный результат обрабатываем дальше. В этом случае мы используем getOrThrow(...)

    /**
     * Test case for using the `getOrThrow` function with the `toIntSafe` function.
     *
     * This test demonstrates the usage of the `getOrThrow` function with the `toIntSafe` function
     * to retrieve values or throw exceptions when working with results.
     *
     * - It uses `runCatching` to call `toIntSafe` with an invalid input "1.2" and attempts to retrieve the value.
     * - It asserts that an exception of type `NumberFormatException` is thrown.
     * - It uses `kotlin.runCatching` to call `toIntSafe` with a valid input "12" and attempts to retrieve the value.
     * - It asserts that no exception is thrown, indicating a successful result.
     */
    @Test
    fun `Using getOrThrow`() {
        // Use `runCatching` to call `toIntSafe` with an invalid input "1.2" and attempt to retrieve the value.
        val result1 = runCatching { "1.2".toIntSafe().getOrThrow() }.exceptionOrNull()

        // Assert that an exception of type `NumberFormatException` is thrown.
        assertNotNull(result1)
        assertIs<NumberFormatException>(result1)

        // Use `kotlin.runCatching` to call `toIntSafe` with a valid input "12" and attempt to retrieve the value.
        val result2 = kotlin.runCatching { "12".toIntSafe().getOrThrow() }.exceptionOrNull()

        // Assert that no exception is thrown, indicating a successful result.
        assertNull(result2)
    }

Трансформация результатов

Мы видим, что подход с запаковываем нормальных и ошибочных результатов в инстанцию класса Result пока неплохо работает. Досадная неприятность - на выходе результат нам надо распаковывать, чтобы использовать дальше. Получается, что мы сэкономили в одном месте и платим за это в другом.

Это верно только отчасти. Класс Result предоставляет несколько удобных функций, которые позволяют трансформировать одно или оба (нормальное и ошибочное) значения без предварительной распаковки.

Первая такая функция map(..), которая позволяет трансформировать нормальный результат и оставляет ошибочный без изменения.

Для демонстрации её возможностей мы создадим функцию, использующую нашу функцию toIntSafe(…) и увеличивающее в нормальном случае значение на 1 без предварительной распаковки. 

    /**
     * Increases an integer value obtained from a string by 1.
     *
     * This function takes a string input [x], attempts to convert it to an integer using [toIntSafe],
     * and then increases the resulting integer value by 1 using the `map` function from the [Result] class.
     * The final result is a [Result] containing the incremented integer value.
     *
     * @param x The string representing an integer.
     * @return A [Result] containing the incremented integer value or an error result if the conversion fails.
     */
    private fun increase(x: String): Result<Int> {
        return x.toIntSafe()
            .map { it + 1 }
    }

Проверим, как она работает:

    /**
     * Test case for using the `map` function with the `increase` function.
     *
     * This test demonstrates the usage of the `map` function to increment an integer value obtained from a string.
     *
     * - It calls the `increase` function with the valid input "112" and retrieves the result.
     * - It asserts that the result is 113, indicating a successful increment.
     * - It calls the `increase` function with an invalid input "112.9" and attempts to retrieve the exception.
     * - It asserts that an exception of type `NumberFormatException` is thrown, indicating a failed conversion.
     */
    @Test
    fun `Using map`() {
        // Call the `increase` function with the valid input "112" and retrieve the result.
        val result1 = increase("112").getOrNull()!!

        // Assert that the result is 113, indicating a successful increment.
        assertEquals(113, result1)

        // Call the `increase` function with an invalid input "112.9" and attempt to retrieve the exception.
        val result2 = increase("112.9").exceptionOrNull()!!

        // Assert that an exception of type `NumberFormatException` is thrown, indicating a failed conversion.
        assertNotNull(result2)
        assertIs<NumberFormatException>(result2)
    }

Если мы опасаемся, что в процессе трансформации может произойти ошибка, мы должны использовать функцию mapCatching(…). В этом случае в конце нам надо разбираться, произошла ли ошибка и если да, то где - до использования функции map(...) или в ней внутри. 

Для демонстрации подхода мы создадим ещё одну функцию, складывающую значения, заданные в текстовом виде:

    /**
     * Adds two integers obtained from strings and returns the result as a [Result].
     *
     * This function takes two string inputs [x] and [y], attempts to convert them to integers using [toIntSafe],
     * and then adds the resulting integers. The operation is performed using the `mapCatching` function from the [Result] class.
     * The final result is a [Result] containing the sum of the integers or an error result if the conversion or addition fails.
     *
     * @param x The first string representing an integer.
     * @param y The second string representing an integer.
     * @return A [Result] containing the sum of the integers or an error result if the conversion or addition fails.
     */
    private fun add(x: String, y: String): Result<Int> {
        return x.toIntSafe()
            .mapCatching { it + y.toInt() }
    }

Проверяем:


    /**
     * Test case for using the `mapCatching` function with the `add` function.
     *
     * This test demonstrates the usage of the `mapCatching` function to add two integers obtained from strings.
     *
     * - It calls the `add` function with valid inputs "112" and "38" and retrieves the result.
     * - It asserts that the result is 150, indicating a successful addition.
     * - It calls the `add` function with invalid inputs "112.9" and "38" and attempts to retrieve the exception.
     * - It asserts that an exception of type `NumberFormatException` is thrown, and the error message contains "112.9".
     * - It calls the `add` function with valid inputs "112" and "38.5" and attempts to retrieve the exception.
     * - It asserts that an exception of type `NumberFormatException` is thrown, and the error message contains "38.5".
     */
    @Test
    fun `Using mapCatching`() {
        // Call the `add` function with valid inputs "112" and "38" and retrieve the result.
        val result1 = add("112", "38").getOrNull()!!

        // Assert that the result is 150, indicating a successful addition.
        assertEquals(150, result1)

        // Call the `add` function with invalid inputs "112.9" and "38" and attempt to retrieve the exception.
        val result2 = add("112.9", "38").exceptionOrNull()!!

        // Assert that an exception of type `NumberFormatException` is thrown, and the error message contains "112.9".
        assertNotNull(result2)
        assertIs<NumberFormatException>(result2)
        assertTrue(result2.message!!.contains("112.9"))

        // Call the `add` function with valid inputs "112" and "38.5" and attempt to retrieve the exception.
        val result3 = add("112", "38.5").exceptionOrNull()!!

        // Assert that an exception of type `NumberFormatException` is thrown, and the error message contains "38.5".
        assertNotNull(result3)
        assertIs<NumberFormatException>(result3)
        assertTrue(result3.message!!.contains("38.5"))
    }

Функции recover(…) и recoverCatching(…) работают аналогично map(…) mapCatching(…) но используют при этом ошибочное значение. Это может быть полезно, если вы по информации об ошибке можете задать новое правильное значение.


    /**
     * Test case for using the `recover` function with the `toIntSafe` function.
     *
     * This test demonstrates the usage of the `recover` function to handle exceptions when working with results.
     *
     * - It uses `toIntSafe` to convert "-15" to an integer and then uses `recover` to retrieve the result or
     *   the stack trace size in case of an exception. It asserts that the result is -15.
     * - It uses `toIntSafe` to convert "-15.1" to an integer and then uses `recover` to retrieve the result or
     *   the stack trace size in case of an exception. It asserts that the stack trace size is greater than 10.
     */
    @Test
    fun `Using recover`() {
        // Use `toIntSafe` to convert "-15" to an integer and use `recover` to retrieve the result or the stack trace size.
        val result1 = "-15".toIntSafe().recover { exception -> exception.stackTrace.size }

        // Assert that the result is -15.
        assertEquals(-15, result1.getOrNull())

        // Use `toIntSafe` to convert "-15.1" to an integer and use `recover` to retrieve the result or the stack trace size.
        val result2 = "-15.1".toIntSafe().recover { exception -> exception.stackTrace.size }

        // Assert that the stack trace size is greater than 10.
        assertTrue(result2.getOrNull()!! > 10)
    }


    /**
     * Test case for using the `recoverCatching` function with the `toIntSafe` function.
     *
     * This test demonstrates the usage of the `recoverCatching` function to handle exceptions when working with results.
     *
     * - It uses `runCatching` to call `toIntSafe` with "-15.1" and then uses `recover` to convert "0.0" to an integer
     *   or retrieve an exception in case of a failure. It asserts that an exception of type `NumberFormatException` is thrown,
     *   and the error message contains "0.0".
     * - It directly uses `recoverCatching` with "-15.1" and converts "0.0" to an integer. It asserts that an exception
     *   of type `NumberFormatException` is thrown, and the error message contains "0.0".
     * - It directly uses `recoverCatching` with "-15" and converts "0.0" to an integer. It asserts that the result is -15.
     * - It directly uses `recoverCatching` with "-15.1" and converts "2" to an integer. It asserts that the result is 2.
     */
    @Test
    fun `Using recoverCatching`() {


        // Directly use `recoverCatching` with "-15.1" and convert "0.0" to an integer.
        val result2 =  "-15.1".toIntSafe().recoverCatching { "0.0".toInt() }.exceptionOrNull()

        // Assert that an exception of type `NumberFormatException` is thrown, and the error message contains "0.0".
        assertNotNull(result2)
        assertIs<NumberFormatException>(result2)
        assertTrue(result2.message!!.contains("0.0"))

        // Directly use `recoverCatching` with "-15" and convert "0.0" to an integer.
        val result3 =  "-15".toIntSafe().recoverCatching { "0.0".toInt() }.getOrNull()

        // Assert that the result is -15.
        assertEquals(-15, result3)

        // Directly use `recoverCatching` with "-15.1" and convert "2" to an integer.
        val result4 =  "-15.1".toIntSafe().recoverCatching { "2".toInt() }.getOrNull()

        // Assert that the result is 2.
        assertEquals(2, result4)
    }

Функция fold(…) позволяет объединить обе рассмотренные функции вызывать вместе, в одном вызове. 

    /**
     * Adds two strings representing integers and returns the result as a string.
     *
     * @param a The first input string.
     * @param b The second input string.
     * @return A string representing the sum of the integer values in [a] and [b], or an error message
     * if the conversion or addition fails.
     */
    private fun addAsString1(a: String, b: String): String {
        return runCatching {
            "${a.toInt() + b.toInt()}"   }
            .fold(
                {"Result: $it"},
                {getFalseValue(it)}
            )
    }

Чтобы повысить читабельность вашей реализации, рекомендуется использовать имена параметров onSuccess и onFailure.

    /**
     * Adds two strings representing integers and returns the result as a string.
     *
     * @param a The first input string.
     * @param b The second input string.
     * @return A string representing the sum of the integer values in [a] and [b], or an error message
     * if the conversion or addition fails.
     */
    private fun addAsString2(a: String, b: String): String {
        return runCatching {
            "${a.toInt() + b.toInt()}"   }
            .fold(
                onSuccess = {"Result: $it"},
                onFailure = {getFalseValue(it)}
            )
    }

Проверяем:


    /**
     * Test case for using the `fold` function with two different implementations of `addAsString`.
     *
     * This test demonstrates the usage of the `fold` function to obtain results from two different implementations
     * of the `addAsString` function and verifies their correctness.
     *
     * - It calls `addAsString1` with valid inputs "1" and "4" and checks that the result is "Result: 5".
     * - It calls `addAsString2` with the same valid inputs and verifies that the result is also "Result: 5".
     * - It calls `addAsString1` with an invalid input "1.3" and "4" and checks that the result is "False format by 1.3".
     * - It calls `addAsString2` with the same invalid inputs and verifies that the result is also "False format by 1.3".
     */
    @Test
    fun `Using fold`() {
        // Call `addAsString1` with valid inputs "1" and "4" and check that the result is "Result: 5".
        val result1 = addAsString1("1", "4")
        assertEquals("Result: 5", result1)

        // Call `addAsString2` with the same valid inputs and verify that the result is also "Result: 5".
        val result2 = addAsString2("1", "4")
        assertEquals("Result: 5", result2)

        // Call `addAsString1` with an invalid input "1.3" and "4" and check that the result is "False format by 1.3".
        val result3 = addAsString1("1.3", "4")
        assertEquals("False format by 1.3", result3)

        // Call `addAsString2` with the same invalid inputs and verify that the result is also "False format by 1.3".
        val result4 = addAsString2("1.3", "4")
        assertEquals("False format by 1.3", result4)
    }

Того же результата можно добиться путём последовательного вызова двух функций onSuccess(…) и onFailure(…). Шарм подхода проявляется в том, что результат не зависит от того, в какой последовательности будут вызваны эти функции.

    /**
     * Test case for using the `onSuccess` and `onFailure` functions with `toIntSafe`.
     *
     * This test demonstrates the usage of the `onSuccess` and `onFailure` functions with the `toIntSafe` function
     * to handle success and failure cases and update a result variable.
     *
     * - It uses `toIntSafe` to convert "3.5" to an integer and uses `onFailure` to set the result to "FAILURE".
     * - It asserts that the result variable is "FAILURE" since the conversion fails.
     * - It uses `toIntSafe` to convert "3.5" to an integer and uses `onSuccess` to set the result to "SUCCESS".
     * - It again uses `onFailure`, but this time after `onSuccess`, and asserts that the result remains "FAILURE"
     * - It uses `toIntSafe` to convert "35" to an integer and uses `onFailure` to set the result to "FAILURE".
     * - It asserts that the result variable is "SUCCESS" since the conversion is successful and `onFailure` is not called.
     * - It uses `toIntSafe` to convert "35" to an integer and uses `onSuccess` to set the result to "SUCCESS".
     * - It again uses `onFailure`, but this time after `onSuccess`, and asserts that the result remains "SUCCESS"
     *   since `onFailure` will be not called.
     */
    @Test
    fun `Using onSuccess and onFailure`() {
        // Initialize the result variable.
        var result = ""

        // Use `toIntSafe` to convert "3.5" to an integer and use `onFailure` to set the result to "FAILURE".
        "3.5".toIntSafe()
            .onFailure { result = FAILURE }

        // Assert that the result variable is "FAILURE" since the conversion fails.
        assertEquals(FAILURE, result)

        // Use `toIntSafe` to convert "3.5" to an integer and use `onSuccess` to set the result to "SUCCESS".
        //  but it should pass.
        "3.5".toIntSafe()
            .onSuccess { result = SUCCESS }
            .onFailure { result = FAILURE }

        // Assert that the result variable remains "FAILURE" `.
        assertEquals(FAILURE, result)

        result = SUCCESS
        // Use `toIntSafe` to convert "35" to an integer and use `onFailure` to set the result to "FAILURE".
        "35".toIntSafe()
            .onFailure { result = FAILURE }

        // Assert that the result variable is "SUCCESS" since the conversion is successful and the action
        // of `onFailure` is not called.
        assertEquals(SUCCESS, result)

        // Use `toIntSafe` to convert "35" to an integer and use `onSuccess` to set the result to "SUCCESS".
        // Then, use `onFailure`, but it should not override the previous `onSuccess` value.
        "35".toIntSafe()
            .onSuccess { result = SUCCESS }
            .onFailure { result = FAILURE }

        // Assert that the result variable remains "SUCCESS" since `onFailure` does not override the previous `onSuccess`
        //because it action was not called
        assertEquals(SUCCESS, result)
    }

Обработка ошибок в "безрезультатных" функциях

В рассмотренных выше примерах мы рассмотрели использование класса Result в функциях, возвращающих результат. А что делать, если с точки зрения бизнес‑логики функция никакого результата не возвращает? Например, запись в базу данных или посылка данных по коммуникационному каналу как правило заканчивается хорошо, но иногда сбоит. Как обрабатывать ошибки без бросания Exception в этом случае?

Один из хороших подходов — сделать функцию возражающей опционально информацию об ошибке, как в примере внизу.

Но сначала определим две вспомогательные функции, которые нам потребуются в последующих тестах:

    /**
     * Dummy function for an action that should be executed when a value is available.
     * This function is used to fix the call structure and set the 'actionWithValueExecuted' flag to true.
     *
     * @param ignoredValue The integer value (ignored) for which the action is performed.
     */
    private fun someActionWithValue(ignoredValue: Int) {
        actionWithValueExecuted = true
    }

    /**
     * Dummy function for an action that should be executed when an error occurs.
     * This function is used to fix the call structure and set the 'actionWithErrorExecuted' flag to true.
     *
     * @param ignoredError The Throwable (ignored) representing the error condition for which the action is performed.
     */
    private fun someActionWithError(ignoredError: Throwable) {
        actionWithErrorExecuted = true
    }

Ну а теперь модифицированная "безрезультатная" функция и тест к ней:

    /**
     * Processes an integer value represented as a string, following the strategy of
     * "make something by success and throw by failure."
     *
     * @param value The string representation of an integer value.
     * @return A Throwable instance if there is a conversion failure, otherwise null.
     */
    private fun processIntValue(value: String): Throwable? =
        value.toIntSafe()
            .onSuccess { someActionWithValue(it) }
            .exceptionOrNull()
    
    /**
    * Test case to demonstrate processing an integer value as a string and handling success and failure.
    */
    @Test
    fun `Using pseudo result-less`() {

        // When "25" is processed, it should result in a null Throwable, indicating success.
        assertNull(processIntValue("25"))
        assertTrue(actionWithValueExecuted)

        // Reset the action flag for the next test.
        actionWithValueExecuted = false

        // When "25.9" is processed, it should result in a non-null Throwable, indicating a failure.
        assertNotNull(processIntValue("25.9"))
        assertFalse(actionWithValueExecuted)
    }

"Тройственные" результаты

Иногда встречаются ситуации, когда сначала надо проверить, применима ли данная функция к данному набору параметров и только после этого её применять. Привет этом, например при парсинге или валидации данных, эта проверка и собственно применение функции пересекаются по коду и проверочная часть существенно «дороже» остальных действий.

В этом случае мы можем говорить о «тройственном» результате функции:

  • нормальный результат

  • информация об ошибкe

  • информация о том, что функция неприменима к данному набору параметров.

Представим, что нам нужно имплементировать функцию, которая по текстовому значению, ожидая что это целое число, возвращает его знак «+» или «‑», а в случае 0 считается, что функция неприменима. Разумеется, входной параметр — это строка и возможно, не представляет никакое целое число.

Возможный подход в этом случае — первые две альтернативы паковать как и прежде в Result, но для обозначения тройственности сделать возвращаемое значение опциональным (возможно — null):


    /**
     * Attempts to extract the sign of an integer from the current [String].
     *
     * @return A [Result] containing the sign of the integer as a [String] ("+" or "-"), or `null` by zero
     * if the operation is not applicable (e.g., for non-integer strings) [Result] contains exception.
     */
    private fun String.signOfInt(): Result<String>? {
        // Try to convert the string to an integer using the toIntSafe() extension function.
        val result = this.toIntSafe()

        // If the conversion fails, return a failure result with the exception.
        if (result.isFailure) return Result.failure(result.exceptionOrNull()!!)

        // Determine the sign of the integer and return it as a success result.
        return when (result.getOrNull()!!.sign) {
            1 -> Result.success("+")
            -1 -> Result.success("-")
            else -> null
        }
    }

    /**
     * Test case for using the `signOfInt` function with result-less return.
     *
     * This test demonstrates the usage of the `signOfInt` function to obtain results and exceptions in scenarios where
     * the function may return a result or no result (`null`) based on the input.
     *
     * - It calls the `signOfInt` function with an invalid input "1.1" and uses `exceptionOrNull` to retrieve the exception.
     *   It asserts that an exception of type `NumberFormatException` is thrown, and the error message contains "1.1".
     * - It calls the `signOfInt` function with a valid input "0" and expects the result to be `null` since "0" has no sign.
     * - It calls the `signOfInt` function with valid inputs "+11" and "-112" and uses `getOrNull` to retrieve the result.
     *   It asserts that the results are "+", and "-", respectively.
     */
    @Test
    fun `Using not-applicable`() {
        // Call the `signOfInt` function with an invalid input "1.1" and retrieve the exception.
        val result1 = "1.1".signOfInt()?.exceptionOrNull()

        // Assert that an exception of type `NumberFormatException` is thrown, and the error message contains "1.1".
        assertNotNull(result1)
        assertIs<NumberFormatException>(result1)
        assertTrue(result1.message!!.contains("1.1"))

        // Call the `signOfInt` function with a valid input "0" and expect the result to be `null` since "0" has no sign.
        assertNull("0".signOfInt())

        // Call the `signOfInt` function with valid inputs "+11" and "-112" and retrieve the results.
        val result2 = "+11".signOfInt()?.getOrNull()
        val result3 = "-112".signOfInt()?.getOrNull()

        // Assert that the results are "+", and "-", respectively.
        assertEquals("+", result2)
        assertEquals("-", result3)
    }

Многошаговые сценарии использования Result

Функции класса Result можно использовать в цепочках и каскадах вызовов.
Ниже мы рассмотрим парочку самых типичных.

Первый сценарий состоит в пребывании цепочки обработки или вычислений при первой встретившейся ошибке. При этом в случае ошибки мы хотим знать, на каком этапе она произошла.

Представим, что мы хоти вычислить значение по формуле:

y =ax^2 + bx + c

При этом a, b, c заданы в виде строк и каждая строка может быть ошибочной. Вот возможное решение:

    /**
     * Calculates the expression ax^2 + bx + c for given values of x, a, b, and c.
     * This function employs the strategy of "stopping normal processing by the first failure in the chain."
     *
     * @param x The value of 'x' in the equation.
     * @param a The coefficient 'a' as a String.
     * @param b The coefficient 'b' as a String.
     * @param c The coefficient 'c' as a String.
     * @return A Result<Int> representing the result of the calculation or an exception if any of the conversions fail.
     */
    private fun `calculate ax2 + bx + c`(x: Int, a: String, b: String, c: String): Result<Int> =
        runCatching { a.toInt() * x * x }
            .mapCatching { it + b.toInt() * x }
            .mapCatching { it + c.toInt() }

    /**
     * Test case to demonstrate chained calculations and catching the first failure.
     */
    @Test
    fun `Using chained call with catching first failure`() {

        // Calculate the expression successfully with valid inputs.
        assertEquals(6, `calculate ax2 + bx + c`(1, "1", "2", "3").getOrNull()!!)

        // Attempt to calculate with 'a' as a non-integer should result in an exception.
        assertTrue(`calculate ax2 + bx + c`(1, "1.1", "2", "3").exceptionOrNull().toString().contains("1.1"))

        // Attempt to calculate with 'b' as a non-integer should result in an exception.
        assertTrue(`calculate ax2 + bx + c`(1, "1", "2.2", "3").exceptionOrNull().toString().contains("2.2"))

        // Attempt to calculate with 'c' as a non-integer should result in an exception.
        assertTrue(`calculate ax2 + bx + c`(1, "1", "2", "3.3").exceptionOrNull().toString().contains("3.3"))
    }

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

В примере внизу (это последний пример!:‑) мы при конвертировании строки в целое число в случае неудачи с прямым конвертирование предположим, что строка представляет собой Double, а если и это не поможет, то выдадим максимальное целое значение:

    /**
     * Converts a string to an integer using a strategy of "trying many approaches to get a value."
     * This function first attempts to convert the string to an integer safely. If that fails, it tries
     * to convert the string to a double and then to an integer. If both conversions fail, it returns
     * Int.MAX_VALUE.
     *
     * @return An integer value obtained from the string or Int.MAX_VALUE if all conversion attempts fail.
     */
    private fun String.toIntAnyway(): Int =
        this.toIntSafe()
            .recoverCatching { this.toDouble().toInt() }
            .getOrDefault(Int.MAX_VALUE)

    /**
     * Test cases to demonstrate the "try many approaches to get a value" strategy.
     */
    @Test
    fun `Using chained call anyway strategy`() {

        // Successfully convert "2" to an integer.
        assertEquals(2, "2".toIntAnyway())

        // Convert "2.2" to an integer after trying the double conversion.
        assertEquals(2, "2.2".toIntAnyway())

        // Convert "23.81e5" to an integer after trying the double conversion.
        assertEquals(2381000, " 23.81e5".toIntAnyway())

        // Conversion fails for " Very match," so it returns Int.MAX_VALUE.
        assertEquals(Int.MAX_VALUE, " Very match".toIntAnyway())
    }

Вместо заключения

Если вы дочитали это руководство до этого места, я надеюсь — вам теперь понятно, что при обработке ошибок в Kotlin не стоит создавать собственные классы или приспосабливать для этого класс Either из библиотеки Arrow. Просто используйте правильно то, что уже в языке и так есть — класс Result.

Напомню, что исходные коды для этой статьи вы найдёте здесь.

Я надеюсь, эта статья помогла вам сформировать вам правильные ментальные модели обработки ошибок в Kotlin. Если тема ментальных моделей в программировании вас интересует, возможно вас заинтересуют эта и эта статьи. А если вы найдёте их интересными, присмотритесь или даже вступите в эту Телеграм‑группу.

А ещё я пишу книгу «Мемуары кочевого программиста: байки, были, думы». Если у вас есть время и настроение — я приглашаю вас полистать её страницы.

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


  1. Viacheslav01
    26.09.2023 14:16

    Передумал