[译] 在 Swift 中使用 errors 做为控制流

咱们在 App 和系统中对控制流的管理方式,会对咱们代码的执行速度、Debug 的难易程度等方方面面产生巨大影响。咱们代码中的控制流本质上是咱们各类方法函数和语句的执行顺序,以及代码最终将会进入到哪一个流程分支。前端

Swift 为咱们提供了不少定义控制流的工具 —— 如 if, elsewhile 语句,还有相似 Optional 这样的结构。这周让咱们将目光放在如何使用 Swift 内置的错误抛出和处理 Model,以使咱们可以更轻松地管理控制流。android

撇开 Optional

Optional 做为一种重要的语言特性,也是数据建模时处理字段缺失的一种良好方式。在涉及到控制流的特定函数内却也成了大量重复样板代码的源头。ios

下面我写了个函数来加载 App Bundle 内的图片,而后调整图片尺寸并渲染出来。因为上面每一步操做都会返回一张可选值类型的图片,所以咱们须要使用几回 guard 语句来指出函数可能会在哪些地方退出:git

func loadImage(named name: String, tintedWith color: UIColor, resizedTo size: CGSize) -> UIImage? {
    guard let baseImage = UIImage(named: name) else {
        return nil
    }
    
    guard let tintedImage = tint(baseImage, with: color) else {
        return nil
    }
    
    return resize(tintedImage, to: size)
}
复制代码

上面代码面对的问题是咱们实际上在两处地方用了 nil 来处理运行时的错误,这两处地方都须要咱们为每步操做结果进行解包,而且还使引起 error 的语句变得无从查找。github

让咱们看看如何经过 error 重构控制流来解决这两个问题,而不是使用抛出函数。咱们将从定义一个枚举开始,它包含图像处理代码中可能发生的每一个错误的状况——看起来像这样:swift

enum ImageError: Error {
    case missing
    case failedToCreateContext
    case failedToRenderImage
    ...
}
复制代码

例如,下面是咱们如何快速更新 loadImage(named:) 来返回一个非可选的 UIImage 或抛出 ImageError.missing:后端

private func loadImage(named name: String) throws -> UIImage {
    guard let image = UIImage(named: name) else {
        throw ImageError.missing
    }
    
    return image
}
复制代码

若是咱们用一样的手法修改其它图像处理函数,咱们就能在高层次的函数上也作出相同改变 —— 删除全部可选值并保证它要么返回一个正确的图像,要么抛出咱们一系列的操做中产生的任何 error:api

func loadImage(named name: String, tintedWith color: UIColor, resizedTo size: CGSize) throws -> UIImage {
    var image = try loadImage(named: name)
    image = try tint(image, with: color)
    return try resize(image, to: size)
}
复制代码

上面代码的改动不只让咱们的函数体变得更加简单,并且 Debug 的时候也变得更加轻松。由于当发生问题时将会返回咱们明肯定义的错误,而不是去找出究竟是哪一个操做返回了 nil。闭包

然而咱们可能对 一直 处理各类错误没有丝毫兴趣,因此咱们就不须要在咱们代码中处处使用 do, try, catch 语句结构,(讽刺的是,这些语句也一样会产生大量咱们最初要避免的模板代码)。函数

开心的是当须要使用 Optional 的时候咱们均可以回过头来用它 —— 甚至包括在使用抛出函数的时候。咱们惟一须要作的就是在须要调用抛出函数的地方使用 try? 关键字,这样咱们又会获得一开始那样可选值类型的结果:

let optionalImage = try? loadImage(
    named: "Decoration",
    tintedWith: .brandColor,
    resizedTo: decorationSize
)
复制代码

使用 try? 的好处之一就是它把世界上最棒的两件事融合到了一块儿。咱们既能够在调用函数后获得一个可选值类型结果 —— 与此同时又让咱们可以使用抛出 error 的优势来管理咱们的控制流 👍。

验证输入

接下来,让咱们看下在验证输入时使用 error 能够多大程度上改善咱们的控制流。即便 Swift 已是一个很是有优点而且强类型的环境,它也不能一直保证咱们的函数收到验证过的输入值 —— 有些时候使用运行时检查是咱们惟一能作的。

让咱们看下另外一个例子,在这个例子中,咱们须要在注册新用户时验证用户的选择,在以前的时候,咱们的代码经常使用 guard 语句来验证每条规则,当错误发生时输出一条错误信息 —— 就像这样:

func signUpIfPossible(with credentials: Credentials) {
    guard credentials.username.count >= 3 else {
        errorLabel.text = "Username must contain min 3 characters"
        return
    }
    
    guard credentials.password.count >= 7 else {
        errorLabel.text = "Password must contain min 7 characters"
        return
    }
    
    // Additional validation
    ...
        
        service.signUp(with: credentials) { result in
            ...
    }
}
复制代码

即便咱们只验证上面的两条数据,咱们的验证逻辑也比咱们咱们预期中的增加快。当这种逻辑和咱们的 UI 代码混合在一块儿时(特别是同处在一个 View Controller 中)也让整个测试变得更加困难 —— 因此让咱们看看是否能够把一些代码解耦以使控制流更加完善。

