JavaRush /Курсы /Swift SELF /Result<Success, Failure> — ошибки как значения

Result<Success, Failure> — ошибки как значения

Swift SELF
31 уровень , 0 лекция
Открыта

1. Иногда нужен Result, даже если уже есть throws

Если честно, throws в Swift очень удобен… пока вы не начинаете хотеть «положить итог операции в карман». Проблема в том, что throws — это не данные, а поток выполнения: либо функция вернула значение, либо «выпрыгнула» наружу через ошибку.

И вот вы пытаетесь сделать что-то вроде «соберу результаты для трёх операций, а потом разберусь» — и внезапно понимаете, что вам нужен контейнер, который может хранить либо успех, либо ошибку, как обычное значение.

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

Три модели «не получилось»: Optional, throws, Result

Когда вы пишете программу, «не получилось» бывает трёх разных видов. И если путать их между собой, код становится как коробка с проводами: вроде всё соединено, но трогать страшно. Давайте аккуратно разделим модели.

Optional — это история про «значение есть или значения нет». Обычно это нормально для ситуаций, где отсутствие — ожидаемая часть жизни. Например, пользователь мог не ввести строку (и readLine() вернул nil), или в словаре не оказалось ключа.

throws — это история про «операция либо завершилась, либо прервала выполнение и ушла наружу с ошибкой». Очень удобно для линейного кода в стиле «делай шаги подряд, а если где-то всё плохо — прекращай».

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

Вот небольшая табличка, чтобы это закрепилось в голове:

Модель Что хранит Где удобно Что теряем / ограничение
Optional<T>
T или nil «может отсутствовать» без причины Нет причины, почему отсутствует
throws
не хранит, а «прыгает» линейные цепочки действий трудно «сохранить исход» в переменную
Result<Success, Failure>
Success или Failure когда исход нужно хранить и передавать нужно явно обработать .success/.failure

2. Устройство Result и как его читать

Что такое Result технически

Важно снять ореол мистики: Result — это не какой-то особый механизм языка, а обычный тип стандартной библиотеки. По сути это enum с двумя кейсами: успех и ошибка.

public enum Result<Success, Failure: Error> {
    case success(Success)
    case failure(Failure)
}

Обратите внимание на две вещи.

Первая — Result хранит ассоциированное значение внутри кейса. То есть .success(…) несёт внутри «полезный результат», а .failure(…) несёт внутри конкретную ошибку.

Вторая — тип Failure ограничен протоколом Error. Это значит, что в .failure нельзя положить «что угодно», например строку "ой" или число 404. Нужно положить тип ошибки. Обычно это ваш enum, который вы уже умеете писать.

Если совсем по-человечески, Result — это конверт. Внутри либо «подарок» (значение успеха), либо «записка с объяснением, почему подарка нет» (ошибка). И это один и тот же конверт, просто содержимое разное.

Как читать Result<Success, Failure>

Угловые скобки в Swift часто выглядят так, будто компилятор написал вам письмо на эльфийском. На самом деле, здесь микро-правило очень простое:

Result<A, B> читаем как: «либо успех типа A, либо ошибка типа B».

Причём порядок важен: первым всегда идёт тип успеха, вторым — тип ошибки.

Давайте потренируемся на нескольких примерах:

Тип Как прочитать
Result<Int, ParseError>
«или число Int, или ошибка парсинга ParseError»
Result<String, ValidationError>
«или строка, или ошибка валидации»
Result<Void, SaveError>
«или успех без значения, или ошибка сохранения»

Заметьте Void: иногда успеху не нужно ничего «нести», вам важен сам факт, что операция прошла. Тогда успех — это Void, а смысл результата — «получилось/не получилось».

4. Используем Result как обычное значение

Минимальный пример: создаём Result руками

Сейчас нам важно увидеть, что Result — это реально значение, которое можно создать, положить в переменную и распечатать. Никакой «особой» среды не нужно.

import Foundation

enum ParseError: Error {
    case empty
    case notANumber
}

