如何在Swift中使用Result

Result介绍

Swift标准库的Result类型使我们能够使用单个统一类型来表达给定操作的结果(无论成功还是失败)。让我们看一下在哪种情况下Result可能有用的方法,以及一些在开始使用该类型时要牢记的技巧和窍门。

尽管有很多不同的方法可以对Result类型进行建模,但是Swift标准库中内置的方法被声明为通用枚举,它针对结果可能包含的成功值以及遇到的任何错误进行了强类型化。看起来像这样:

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

就像上面的声明所示,Result只要Failure类型符合Swift的Error协议,我们就可以用来表示任何成功/失败组合。那么我们如何在实践中使用上述类型,这样做的好处是什么?

例如,让我们看一下URLSession,它是最常用的API之一——使用基于闭包的设计以异步方式返回网络请求的各种结果:

let url = URL(string: "https://www.swiftbysundell.com")!

let task = URLSession.shared.dataTask(with: url) {
    data, response, error in
    
    if let error = error {
        // Handle error
        ...
    } else if let data = data {
        // Handle successful response data
        ...
    }
}

task.resume()

尽管URLSession这些年来已经发展了很多,并且拥有一套功能强大的API,但确切地决定如何处理网络调用的结果有时会有些棘手。因为,如上面的示例所示,dataerror结果一样作为可选参数传递到我们的闭包中-这反过来要求我们在每次进行网络调用时都要解开每个值。

让我们看看使用Result可以帮助我们如何解决该问题。我们将从扩展URLSession新API 入手,该API将一个Result<Data, Error>值传递到其完成处理程序中,而不是一组可选参数。为了实现这一点,我们将对标准API提供给我们的可选选项进行解包(类似于上面的操作),以构造我们的代码Result,如下所示:

extension URLSession {
    func dataTask(
        with url: URL,
        handler: @escaping (Result<Data, Error>) -> Void
    ) -> URLSessionDataTask {
        dataTask(with: url) { data, _, error in
            if let error = error {
                handler(.failure(error))
            } else {
                handler(.success(data ?? Data()))
            }
        }
    }
}

请注意,通过忽略默认API的URLResponse值(在闭包中使用下划线而不是其参数名称),我们在上面做了一些简化。尽管对于更简单的网络任务,可能不需要检查该响应值,但这并不是我们始终想要做的事情。

如果现在返回到先前的调用站点并对其进行更新以使用新的API,我们可以看到我们的代码变得更加清晰了-因为我们现在可以为successfailure案例编写完全独立的代码路径,如下所示:

let task = URLSession.shared.dataTask(with: url) { result in
    switch result {
    case .success(let data):
        // Handle successful response data
        ...
    case .failure(let error):
        // Handle error
        ...
    }
}

关于Result上面使用方式的一个有趣的细节是,我们Failure简单地将其类型指定为Error。这意味着任何错误都可以传递到我们的结果中,这反过来又限制了我们在呼叫站点进行更具体的错误处理的选项(因为我们没有任何要处理的潜在错误的详尽列表)。直接与系统API结合使用时,要改变这一点比较棘手,而这又会引发任何错误-当我们构建更具体的抽象形式时,我们通常可以为其设计一个更统一的错误API

例如,假设我们正在构建一个非常简单的图像加载器,可以再次使用来通过网络加载图像URLSession 。但是在开始实际实现加载程序本身之前,让我们首先定义一个枚举,该枚举列出它可能遇到的所有潜在错误。目前,我们只有两种情况——发生了网络错误,或者我们下载的数据被证明是无效的:

enum ImageLoadingError: Error {
    case networkFailure(Error)
    case invalidData
}

然后,在构建图像加载器时,我们现在可以专门Result处理上述错误类型-这又使我们能够向呼叫站点发送更多的错误信息:

struct ImageLoader {
    typealias Handler = (Result<UIImage, ImageLoadingError>) -> Void

    var session = URLSession.shared

    func loadImage(at url: URL,
                   then handler: @escaping Handler) {
        let task = session.dataTask(with: url) { result in
            switch result {
            case .success(let data):
                if let image = UIImage(data: data) {
                    handler(.success(image))
                } else {
                    handler(.failure(.invalidData))
                }
            case .failure(let error):
                handler(.failure(.networkFailure(error)))
            }
        }

        task.resume()
    }
}

然后,上述设计使我们能够在使用图像加载器时以更精细的方式处理每个潜在的错误,例如:

