Сила типов Result в Swift

04 августа 2020

Одно из больших преимуществ системы типов в Swift заключается в том, что она позволяет нам избежать множество неопределенностей, когда дело доходит до обработки значений и результатов различных операций. Благодаря дженерикам и связанным значениям перечислений, мы можем легко создавать типы, которые позволяют использовать компилятор для обеспечения правильной обработки значений и результатов.

Примером такого типа является тип Result, который представлен в стандартной библиотеке как часть Swift 5, но также годами используется в сообществе через пользовательские реализации. Давайте рассмотрим различные версии типа Result и некоторые интересные вещи, которые он позволяет нам делать в сочетании с некоторыми языковыми функциями Swift.

Проблемы

При выполнении многих видов операций, очень часто мы имеем два разных результата - success и failure. В Objective-C эти два результата обычно использовались, когда, вызывался завершающий обработчик после завершения операции. Однако при переводе на Swift проблема с этим подходом становится совершенно очевидной, поскольку значение и ошибка должны быть опциональными:

func load(then handler: @escaping (Data?, Error?) -> Void) {
...
}

Проблема в том, что обработка результата вышеупомянутой load функции становится довольно сложной задачей. Даже если аргумент ошибки nil, то нет никакой гарантии во время компиляции, что искомые данные у нас все же есть, так как все данные также могут быть равны nil, что делает наш код не совсем корректным.

Отдельные состояния

Использование типа Result решает эту проблему, превращая каждый результат в два отдельных состояния, используя перечисление, содержащее кейс для каждого состояния - один для success и один для failure:

enum Result {
case success(Value)
case failure(Error)
}

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

func load(then handler: @escaping (Result) -> Void) {
...
}

Использование типа Result не только повышает безопасность нашего кода во время компиляции, но также побуждает нас всегда добавлять правильную обработку ошибок всякий раз, когда мы вызываем API, который выдает значение Result, например:

load { [weak self] result in
switch result {
case .success(let data):
self?.render(data)
case .failure(let error):
self?.handle(error)
}
}

Теперь мы сделали наш код более понятным, а также устранили источник двусмысленности, что привело к созданию более надежного и приятного в использовании API 👍.

Типизированные ошибки

Мы можем пойти дальше в вопросах безопасности. В нашей предыдущей итерации кейс failure перечисления Result содержал значение ошибки, которое может быть любого типа, соответствующего протоколу Error в Swift. И хотя это дает нам большую гибкость, становится трудно понять, какие именно ошибки могут возникнуть при вызове данного API.

Один из способов решения этой проблемы - сделать связанное значение ошибки универсальным:

enum Result {
case success(Value)
case failure(Error)
}

Таким образом, теперь мы должны указать, какой тип ошибки может ожидать пользователя API. Давайте снова обновим нашу функцию load, чтобы использовать новую версию нашего типа Result - со строго типизированными ошибками:

typealias Handler = (Result) -> Void

func load(then handler: @escaping Handler) {
...
}

Можно утверждать, что использование подобных строго типизированных ошибок, идет вразрез с текущей моделью обработки ошибок Swift, которая не включает типизированные ошибки (мы можем только объявить, что это функцию как throws, а не тип ошибки, которую она может выдать). Однако добавление этой дополнительной информации о типе каждого Result будет иметь преимущества, например, это позволит нам обрабатывать всевозможные ошибки на стороне вызова:

load { [weak self] result in
switch result {
case .success(let data):
self?.render(data)
case .failure(let error):
// Since we now know the type of 'error', we can easily
// switch on it to perform much better error handling
// for each possible type of error.
switch error {
case .networkUnavailable:
self?.showErrorView(withMessage: .offline)
case .timedOut:
self?.showErrorView(withMessage: .timedOut)
case .invalidStatusCode(let code):
self?.showErrorView(withMessage: .statusCode(code))
}
}
}

Выполнение обработки ошибок, как мы делали выше, может показаться излишним, но «навязывание» привычки обрабатывать ошибки таким, более точным, способом часто может привести к гораздо более приятному восприятию этого пользователем - так как пользователи фактически будут проинформированы о том, что пошло не так вместо того, чтобы просто видеть общий экран ошибок. А еще мы могли бы даже добавить соответствующие действия для каждой из ошибок!

Анонимизация ошибок

Однако, учитывая текущую систему ошибок Swift, не всегда практично (или даже возможно) получить строго типизированную, предсказуемую ошибку из каждой операции. Иногда нам нужно использовать базовые API и системы, которые могут вызвать любую ошибку, поэтому нам нужен какой-то способ, чтобы сказать системе типов, что наш тип Result также может содержать любую ошибку.

К счастью, Swift предоставляет очень простой способ сделать это - используя NSError из Objective-C. Любая ошибка Swift может быть автоматически преобразована в NSError без необходимости дополнительного приведения типов. Что еще лучше, мы можем сказать что Swift преобразовывает любую ошибку, возникшую в блоке do в NSError, упрощая передачу ее в завершающий обработчик:

class ImageProcessor {
typealias Handler = (Result) -> Void

func process(_ image: UIImage, then handler: @escaping Handler) {
do {
// Any error can be thrown here
var image = try transformer.transform(image)
image = try filter.apply(to: image)
handler(.success(image))
} catch let error as NSError {
// When using 'as NSError', Swift will automatically
// convert any thrown error into an NSError instance
handler(.failure(error))
}
}
}

Мы всегда будем получать NSError в нашем catch блоке, независимо от того, какая ошибка была фактически выброшена. Хорошей практикой является указать универсальный API ошибки на верхнем уровне фреймворка или модуля. Пример выше показывает, как мы можем сократить написание шаблонного кода, когда нам не очень важна обработка конкретных ошибок.

Стандартная библиотека

В рамках Swift 5 стандартная библиотека также получает свою собственную реализацию типа Result, повторяющий во многом тот же дизайн, что и наша последняя итерация (в том, что она поддерживает строгую типизацию для значений и ошибок). Одним из преимуществ типа Result, включенного в стандартную библиотеку, является то, что отдельным фреймворкам и приложениям больше не нужно определять свой тип Result, и, что более важно, больше не нужно конвертировать значения между различными разновидностями одного и того же типа.

Swift 5 также вносит еще одно интересное изменение, тесно связанное с типом Result (фактически оно было реализовано как часть того же предложения эволюции Swift), и именно этот протокол Error теперь самоподписывающийся. Это означает, что теперь Error можно использовать в качестве универсального типа, который ограничен необходимостью соответствовать тому же самому протоколу, что в свою очередь означает, что NSError в Swift 5 больше не нужен, поскольку мы можем просто использовать протокол Error для анонимизации ошибок:

class ImageProcessor {
typealias Handler = (Result) -> Void

func process(_ image: UIImage, then handler: @escaping Handler) {
do {
var image = try transformer.transform(image)
image = try filter.apply(to: image)
handler(.success(image))
} catch {
handler(.failure(error))
}
}
}

Код выше работает как для типа Result стандартной библиотеки, так и для пользовательских библиотек - при условии, что он ограничен протоколом Error (поскольку другие протоколы пока не могут быть самоподписанными). Довольно круто! 😎

Выбрасывание ошибок

Иногда нам не нужно использовать результат на выходе, а нужно использовать его внутри блоков обработки ошибок в Swift do, try, catch. Хорошая новость заключается в том, что, поскольку теперь у нас есть специальный тип Result, мы можем легко расширить его, добавив удобные API. Например, реализация Swift 5 типа Result включает в себя метод get(), который либо возвращает значение Result, либо выбрасывает ошибку, которую мы также можем реализовать для пользовательских типов Result, например:

extension Result {
func get() throws -> Value {
switch self {
case .success(let value):
return value
case .failure(let error):
throw error
}
}
}

Приведенный выше API действительно может стать полезным для таких задач, как написание тестов, когда мы не хотим добавлять какие-либо ветви кода или условные выражения. Вот пример, в котором мы тестируем SearchResultsLoader, используя мокированный, синхронный сетевой движок, и используя вышеописанный метод get, мы можем сохранить все утверждения и проверки на верхнем уровне нашего теста, например так:

class SearchResultsLoaderTests: XCTestCase {
func testLoadingSingleResult() throws {
let engine = NetworkEngineMock.makeForSearchResults(named: ["Query"])
let loader = SearchResultsLoader(networkEngine: engine)
var result: Result?

loader.loadResults(matching: "query") {
result = $0
}

let searchResults = try result?.get()
XCTAssertEqual(searchResults?.count, 1)
XCTAssertEqual(searchResults?.first?.name, "Query")
}
}

Декодирование

Мы также можем добавлять дополнительные расширения для других распространенных операций. Например, если наше приложение имеет дело с декодированием JSON, мы могли бы использовать ограничение того же типа, чтобы разрешить прямое декодирование любого типа Result, имеющего значение типа Data, добавив следующее расширение:

// Здесь мы используем 'Success' в качестве имени для универсального типа
// для нашего значения (вместо 'Value', который мы использовали ранее)
// Все это для того, чтобы соответствовать правилам именования в Swift 5
extension Result where Success == Data {
func decoded(
using decoder: JSONDecoder = .init()
) throws -> T {
let data = try get()
return try decoder.decode(T.self, from: data)
}
}

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

load { [weak self] result in
do {
let user = try result.decoded() as User
self?.userDidLoad(user)
} catch {
self?.handle(error)
}
}

Реализация Swift 5 стандартной библиотеки из типа Result также включает в себя такие методы, как map, mapError и flatMap, что позволяет нам выполнять различные виды преобразований с использованием встроенных замыканий и функций.

Вывод

Использование типа Result может быть отличным способом уменьшить неоднозначность при работе со значениями и результатами асинхронных операций. Добавляя удобные API-интерфейсы с помощью расширений, мы также можем уменьшить шаблонный код и упростить выполнение обычных операций при работе с результатами, сохраняя при этом полную безопасность типов.

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

Оригинал статьи

Содержание