let ok: Result<Int, ParseError> = .success(42)
let bad: Result<Int, ParseError> = .failure(.notANumber)

print(ok)   // success(42)
print(bad)  // failure(notANumber)

Здесь происходит важный психологический момент: вы смотрите на ok и bad и понимаете, что это обычные значения. Они не «вылетают» из функции как throw. Они лежат в переменных и спокойно ждут, когда вы решите с ними что-то сделать.

Практическая мотивация: собираем исходы операций в массив

Вот место, где Result начинает сиять (без пафоса, просто реально удобно).

Представим, что мы пишем наше учебное CLI-приложение (условно назовём его LibraryCLI) и читаем от пользователя несколько строк, которые должны быть числами (например, ID книг). Мы хотим не «упасть на первой ошибке», а собрать все исходы и потом показать отчёт: что распарсилось, а что нет.

С throws это неудобно: первый throw остановит цепочку, если вы явно не начнёте ловить ошибки на каждом шаге и складывать их куда-то отдельно. С Optional вы потеряете причину (пусто? буква? пробелы?).

А Result идеально подходит: он хранит и успех, и причину провала.

Сделаем маленькую функцию:

import Foundation

enum IDParseError: Error {
    case empty
    case notANumber
}

func parseID(_ text: String) -> Result<Int, IDParseError> {
    guard !text.isEmpty else { return .failure(.empty) }
    guard let n = Int(text) else { return .failure(.notANumber) }
    return .success(n)
}

Обратите внимание: функция не печатает ничего в консоль и не делает throw. Она просто возвращает значение, в котором уже зашит итог.

Теперь можно собрать результаты:

import Foundation

let inputs = ["10", "", "abc", "42"]
let results = inputs.map(parseID)

print(results)
// [success(10), failure(empty), failure(notANumber), success(42)]

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

Result помогает проектировать ошибки как тип

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

Result подталкивает вас к здоровой архитектуре: ошибка — это тип, чаще всего enum, где каждый кейс — отдельная причина. Это прямо соответствует задумке Result: тип ошибки в .failure не просто «любой», а Failure: Error.

Покажу на примере ближе к нашему CLI. Допустим, у нас есть команда remove <id>, и мы хотим различать причины:

import Foundation

enum RemoveCommandError: Error {
    case missingID
    case invalidID
    case negativeID
}

func parseRemoveID(_ tokens: [String]) -> Result<Int, RemoveCommandError> {
    guard tokens.count >= 2 else { return .failure(.missingID) }
    guard let id = Int(tokens[1]) else { return .failure(.invalidID) }
    guard id > 0 else { return .failure(.negativeID) }
    return .success(id)
}

Заметьте, насколько читаемее становится логика: мы явно видим, какие причины возможны. И это не просто «красота ради красоты». Позже вы сможете для каждой причины показать пользователю разные подсказки, а не одно универсальное «Ошибка».

5. Когда выбирать Optional, throws и Result

Где заканчивается Optional и начинается Result

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

Если отсутствие результата — нормальный вариант без причины, берите Optional. Если важно понимать, почему не получилось, берите Result.

Например, readLine() возвращает String? не потому что произошла «ошибка пользователя», а потому что ввода может не быть (EOF, пустой поток и т.д.). Там нет «причины, которую вам надо разветвлять по кейсам», поэтому Optional подходит.

А вот «пользователь ввёл не число» — это уже не «нет значения», а конкретная причина провала. Вы вполне можете моделировать это как Result<Int, ParseError> и дальше принимать решения по конкретному кейсу ошибки.

Иногда вы увидите код, который делает Int(text) и получает nil, а потом превращает nil в «какую-то» ошибку. Это нормальный мостик: внутри Swift многие преобразования дают Optional, а вы можете поднять это на уровень Result, если вам нужна диагностика.

Где заканчивается throws и начинается Result

Чтобы не запутаться: throws и Result — не враги и не конкуренты. Они решают разные задачи, хотя и обе связаны с ошибками.

