JavaRush /Курсы /Kotlin SELF /Result<T> — как хранить статус «успех/ошибка»

Result<T> — как хранить статус «успех/ошибка»

Kotlin SELF
40 уровень , 3 лекция
Открыта

1. Введение

Когда вы только начинаете программировать, исключения часто воспринимаются как молния: «что-то где-то ударило, и программа упала». Но по мере роста проекта выясняется, что часть ошибок — это вообще-то нормальные жизненные ситуации. Пользователь ввёл не число, попросил удалить запись, которой нет, указал пустую категорию. Это не «конец света», это просто «нет результата» — но с объяснением.

И вот тут начинается типичная дилемма: если везде кидать исключения, код пользователя вашей функции превращается в лес try/catch. Если везде возвращать null, то быстро теряется смысл: почему null? «Не найдено»? «Некорректный ввод»? «Сломалась логика»? Kotlin даже в примерах к стандартной библиотеке показывает, что иногда лучше пользоваться безопасными альтернативами вроде toIntOrNull() вместо падения toInt() — именно потому, что «не число» часто ожидаемо.

Result<T> — это один из инструментов, который помогает сказать: «Функция может завершиться успехом или ошибкой, и я возвращу это как значение, а не как взрыв».

2. Что такое Result<T>: значение или ошибка в одной коробке

Представьте себе коробку с наклейкой «Осторожно»: внутри либо полезная вещь (например, Int), либо бумажка с объяснением, почему вещь не получилась (в виде Throwable). Это и есть Result<T>: контейнер, который хранит один из двух исходов выполнения — успех с T или ошибка с исключением.

Важная мысль: Result<T> — это не магия и не замена всем подходам. Это всего лишь форма передачи результата, которая позволяет не заставлять каждого пользователя функции писать try/catch, если ситуация ожидаема. Но при этом «ошибка» остаётся не строкой и не null, а полноценным исключением (тип + сообщение + потенциальная причина). Это удобно, потому что исключения в Kotlin имеют богатую экосистему типов (NumberFormatException, IllegalArgumentException и т.д.).

Схематично это можно представлять так:

flowchart TD
    A[Функция что-то делает] --> B{Получилось?}
    B -->|да| C["Result.success(value)"]
    B -->|нет| D["Result.failure(exception)"]
    C --> E[Код вызывающего решает: взять значение, подставить fallback или остановиться]
    D --> E

3. Как создать Result: success(...) и failure(...)

Создавать Result очень просто. Есть два базовых фабричных способа:

  • Result.success(value) — упаковали значение.
  • Result.failure(exception) — упаковали ошибку.

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

fun main() {
    val ok: Result<Int> = Result.success(10)
    val bad: Result<Int> = Result.failure(IllegalArgumentException("nope"))

    println(ok)   // Success(10)
    println(bad)  // Failure(java.lang.IllegalArgumentException: nope)
}

Это ещё не «красивое приложение», но уже видно: в переменной лежит исход, а не «взорвалось/не взорвалось».

Теперь важный практический нюанс: чем качественнее исключение внутри failure, тем проще потом понять, что произошло.

4. Возвращаем Result из функций на примере проекта

Чтобы примеры не были «в вакууме», продолжим наш практический консольный проект (условно назовём его BudgetBuddy): он хранит расходы и умеет принимать команды add, list, remove. Мы не лезем в файлы и сеть — это будут другие дни курса — поэтому хранилище будет просто в памяти.

В домене у нас есть модель расхода:

data class Expense(
    val id: Int,
    val amount: Int,
    val category: String,
    val note: String
)

Теперь представим типичную задачу: пользователь вводит сумму текстом, а мы хотим распарсить её в Int. Если написать «в лоб» через toInt(), то при неправильном вводе получим NumberFormatException.

В ранних лекциях мы уже видели вариант «мягкого» парсинга через toIntOrNull(). Сейчас сделаем версию с явным контрактом: вернём Result<Int>, где при ошибке будет исключение с понятным сообщением.

fun parseAmount(text: String): Result<Int> {
    val trimmed = text.trim()
    val n = trimmed.toIntOrNull()
        ?: return Result.failure(NumberFormatException("Amount is not a number: '$text'"))

    if (n <= 0) {
        return Result.failure(IllegalArgumentException("Amount must be > 0, got $n"))
    }

    return Result.success(n)
}

Обратите внимание на стиль: мы не кидаем исключение наружу, а упаковываем его как значение. И ещё один момент: мы добавили got ... — это ускоряет диагностику, потому что в сообщении видно фактическое значение.

5. Result как контракт: функция не решает судьбу ошибки

Очень легко неправильно понять Result: будто бы он «обрабатывает ошибку». Он не обрабатывает. Он просто аккуратно говорит: «Вот итог, дальше решай ты».