理想状况下,咱们但愿验证代码只被咱们本身持有,这样就能使开发和测试相互隔离,而且可以使咱们的代码变得更易于重用。为了达到这个目的,咱们为全部的验证逻辑建立一个公用类型来包含验证代码的闭包。咱们能够称这个类型为验证器,并将它定义为一个简单的结构体并让它持有针对给出 Value 类型进行验证的闭包:

struct Validator<Value> {
    let closure: (Value) throws -> Void
}
复制代码

使用上面的代码,咱们就把验证函数重构为当一个输入值没有经过验证时抛出一个 error。然而,为每个验证过程定义一个新的 Error 类型可能会再次引起产生没必要要模板代码的问题(特别是当咱们仅仅只是想为用户展现出来一个错误而已时)—— 因此让咱们引入一个写验证逻辑时只须要简单传递一个 Bool 条件和一条当发生错误时展现给用户信息的函数:

struct ValidationError: LocalizedError {
    let message: String
    var errorDescription: String? { return message }
}

func validate( _ condition: @autoclosure () -> Bool,
    errorMessage messageExpression: @autoclosure () -> String
    ) throws {
    guard condition() else {
        let message = messageExpression()
        throw ValidationError(message: message)
    }
}
复制代码

上面咱们又使用了 @autoclosure,它是让咱们在闭包内自动解包的推断语句。查看更多信息,点击 "Using @autoclosure when designing Swift APIs"

有了上述条件,咱们如今能够实现共用验证器的所有验证逻辑 —— 在 Validator 类型内构造计算静态属性。例如,下面是咱们如何实现密码验证的:

extension Validator where Value == String {
    static var password: Validator {
        return Validator { string in
            try validate(
                string.count >= 7,
                errorMessage: "Password must contain min 7 characters"
            )
            
            try validate(
                string.lowercased() != string,
                errorMessage: "Password must contain an uppercased character"
            )
            
            try validate(
                string.uppercased() != string,
                errorMessage: "Password must contain a lowercased character"
            )
        }
    }
}
复制代码

最后,让咱们建立另外一个 validate 重载函数,它的做用有点像 语法糖,让咱们在有须要验证的值和要使用的验证器的时候去调用它:

func validate<T>(_ value: T, using validator: Validator<T>) throws {
    try validator.closure(value)
}
复制代码

全部代码都写好了,让咱们修改须要调用的地方以使用新的验证系统。上述方法的优雅之处在于,虽然须要一些额外的类型和一些基础准备,但它使咱们的验证输入值的代码变得很是漂亮而且整洁:

func signUpIfPossible(with credentials: Credentials) throws {
    try validate(credentials.username, using: .username)
    try validate(credentials.password, using: .password)
    
    service.signUp(with: credentials) { result in
        ...
    }
}
复制代码

也许还能作的更好点,咱们能够经过使用 do, try, catch 结构调用上面的 signUpIfPossible 函数将全部验证错误的逻辑放在一个单独的地方 —— 这时咱们就只须要向用户显示抛出错误的描述信息:

do {
    try signUpIfPossible(with: credentials)
} catch {
    errorLabel.text = error.localizedDescription
}
复制代码

值得注意的是,虽然上面的代码示例没有使用任何本地化,但咱们老是但愿在真实应用程序中向用户显示全部错误消息时使用本地化字符串。

抛出异常测试

围绕可能遇到的错误构建代码的另外一个好处是,它一般使测试更加容易。因为一个抛出函数本质上有两个不一样的可能输出 —— 一个值和一个错误。在许多状况下,覆盖这两个场景去添加测试是很是直接的。

例如,下面是咱们如何可以很是简单地为咱们的密码验证添加测试 —— 经过简单地断言错误用例确实抛出了一个错误,而成功案例没有抛出错误,这就涵盖了咱们的两个需求:

class PasswordValidatorTests: XCTestCase {
    func testLengthRequirement() throws {
        XCTAssertThrowsError(try validate("aBc", using: .password))
        try validate("aBcDeFg", using: .password)
    }
    
    func testUppercasedCharacterRequirement() throws {
        XCTAssertThrowsError(try validate("abcdefg", using: .password))
        try validate("Abcdefg", using: .password)
    }
}
复制代码

如上面代码所示,因为 XCTest 支持抛出测试功能 —— 而且每一个未被处理的错误都会做为一个失败 —— 咱们惟一须要作的就是使用 try 来调用咱们的 validate 函数验证用例是否成功,若是没有抛出错误咱们就测试成功了 👍。

总结

在 Swift 代码中其实有不少种方式来管理控制流 —— 不管操做成功仍是失败,使用 error 结合抛出函数是一个很是好的选择。虽然这样作的时候会须要一些额外的操做(如引入 error 类型并使用 trytry? 来调用函数)—— 可是让咱们的代码简洁起来真的会带来极大的提高。

函数将可选类型做为返回结果固然也是值得提倡的 —— 特别是在没有任何合理的错误能够抛出的状况下,可是若是咱们须要在几处地方同时为可选值使用 guard 语句进行判断,那么使用 error 替代可能给咱们带来更清晰的控制流。

你是什么想法呢? 若是你如今正在使用 error 结合抛出函数来管理你代码中的控制流 —— 或者你正在尝试其余方案?请在 Twitter @johnsundell 告诉我,期待你的疑问、评论和反馈。

感谢阅读!🚀

若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索