Если вы пишете функцию, которая делает последовательную работу и при первой проблеме надо сразу прекратить — throws очень выразителен. Вы пишете линейный код и не таскаете «контейнер результата» руками.

Если же вам нужно «сохранить исход операции как данные», то Result звучит естественнее. У Result как раз есть сценарий «delayed handling», когда вы выполняете потенциально падающую операцию сейчас, а разбирать последствия хотите позже — и для этого удобно хранить итог в Result.

Важно: сегодня мы не будем обсуждать удобные мосты между throws и Result (они есть и используются очень часто), потому что это уже следующая лекция дня, где мы будем разбирать создание Result из throwing-кода и разные способы извлечения значения.

Визуальная схема: «ошибка как управление» и «ошибка как данные»

Иногда мозгу проще заходит через картинку. Давайте нарисуем два потока.

throws: ошибка — это путь выполнения

flowchart TD
    A[Вызов функции] --> B{Шаг 1 ок?}
    B -- да --> C{Шаг 2 ок?}
    B -- нет --> E[throw Error]
    C -- да --> D[return Success]
    C -- нет --> E

Result: ошибка — это значение

flowchart TD
    A[Вызов функции] --> B[return Result]
    B --> C{Result}
    C -- success --> D[Success как данные]
    C -- failure --> E[Failure как данные]

Смысл такой: при throws ошибка «ломает поток», при Result ошибка «лежит в коробке рядом с успехом». Сама коробка всегда возвращается.

Встраиваем Result в стиль CLI без усложнений

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

Например, пусть у нас есть утилита чтения числа из строки. Она не печатает в консоль вообще.

import Foundation

enum NumberReadError: Error {
    case empty
    case notANumber
}

func readInt(from text: String) -> Result<Int, NumberReadError> {
    let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
    guard !trimmed.isEmpty else { return .failure(.empty) }
    guard let value = Int(trimmed) else { return .failure(.notANumber) }
    return .success(value)
}

А уже в «главном» месте программы (top-level код или main) вы решите, как реагировать. Сегодня мы не углубляемся в красивую обработку Result, но сам принцип уже важен: вы не смешиваете вычисление и I/O.

6. Типичные ошибки при работе с Result

Ошибка №1: использовать Result как “Optional с усложнением”, но всё равно терять причину.
Часто новички делают enum MyError { case unknown } и возвращают .failure(.unknown) на все случаи жизни. Формально это Result, но по смыслу это всё тот же Optional, только более многословный. Если уж берёте Result, договоритесь, какие причины действительно важны, и опишите их отдельными кейсами.

Ошибка №2: делать Failure слишком общим (Error) сразу и навсегда.
Да, иногда Result<Int, Error> бывает уместен, но на старте обучения почти всегда лучше конкретный enum. Иначе вы не сможете нормально ветвиться по причинам и будете вынуждены заниматься «угадыванием» ошибок через строки, типы или описания.

Ошибка №3: печатать ошибку внутри функции и одновременно возвращать .failure.
Когда функция и возвращает Result, и печатает сообщение, вы получаете двойную ответственность. В итоге одна и та же ошибка может быть распечатана дважды, или наоборот — распечатана не там, где надо. Чище: функция возвращает данные, а решение «что показать пользователю» остаётся снаружи.

Ошибка №4: пытаться заменить throws на Result везде подряд.
Result — отличный инструмент, но не универсальная замена. Если у вас линейная последовательность шагов и при первой ошибке действительно нужно завершаться — throws часто читабельнее. Считайте Result инструментом для ситуаций, где исход нужно хранить и обрабатывать позже или пачкой.

Ошибка №5: забывать, что .failure должен быть именно типом ошибки, а не текстом.
Иногда рука тянется сделать .failure("bad input"). Компилятор не даст, потому что Failure должен соответствовать Error. Это не придирка, а защита от «ошибок строками», которые почти всегда превращаются в хаос.

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