Чтобы почувствовать это, напишем функцию, которая создаёт расход, но возвращает Result<Expense>. Внутри она использует parseAmount.

fun createExpense(id: Int, amountText: String, category: String, note: String): Result<Expense> {
    val amountResult = parseAmount(amountText)
    val amount = amountResult.getOrElse { return Result.failure(it) }

    val cat = category.trim()
    if (cat.isEmpty()) {
        return Result.failure(IllegalArgumentException("Category must not be blank"))
    }

    return Result.success(Expense(id = id, amount = amount, category = cat, note = note.trim()))
}

Здесь случилась важная штука: мы применили getOrElse как способ извлечь значение или выйти по ошибке. Это похоже на стиль guard clauses: как только видим, что дальше нельзя продолжать корректно — аккуратно возвращаем ошибку, не делая огромный if на весь метод.

И да, мы могли бы сделать это через исключение, но тогда вызывающий обязан был бы писать try/catch (или программа «бах» — и всё). А наша цель — сделать контракт явно читаемым прямо в типе: Result<Expense>.

6. Работа с Result в CLI: извлечение, ветвление и пайплайны

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

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

Ниже — таблица основных методов, которые мы сегодня используем:

Метод Что возвращает Когда уместен Типичный риск
getOrNull()
T?
когда ошибка «не важна», и можно жить с null потерять причину ошибки
exceptionOrNull()
Throwable?
когда хотим отдельно достать ошибку и показать/залогировать забыть обработать успех
getOrElse { ... }
T
когда есть осмысленный fallback или хотим выйти из функции сделать «универсальный костыль»
getOrThrow()
T
или бросит исключение
когда на этом уровне ошибка уже аварийная превратить Result в бессмысленную обёртку

Теперь посмотрим это на коротких примерах.

getOrNull() — превращаем Result<T> в T?

Эта штука полезна, когда мы сознательно хотим «мягкое поведение»: либо число, либо null. В Kotlin такой стиль уже знаком по toIntOrNull() и по OrNull-методам коллекций.

fun main() {
    val n: Int? = parseAmount("123").getOrNull()
    println(n) // 123

    val bad: Int? = parseAmount("x").getOrNull()
    println(bad) // null
}

Минус очевиден: причина потери значения исчезла. Поэтому getOrNull() — это осознанный выбор: «мне не важна причина».

exceptionOrNull() — достаём исключение как данные

Если нам важно объяснить пользователю, почему команда не выполнилась, обычно хочется получить текст ошибки.

fun main() {
    val r = parseAmount("x")
    val msg = r.exceptionOrNull()?.message ?: "OK"
    println(msg) // Amount is not a number: 'x'
}

Обратите внимание: exceptionOrNull() возвращает Throwable?, поэтому безопасные вызовы ?. тут прямо напрашиваются.

getOrElse { ... } — осмысленный fallback или ранний выход

getOrElse хорош, когда вы действительно знаете, что делать при ошибке. Например, если пользователь не ввёл заметку, можно подставить «(no note)». Но с деньгами так делать опасно.

Покажем вариант, где fallback разумен:

fun normalizedNote(note: String): Result<String> =
    if (note.isBlank()) Result.failure(IllegalArgumentException("Empty note"))
    else Result.success(note.trim())

fun main() {
    val note = normalizedNote("   ").getOrElse { "(no note)" }
    println(note) // (no note)
}

Тут fallback — часть UX, а не «заметаем проблему под ковёр».

getOrThrow() — вернуть нас в мир исключений

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

fun main() {
    val amount = parseAmount("x").getOrThrow()
    println(amount) // сюда не дойдём
}

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

onSuccess {} и onFailure {}: ветвление без try/catch

Когда вы пишете консольный интерфейс, вам часто нужно сделать простую вещь: если успех — печатаем OK, если ошибка — печатаем ERROR: …. И вот здесь onSuccess / onFailure позволяют не превращать всё в цепочки if.

Сделаем маленькую функцию, которая печатает результат выполнения команды:

fun printCommandResult(result: Result<String>) {
    result
        .onSuccess { println("OK: $it") }
        .onFailure { println("ERROR: ${it.message}") }
}

И пример использования:

fun main() {
    val ok = Result.success("Expense added")
    val bad = Result.failure(IllegalArgumentException("Wrong command"))

    printCommandResult(ok)   // OK: Expense added
    printCommandResult(bad)  // ERROR: Wrong command
}

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

Мини‑пайплайн команды add в main

Давайте соберём небольшой кусок, похожий на настоящий ввод команд. Напомню, что в прошлых днях мы уже делали разбор строки, нормализацию, split, команды и т.д. Здесь мы просто покажем, как Result помогает не превратить main в огромный try/catch.

Команда:
add <amount> <category> <note...>

Например:
add 120 food Coffee

