1. Иногда нужен Result, даже если уже есть throws
Если честно, throws в Swift очень удобен… пока вы не начинаете хотеть «положить итог операции в карман». Проблема в том, что throws — это не данные, а поток выполнения: либо функция вернула значение, либо «выпрыгнула» наружу через ошибку.
И вот вы пытаетесь сделать что-то вроде «соберу результаты для трёх операций, а потом разберусь» — и внезапно понимаете, что вам нужен контейнер, который может хранить либо успех, либо ошибку, как обычное значение.
Именно для этого и существует Result. Его идея в том, чтобы представлять «успех или ошибка» как значение, а не как «прыжок» по коду.
Три модели «не получилось»: Optional, throws, Result
Когда вы пишете программу, «не получилось» бывает трёх разных видов. И если путать их между собой, код становится как коробка с проводами: вроде всё соединено, но трогать страшно. Давайте аккуратно разделим модели.
Optional — это история про «значение есть или значения нет». Обычно это нормально для ситуаций, где отсутствие — ожидаемая часть жизни. Например, пользователь мог не ввести строку (и readLine() вернул nil), или в словаре не оказалось ключа.
throws — это история про «операция либо завершилась, либо прервала выполнение и ушла наружу с ошибкой». Очень удобно для линейного кода в стиле «делай шаги подряд, а если где-то всё плохо — прекращай».
Result — это история про «я хочу получить итог операции как значение, чтобы хранить его, передавать дальше, складывать в массив, группировать, показывать статистику и вообще обращаться с ним как с обычными данными».
Вот небольшая табличка, чтобы это закрепилось в голове:
| Модель | Что хранит | Где удобно | Что теряем / ограничение |
|---|---|---|---|
|
T или nil | «может отсутствовать» без причины | Нет причины, почему отсутствует |
|
не хранит, а «прыгает» | линейные цепочки действий | трудно «сохранить исход» в переменную |
|
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».
Причём порядок важен: первым всегда идёт тип успеха, вторым — тип ошибки.
Давайте потренируемся на нескольких примерах:
| Тип | Как прочитать |
|---|---|
|
«или число Int, или ошибка парсинга ParseError» |
|
«или строка, или ошибка валидации» |
|
«или успех без значения, или ошибка сохранения» |
Заметьте 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. Это не придирка, а защита от «ошибок строками», которые почти всегда превращаются в хаос.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