Использование Result в Swift 5

Перевод статьи

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

В сегодняшнем посте, моя цель показать вам как и когда Result полезный, и как его использовать в вашем коде. По окончанию статьи, вы должны быть в состоянии рефакторить ваш код с использованием объекта Result и должны быть в состоянии понимать как код который возвращает Result должен быть вызван.

Написание кода который использует тип Result Swift.

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

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

func loadData(from url: URL, completion: (Data?) -> Void) throws {
        URLSession.shared.dataTask(with: url) { data, response, error in
                if let error = error {
                        throw error
                    }
                if let data = data {
                        completion(data)
                    }
            }.resume()
}

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

do {
        try loadData(from: aURL) { data in
                print("fetched data")
            }
        print("This will be executed before any data is fetched from the network.")
} catch {
        print(error)
}

Это совершенно бесполезно. Идея использования try и выбрасывание ошибок заключается в том, что код в блоке do сразу же переходит к перехвату при возникновении ошибки. Не то, чтобы весь код в do выполняется до появления каких-либо ошибок, потому что задача данных в loadData(from:completion:) выполняется асинхронно. На самом деле ошибка, которая может возникнуть в completion задачи данных, на самом деле никогда не выходит за пределы области действия completion. Итак, резюмируя этот абзац, можно с уверенностью сказать, что ошибки, возникающие в асинхронной среде, никогда не попадают в вызов сайта.

Из-за этого выдача ошибок Swift не очень хорошо подходит для асинхронной работы. К счастью, именно здесь выделяется тип Swift Result.

Result в Swift — это перечисление с успешными и неудачными случаями. У каждого есть связанное значение, которое будет содержать либо значение успеха, либо ошибку, если результатом является неудача. Давайте быстро посмотрим на определение Result:

/// A value that represents either a success or a failure, including an
/// associated value in each case.
@frozen
public enum Result<Success, Failure: Error> {
        /// A success, storing a `Success` value.
        case success(Success)
        /// A failure, storing a `Failure` value.
        case failure(Failure)
}

Настоящее определение Result намного длиннее, потому что для этого типа реализовано несколько методов, но на данный момент это самая важная часть.

Давайте прорефакторим эту задачу с данными до использования Result, чтобы ее можно было скомпилировать и использовать:

func loadData(from url: URL, completion: (Result<Data?, URLError>) -> Void) throws {
        URLSession.shared.dataTask(with: url) { data, response, error in
                if let urlError = error as? URLError {
                        completion(.failure(urlError))
                    }
                if let data = data {
                        completion(.success(data))
                    }
            }.resume()
}
loadData(from: aURL) { result in
        // we can use the result here
}

Отлично, теперь мы можем взаимодействовать и сообщать об ошибках вызванных loadData(from:completion:). Поскольку Result является перечислением, объекты Result создаются с использованием точечного синтаксиса. Полный синтаксис здесь: Result.failure (urlError) и Result.success (data). Поскольку Swift знает, что вы вызываете завершение с помощью Result, вы можете опустить перечисление Result.

Поскольку completion handler в этом коде принимает единственный аргумент Result, мы можем выразить результат нашей работы с помощью одного объекта. Это удобно, потому что это означает, что нам не нужно проверять и неудачу, и успех. И мы также абсолютно ясно даем понять, что неудачная операция не может также иметь значения успеха. Completion closure, переданное в URLSession.shared.dataTask (с помощью: completionHandler:), гораздо более неоднозначен. Обратите внимание, как closure принимает три аргумента. OneData ?, один URLResponse? и ошибка ?. Это означает, что теоретически все аргументы могут быть равны нулю, и все аргументы могут быть не нулевыми. На практике у нас не будет никаких данных и ответа в случае ошибки. Если у нас есть ответ, у нас также должны быть данные и без ошибок. Это может сбивать с толку пользователей этого кода и может быть исправлено с помощью Result.

Если completion handler задачи данных примет единственный аргумент типа Result <(Data, URLResponse), Error>. Это ясно покажет, каковы возможные результаты задачи с данными. Если у нас есть ошибка, у нас нет данных и нет ответа. Если задача завершится успешно, обработчик завершения получит результат, который гарантированно содержит данные и ответ. Также гарантируется отсутствие ошибок.

Давайте рассмотрим еще один пример, выражающий результат асинхронного кода с помощью Result, прежде чем я объясню, как вы можете использовать код, который предоставляет результаты с типом Result:

enum ConversionFailure: Error {
  case invalidData
}
 