fun handleAddCommand(input: String, nextId: Int): Result<Expense> {
    val parts = input.trim().split(" ")
    if (parts.size < 4) {
        return Result.failure(IllegalArgumentException("Usage: add <amount> <category> <note...>"))
    }

    val amountText = parts[1]
    val category = parts[2]
    val note = parts.drop(3).joinToString(" ")

    return createExpense(id = nextId, amountText = amountText, category = category, note = note)
}

И вот как это может выглядеть в main:

fun main() {
    val input = "add x food Coffee"
    val result = handleAddCommand(input, nextId = 1)

    result
        .onSuccess { println("Added: $it") }
        .onFailure { println("Cannot add: ${it.message}") }
    // Cannot add: Amount is not a number: 'x'
}

Здесь важно, что ошибка «не число» — это NumberFormatException, типичный случай форматного сбоя. Но мы не даём ему «взорвать» программу. Мы превращаем его в управляемый исход.

Как не превратить Result в «прятатель ошибок»

Result легко использовать неправильно: завернуть ошибку, что-то пробормотать в консоль и продолжить так, будто всё в порядке. В результате программа становится очень «живучей»… но начинает врать. Это почти как заклеить лампочку «Check engine» изолентой: тише стало, но проблема не ушла.

Чтобы этого избежать, полезно держать в голове правило: после ошибки всегда должно быть решение. В CLI‑программе решения обычно три: показать сообщение и продолжить цикл, показать сообщение и завершить программу, или подставить осмысленный fallback. Если вы сделали onFailure { } и ничего не сделали внутри — вы как будто сказали: «Я увидел пожар и решил, что пусть дальше горит, я просто не смотрю».

Мини‑пример «правильной дисциплины» — превращать Result в строку, которую уже печатает CLI:

fun toCliMessage(result: Result<Expense>): String {
    val e = result.exceptionOrNull()
    return if (e != null) {
        "ERROR: ${e.message}"
    } else {
        val expense = result.getOrThrow()
        "OK: added id=${expense.id}, amount=${expense.amount}"
    }
}

Тут есть тонкость: мы сначала проверили ошибку, а потом уже сделали getOrThrow() — то есть «взорвёмся» только если нарушили собственную логику (если почему-то ошибка null, а значения нет — что обычно не случается при корректном Result). Этот стиль похож на fail-fast: падать нужно там, где сломалась логика программы, а не там, где пользователь просто ввёл x вместо числа.

7. Типичные ошибки при работе с Result<T>

Ошибка №1: превращать Result в null через getOrNull() и забывать, что вообще была ошибка.
Такое часто случается, когда хочется «ну чтобы просто работало». В итоге вы теряете причину и лишаете себя возможности нормально объяснить пользователю, что не так. Если по UX важно «почему», лучше достать exceptionOrNull() и сформировать сообщение, а не молча получать null.

Ошибка №2: ставить getOrElse { 0 } как универсальный костыль.
Для суммы расходов 0 может выглядеть невинно, но это может скрыть реальную проблему: например, пользователь ввёл мусор, а вы тихо записали расход на 0. Потом отчёты «почему-то» не сходятся. Fallback должен быть осмысленным именно в контексте задачи, а не просто удобным для компилятора.

Ошибка №3: писать Result.failure(Exception("oops")) без смысла и без фактических данных.
Сообщение oops может быть смешным ровно первые 3 секунды, пока вы не начнёте отлаживать. Нормальное сообщение должно говорить, что ожидалось и что получили, включая фактическое значение (got ...).

Ошибка №4: использовать onFailure { } как «глушитель» и продолжать как ни в чём не бывало.
Result не обязан «ронять» программу — и это его плюс. Но если вы при ошибке не выбрали политику (остановиться, продолжить, fallback), то вы не обработали ошибку, а спрятали её. В такой момент баги начинают путешествовать по коду тихо, и находят вас в самый неудобный день.

Ошибка №5: возвращать Result из функции, а в первом же месте вызова делать getOrThrow() везде подряд.
Так вы превращаете Result в лишнюю обёртку: формально вы «перенесли» исключение в значение, но тут же вернули исключение назад без выигрыша в читабельности. Если уж вы выбрали контракт Result, дайте вызывающему коду шанс осознанно обработать исход через onSuccess/onFailure, getOrElse, либо преобразование в понятное сообщение.

1
Задача
Kotlin SELF, 40 уровень, 3 лекция
Недоступна
Деление калькулятора
Деление калькулятора
1
Задача
Kotlin SELF, 40 уровень, 3 лекция
Недоступна
Ввод количества
Ввод количества
1
Задача
Kotlin SELF, 40 уровень, 3 лекция
Недоступна
Никнейм игрока
Никнейм игрока
1
Задача
Kotlin SELF, 40 уровень, 3 лекция
Недоступна
Итоговая цена
Итоговая цена
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