let imageURL = URL(string: "https://www.swiftbysundell.com/images/logo.png")!
let imageLoader = ImageLoader()

imageLoader.loadImage(at: imageURL) { result in
    switch result {
    case .success(let image):
        // Handle image
        ...
    case .failure(.invalidData):
        // Handle an invalid data failure
        ...
    case .failure(.networkFailure(let error)):
        // Handle any network error
        ...
    }
}

Swift的内置Result类型可能只需要声明几行代码,但是它使我们能够采用的模式非常强大,并且可以导致更简单的代码——尤其是在执行异步操作(例如网络调用)时。

Result优点

Swift的类型系统的一大好处是,它使我们在处理各种操作的值和结果时消除了很多歧义。借助泛型和关联的枚举值之类的功能,我们可以轻松创建类型,以使我们能够利用编译器来确保以正确的方式处理值和结果。

SE-0235在标准库中引入了一种Result类型,为我们提供了一种更简单,更清晰的方式来处理诸如异步API之类的复杂代码中的错误。自Swift诞生以来,人们一直在要求这些东西,因此很高兴看到它最终在Swift 5中出现!

Swift的Result类型实现为具有两种案例的枚举:successfailure。两者都是使用泛型实现的,因此它们可以有一个您选择的关联的值,但failure必须遵循Swift的Error类型。如果需要,您可以使用特定的错误类型,例如NetworkErrorAuthenticationError,允许我们在Swift中首次类型化throws,但这不是必需的。

演示说明

为了演示Result,我们可以编写一个连接到远程服务器的函数,以计算有多少未读消息正在等待用户。在此示例代码中,我们将仅有一个可能的错误,即所请求的URL字符串无效:

enum NetworkError: Error {
    case badURL
}

获取数据的函数将接受URL字符串作为其第一个参数,并将完成处理作为其第二个参数。该完成处理本身将接受一个 Result ,其中成功案例将存储一个整数,该整数表示存在多少未读消息,而失败案例将为 NetworkError 。我们实际上并不打算在这里连接到服务器,但是使用完成处理至少可以让我们模拟异步代码——如果我们进行真正的联网,尝试直接返回一个值将会导致UI冻结。

代码:

func fetchUnreadCount1(from urlString: String, completionHandler: @escaping (Result<Int, NetworkError>) -> Void)  {
    guard let url = URL(string: urlString) else {
        completionHandler(.failure(.badURL))
        return
    }

    // complicated networking code here 
    print("Fetching \(url.absoluteString)...")
    completionHandler(.success(5))
}

要使用该代码,我们需要检查Result内部的值,以查看调用成功还是失败,如下所示:

fetchUnreadCount1(from: "https://www.hackingwithswift.com") { result in
    switch result {
    case .success(let count):
        print("\(count) unread messages.")
    case .failure(let error):
        print(error.localizedDescription)
    }
}

即使在这种简单的情况下, Result 也提供了两个好处。首先,我们获得的错误现在是强类型:必须为 NetworkError 。Swift的常规抛出函数未经检查,因此可以抛出任何类型的错误。因此,如果添加一个 switch 块来检查其案例,那么即使不可能也需要添加default案例。借助Result的强类型错误,我们可以通过列出错误枚举的所有案例来创建穷举switch块。

其次,这是现在很清楚,我们将取回要么是成功的数据要么是错误——这是不可能得到两个或者两者都不是的。如果我们使用传统的Objective-C方法对完成处理进行重写 fetchUnreadCount1() ,则可以看到这第二个好处的重要性:

func fetchUnreadCount2(from urlString: String, completionHandler: @escaping (Int?, NetworkError?) -> Void) {
    guard let url = URL(string: urlString) else {
        completionHandler(nil, .badURL)
        return
    }

    print("Fetching \(url.absoluteString)...")
    completionHandler(5, nil)
}

在这里,完成处理期望同时接收到整数和错误,尽管其中有可能为nil。Objective-C之所以使用这种方法,是因为它没有能力表达具有关联的枚举值,因此别无选择,只能将两者都发送回去,并让用户在调用站点进行查找。

但是,这种旧方法意味着我们已经从两种可能的状态变成了四种:非错误整数,非整数错误,错误 整数以及非整数非错误。最后两个应该是不可能的状态,但是在Swift引入Result之前没有简单的方法来表达这一点 。

