Result<T> 还是 Result<T, E: Error>

背景知识

Cocoa API 中有很多接受回调的异步方法,比如 URLSessiondataTask(with:completionHandler:)

URLSession.shared.dataTask(with: request) {
    data, response, error in
        if error != nil {
            handle(error: error!)
        } else {
            handle(data: data!)
        }
}

有些情况下,回调方法接受的参数比较复杂,比如这里有三个参数:(Data?, URLResponse?, Error?),它们都是可选值。当 session 请求成功时,Data 参数包含 response 中的数据,Errornil;当发生错误时,则正好相反,Error 指明具体的错误 (由于历史原因,它会是一个 NSError 对象),Datanil

这么做虽然看上去无害,但其实存在改善的余地。显然 dataerror 是互斥的:事实上是不可能存在 dataerror 同时为 nil 或者同时非 nil 的情况的,但是编译器却无法静态地确认这个事实。编译器没有制止我们在错误的 if 语句中对 nil 值进行解包,而这种行为将导致运行时的意外崩溃。

我们可以通过一个简单的封装来改进这个设计:如果你实际写过 Swift,可能已经对 Result 很熟悉了。它的思想非常简单,用泛型将可能的返回值包装起来,因为结果是成功或者失败二选一,所以我们可以藉此去除不必要的可选值。

enum Result<T, E: Error> {
    case success(T)
    case failure(E)
}

把它运用到 URLSession 中的话,包装一下 URLSession 方法,上面调用可以变为:

// 如果 Result 存在于标准库的话,
// 这部分代码应该由标准库的 Foundataion 扩展进行实现
extension URLSession {
    func dataTask(with request: URLRequest, completionHandler: @escaping (Result<(Data, URLResponse), NSError>) -> Void) -> URLSessionDataTask {
        return dataTask(with: request) { data, response, error in
            if error != nil {
                completionHandler(.failure(error! as NSError))
            } else {
                completionHandler(.success((data!, response!)))
            }
        }
    }
}


URLSession.shared.dataTask(with: request) { result in
    switch result {
    case .success(let (data, _)):
        handle(data: data)
    case .failure(let error):
        handle(error: error)
    }
}

调用的时候看起来很棒,我们可以避免检查可选值的情况,让编译器保证在对应的 case 分支中有确定的非可选值。这个设计在很多存在异步代码的框架中被广泛使用,比如 Swift Package ManagerAlamofire 等中都可觅其踪。

错误类型泛型参数

如此常用的一个可以改善设计的定义,为什么没有存在于标准库中呢?关于 Result,其实已经有相关的提案

这个提案中值得注意的地方在于,Result 的泛型类型只对成功时的值进行了类型约束,而忽略了错误类型。给出的 Result 定义类似这样:

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

很快,在 1 楼就有人质疑,问这样做的意义何在,因为毕竟很多已存在的 Result 实现都是包含了 Error 类型约束的。确定的 Error 类型也让人在使用时多了一份“安全感”。

不过,其实我们实际类比一下 Swift 中已经存在的错误处理的设计。Swift 中的 Error 只是一个协议,在 throw 的时候,我们也并不会指明需要抛出的错误的类型:

func methodCanThrow() throws {
    if somethingGoesWrong {
        // 在这里可以 throw 任意类型的 Error
    }
}

do {
    try methodCanThrow()
} catch {
    if error is SomeErrorType {
        // ...
    } else if error is AnotherErrorType {
        // ...
    }
}

但是,在带有错误类型约束的 Result<T, E: Error> 中,我们需要为 E 指定一个确定的错误类型 (或者说,Swift 并不支持在特化时使用协议,Result<Response, Error> 这样的类型是非法的)。这与现有的 Swift 错误处理机制是背道而驰的。

选择哪个比较好?

两种方式各有优缺点,特别在如果需要考虑 Cocoa 兼容的情况下,更并说不上哪一个就是完胜。这里将两种写法的优缺点简单比较一下,在实践中最好是根据项目情况进行选择。

Result

优点
  1. 可以由编译器帮助进行确定错误类型

    当通过使用某个具体的错误类型扩展 Error 并将它设定为 Result 的错误类型约束后,在判断错误时我们就可以比较容易地检查错误处理的完备情况了:

    enum UserRegisterError: Error {
        case duplicatedUsername
        case unsafePassword
    }
    
    userService.register("user", "password") {
        result: Result<User, UserRegisterError> in
        switch result {
        case .success(let user):
            print("User registered: \(user)")
        case .failure(let error):
            if error == .duplicatedUsername {
                // ...
            } else if error == .unsafePassword {
                // ...
            }
        }
    }

    上例中,由于 Error 的类型已经可以被确定是 UserRegisterError,因此在 failure 分支中的检查变得相对容易。

  2. 按条件的协议扩展

    使用泛型约束的另一个好处是可以方便地对某些情况的 Result 进行扩展。

    举例来说,某些异步操作可能永远不会失败,对于这些操作,我们没有必要再使用 switch 去检查分支情况。一个很好的例子就是 Timer,我们设定一个在一段时间后执行的 Timer 后,如果不考虑人为取消,这个 Timer 总是可以正确执行完毕,而不会发生任何错误的。我们可能会选择使用一个特定的类型来代表这种情况:

    ```swift
    enum NoError: Error {}

    func run(after: TimeInterval, done: @escaping (Result) -> Void ) {
    Timer.scheduledTimer(withTimeInterval: after, repeats: false) { timer in
    done(.success(timer))
    }

top Created with Sketch.