func convertToImage(_ data: Data, completionHandler: @escaping (Result<UIImage, ConversionFailure>) -> Void) {
  DispatchQueue.global(qos: .userInitiated).async {
    if let image = UIImage(data: data) {
      completionHandler(.success(image))
    } else {
      completionHandler(.failure(ConversionFailure.invalidData))
    }
  }
}

В этом коде я определил completion handler, который принимает Result<UIImage, ConversionFailure> в качестве единственного аргумента. Обратите внимание, что перечисление ConversionFailure соответствует Error. Все случаи отказа для результата должны соответствовать этому протоколу. Этот код довольно прост. Определенная мною функция принимает данные и completion handler. Поскольку преобразование данных в изображение может занять некоторое время, эта работа выполняется вне основного потока с помощью DispatchQueue.global(qos: .userInitiated).async. Если данные преобразуются в изображение успешно, вызывается обработчик завершения с .success (image), чтобы предоставить вызывающей стороне успешный результат, который завершает преобразованное изображение. Если преобразование не удается, вызывается completion handler с .failure (ConversionFailure.invalidData), чтобы сообщить вызывающей стороне о неудачном преобразовании изображения.

Давайте посмотрим, как вы можете использовать функцию convertToImage(_:completionHandler:) и как вы можете извлечь значения успеха или неудачи из Result.

Вызов кода, использующего Result

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

let invalidData = "invalid!".data(using: .utf8)!
convertToImage(invalidData) { result in
        switch result {
        case .success(let image):
                print("we have an image!")
            case .failure(let error):
                    print("we have an error! \(error)")
                }
}

В этом примере используется переключатель и мощные возможности обработки шаблонов Swift, чтобы проверить, является ли результат .success(let image) или .failure(let error). Другой способ работы с Result — использовать встроенный метод get:

let invalidData = "invalid!".data(using: .utf8)!
convertToImage(invalidData) { result in
  do {
    let image = try result.get()
    print("we have an image!")
  } catch {
    print("we have an error \(error)")
  }
}

Метод get, определенный для Result, является методом выбрасывания ошибки. Если результат успешный, get() не выдаст ошибку, а просто вернет соответствующее значение успеха. В данном случае это изображение. Если результат не успешен, get() выдает ошибку. Ошибка, вызванная get(), является связанным значением случая .failure объекта Result.

Оба способа извлечения значения из объекта Result имеют примерно равный объем кода, но если вас не интересует обработка ошибок, метод get() может быть намного чище:

convertToImage(invalidData) { result in
        guard let image = try? result.get() else {
                return
            }
        print("we have an image")
}

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

Помимо извлечения результатов из Result, вы также можете отобразить его, чтобы преобразовать значение успеха результата:

convertToImage(invalidData) { result in
        let newResult = result.map { uiImage in
                return uiImage.cgImage
            }
}

Когда вы используете map для Result, он создает новый Result с другим типом success. В этом случае success меняется с UIImage на CGImage. Также возможно изменить ошибку результата:

struct WrappedError: Error {
  let cause: Error
}
convertToImage(invalidData) { result in
  let newResult = result.mapError { conversionFailure in
    return WrappedError(cause: conversionFailure)
  }
}

В этом примере ошибка результата изменяется с ConversionError на WrappedError с помощью mapError(_ 🙂.

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

Оборачивание вызова функции выбрасывающей ошибку в тип Result

После того, как на прошлой неделе я опубликовал свою статью о работе с функциями выбрасывающие ошибку, Matt Massicotte указал мне на то, что есть отличный способ инициализировать Result с помощью вызова функции throw с инициализатором Result(catching 🙂 для Result. Давайте посмотрим на пример того, как это можно использовать в сетевом вызове:

func loadData(from url: URL, _ completion: @escaping (Result<MyModel, Error>) -> Void) {
        URLSession.shared.dataTask(with: url) { data, response, error in
                guard let data = data else {
                        if let error = error {
                                completion(.failure(error))
                                return
                            }
                        fatalError("Data and error should never both be nil")
                    }
                let decoder = JSONDecoder()
                let result = Result(catching: {
                        try decoder.decode(MyModel.self, from: data)
                    })
                completion(result)
            }
}

Инициализатор Result(catching 🙂 принимает closure. Любые ошибки, возникающие в этом closure, перехватываются и используются для создания Result.failure. Если при closure не возникает ошибок, возвращаемый объект используется для создания Result.success.

В итоге

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

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

Если у вас есть какие-либо отзывы или вопросы по поводу этого поста или других моих постов, не стесняйтесь присылать мне твит.