这种情况发生了很多URLSessiondataTask() 方法使用相同的方式,例如:使用(Data?, URLResponse?, Error?)调用其完成处理。这可能会给我们一些数据,一个响应和一个错误,或三者的任意组合,Swift 进化提案称这种情况“尴尬地不同”。

问题本身

在执行多种操作时,通常会有两个不同的结果——成功失败。在Objective-C中,这两个结果通常是通过同时包含值和错误来建模的,例如,当操作完成时调用完成处理。但是,当转换为Swift时,这种方法的问题就很明显了——因为值和错误都必须是可选的?:

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

问题在于处理上述load函数的结果变得非常棘手。即使error参数为 nil ,也不能保证在编译时保证我们正在寻找的数据确实存在—它也可能是nil我们都知道,这会使我们的代码处于一种奇怪的状态。

可以认为 Result 它是超能力的 Optional :它包装了成功的值,但也可以包装第二种表示不存在值的情况。 但是,有了Result,这种不存在就可以传达额外数据,因为不是仅仅为nil, 而是告诉我们出了什么问题。

状态分开

使用 Result 类型通过将每个结果转换为两个单独的状态来解决该问题,方法是使用一个包含每个状态的事例的枚举——一个用于 success and一个用于 failure

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

通过使我们的结果类型通用,可以轻松地在许多不同的上下文中重用它,同时仍保留完整的类型安全性。如果现在使用上面结果类型更新之前的load函数,我们可以看到事情变得更加清晰了:

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

使用Result类型不仅可以提高代码的编译时安全性,而且还鼓励我们在调用产生结果值的API时始终添加适当的错误处理,如下所示:

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

现在我们都使代码更加清晰,并且消除了歧义,从而使API更加健壮和易于使用👍。

为什么不使用throws

在你初次见到Result时,通常会想知道它为什么是有用的,尤其是自Swift 2.0起,Swift已经有了一个很好的关键字throws来处理错误。

您可以通过使完成处理接受另一个引发或返回有问题的数据的函数来实现几乎相同的功能,如下所示:

func fetchUnreadCount3(from urlString: String, completionHandler: @escaping  (() throws -> Int) -> Void) {
    guard let url = URL(string: urlString) else {
        completionHandler { throw NetworkError.badURL }
        return
    }

    print("Fetching \(url.absoluteString)...")
    completionHandler { return 5 }
}

然后 ,您可以使用完成处理进行调用 fetchUnreadCount3(),该完成处理接受要运行的函数,如下所示:

fetchUnreadCount3(from: "https://www.hackingwithswift.com") { resultFunction in
    do {
        let count = try resultFunction()
        print("\(count) unread messages.")
    } catch {
        print(error.localizedDescription)
    }
}

这使我们或多或少地到达了同一个地方,但是阅读起来要复杂得多。更糟糕的是,我们实际上并不知道调用该 result() 函数的作用,因此,如果它执行的任务不只是发送一个值或抛出错误,就有可能导致自身的问题。

即使使用简单的代码, throws 经常也会迫使我们立即处理错误,而不是将其存储起来以备后用。 这个问题随着Result出现而消失:错误被藏匿在一个值,当我们准备好了,我们可以读出。

Result运用

我们已经看了switch块是如何让我们以清晰的方式同时评估Resultsuccessfailure案例 ,但也有六个东西,你开始使用它之前应该知道的。

get()和抛出

首先,Result有一个get()方法要么返回成功值(如果存在),要么抛出错误。有时我们并不是真的想要打开结果,而是直接将其挂接到Swift的 do, try, catch 错误处理模型中。Swift 5中的Resultget()方法源码实现如下:

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

这使您可以转换Result为常规的抛出的调用,如下所示:

fetchUnreadCount1(from: "https://www.hackingwithswift.com") { result in
    if let count = try? result.get() {
        print("\(count) unread messages.")
    }
}

当我们确实不想添加任何代码分支或条件时,上述API确实对于诸如编写测试之类的任务很有用。这是一个示例,我们在其中使用模拟的,同步的网络引擎进行测试SearchResultsLoader,并且通过使用上述get方法,我们可以将所有断言和验证保持在测试的最高级别,如下所示:

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

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

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

要了解有关上述模拟的更多信息,请查看“单元测试异步Swift代码”

if读取案例

其次,您可以根据需要使用常规if语句来读取枚举的案例,尽管有些语法起初有些奇怪。例如:

fetchUnreadCount1(from: "https://www.hackingwithswift.com") { result in
    if case .success(let count) = result {
        print("\(count) unread messages.")
    }
}

闭包初始化器

第三,Result有一个可以接受抛出的闭包的初始化器:如果闭包成功返回了一个值,则该值用于 success 案例,否则抛出的错误将被放入failure案例中。

例如:

let result = Result { try String(contentsOfFile: someFile) }

类型化错误

第四,在类型安全方面,我们可以做得更好。在我们之前的迭代中,Result枚举的failure事例包含一个错误值,该错误值可以是遵循SwiftError协议的任何类型。尽管这给我们带来了很大的灵活性,但是它确实使得很难确切地知道在调用给定API时可能遇到的错误

通过指定API用户可以预期的错误类型,让我们再次更新之前load函数,以使用现在结果类型的新版本——带有强类型错误:

typealias Handler = (Result<Data, LoadingError>) -> Void

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

可以说,使用像这样的强类型错误违背了Swift的当前错误处理模型——该模型不包括类型化错误(我们只能声明一个函数throws,而不是它可能引发的错误类型)。但是,在每个结果中添加额外的类型信息确实有一些好处——例如,它使我们可以明确地处理调用站点上的所有可能错误,如下所示:

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))
        }
    }
}

像我们上面那样做错误处理似乎有些矫枉过正,但是以这种细粒度的方式“强迫”自己养成处理错误的习惯通常可以产生更好的用户体验——因为实际上用户会被告知发生了什么问题,而不仅仅是看到通用的错误屏幕,我们甚至还可以为每个错误添加适当的操作。

匿名化错误

第五,您也可以使用常规Error协议,而不是使用你创建的特定错误枚举。实际上,Swift进化提案说:“预计Result的大多数用法是使用Swift.Error作为Error 类型参数。”

因此,您可以使用Result<Int, Error>,而不是使用Result<Int, NetworkError>。尽管这意味着您失去了类型化抛出的安全性,但是您却可以抛出各种不同的错误枚举——具体使用哪种类型取决于您所选的编码风格。

考虑到Swift的当前错误系统,从每个操作中获取强类型的,可预测的错误并不总是可行的(甚至是不可能的)。有时我们需要使用可能会产生任何错误的底层API和系统,因此我们需要某种方式来告诉类型系统我们的结果类型也可以包含任何错误。

Swift 5中我们可以简单地使用 Error 协议本身来匿名化错误,因为该Error协议现在是自我遵循。这意味着Error现在可以用作为泛型类型,它被约束为必须遵循同一协议

class ImageProcessor {
    typealias Handler = (Result<UIImage, Error>) -> 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))
        }
    }
}

上面我们将总是能够在catch块中得到Error,不管实际上抛出的是什么错误。在框架或模块的顶层提供统一的错误API通常是一个好主意,当我们对处理任何特定的错误并不十分重要时,执行上述操作可以帮助我们减少样板代码。

升级到Swift 5.0

最后,如果您的项目中已经有自定义Result类型(您自定义或从GitHub上导入自定义Result类型之一的任何内容),那么它们将自动用于代替Swift自己的Result类型。这将允许您在不破坏代码的情况下升级到Swift 5.0,但是理想情况下,随着时间的推移,您将移至Swift自己的Result类型,以避免与其他项目不兼容。

Result转换

Result有可能证明是有用的其他四个方法:map()flatMap()mapError(),和flatMapError()。所有这些都使您能够以某种方式转换成功或错误,并且前两个工作原理与Optional上具有相同名称的方法类似。

Result中查看该 map() 方法,然后使用您指定的闭包将成功值转换为另一种值。但是,如果发现失败,它将直接使用失败而忽略您的转换。

为了说明这一点,我们将编写一些代码,该代码生成介于0和最大值之间的随机数,然后计算该数字的因数。如果用户要求一个小于零的随机数,或者该数字恰好是质数(即除自身和1外没有其他因数),那么我们将认为这是失败的。

我们可能首先编写代码来对两种可能的失败情况进行建模:用户试图生成一个小于0的随机数,并且生成的数字为质数:

enum FactorError: Error {
    case belowMinimum
    case isPrime
}

接下来,我们将编写一个接受最大值的函数,并返回一个随机数或一个错误:

func generateRandomNumber(maximum: Int) -> Result<Int, FactorError> {
    if maximum < 0 {
        // creating a range below 0 will crash, so refuse
        return .failure(.belowMinimum)
    } else {
        let number = Int.random(in: 0...maximum)
        return .success(number)
    }
}

调用该函数时,返回的Result将是整数或错误,因此可以用map()来对其进行转换:

let result1 = generateRandomNumber(maximum: 11)
let stringNumber = result1.map { "The random number is: \($0)." }

由于我们已经传递了有效的最大数字,因此result将是一个带有随机数的成功。因此,使用map()将获取该随机数,将其与我们的字符串插入一起使用,然后返回另一种Result类型,这次类型是Result<String, FactorError>

然而,如果我们使用generateRandomNumber(maximum: -11),则result将设置为FactorError.belowMinimum的失败案例 。因此,使用map()仍会返回 Result<String, FactorError> ,但它会具有相同的失败案例和相同的FactorError.belowMinimum错误。

现在您已经了解了map()如何让我们将成功类型转换为另一种类型,让我们继续:我们有一个随机数,因此下一步就是计算它的因数。为此,我们将编写另一个函数,该函数接受一个数字并计算其因数。如果发现数是素数,将发回一个带有isPrime 错误的失败Result ,否则将发回的因数的数量。

代码:

func calculateFactors(for number: Int) -> Result<Int, FactorError> {
    let factors = (1...number).filter { number % $0 == 0 }

    if factors.count == 2 {
        return .failure(.isPrime)
    } else {
        return .success(factors.count)
    }
}

如果我们想使用map()来转换generateRandomNumber()的输出 ,使用calculateFactors(),它将看起来像这样:

let result2 = generateRandomNumber(maximum: 10)
let mapResult = result2.map { calculateFactors(for: $0) }

然而,那样的话mapResult变成一个比较难看类型:Result<Result<Int, FactorError>, FactorError>。这是一个Result内部的另一个Result

就像可选的一样,在这里就是flatMap()方法。如果您的转换闭包返回ResultflatMap()则将Result直接返回新值,而不是将其包装在另一个中Result

let flatMapResult = result2.flatMap { calculateFactors(for: $0) }

所以,在这里mapResult是一个Result<Result<Int, FactorError>, FactorError>flatMapResult被平展成Result<Int, FactorError>——第一原始成功值(随机数)转化到一个新的成功值(因数的数量)。就像map()一样,如果任何Result是一个失败,flatMapResult也将是一个失败。

至于mapError()flatMapError(),它们执行类似的操作,除了它们转换错误值而不是成功值。

Result解码

我们还可以继续为其他常见操作添加更多扩展。例如,如果我们的应用处理解码JSON的工作量很大,则可以使用 /相同的类型约束/ 来使任何携带DataResult值都可以直接解码——通过添加以下扩展名:

// Here we're using 'Success' as the name for the generic type
// for our result's value (rather than 'Value', like we did
// before). This is to match Swift 5's naming convention.
extension Result where Success == Data {
    func decoded<T: Decodable>(
        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实现还包括方法如mapmapErrorflatMap——这使我们能够使用内联闭包和函数进行许多其他类型的转换。

Result结论

在处理异步操作的值和结果时,使Result类型可以是减少歧义的好方式。通过使用扩展添加便捷的API,我们还可以减少样板代码并在处理结果时简化执行常见操作的过程,同时又保留了完整的类型安全性。

是否要求对错误进行强类型仍然在社区内有争论,这也是我个人经常反复讨论的问题。一方面,我喜欢它如何使添加更彻底的错误处理变得更简单,但另一方面,它感觉有点像与系统抗争——因为Swift尚未以头等公民的身份支持强类型错误。

Result更多

我们尚未看到Apple在其自己的框架中采用Result,大概是因为需要尽可能长时间保持Objective-C兼容性。但是,许多第三方库都已选择它,因此在未来几个月中,您会越来越多地看到它。

您可能还想尝试一下 Swift 5.0 Playground中的新增功能,它使您可以交互地尝试Swift 5的新功能。

如果您想了解更多有关Swift中结果类型的信息,则可能需要查看GitHub上antitypical / Result的源代码,这是Swift直接实现它之前最受欢迎的结果实现之一。

我也强烈建议您阅读马特·加拉格尔(Matt Gallagher)的Result的精彩论述尽管它已有数年历史,但仍然既有用又有趣。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值