今是昨非

今是昨非

日出江花红胜火,春来江水绿如蓝

在Swift中使用error来控制流程

此文是翻译#

原文链接:Using errors as control flow in Swift

app 和项目里管理控制流会对代码的执行速度,代码的调试复杂度有重大的影响。代码的控制流本质上是函数和声明的执行顺序,及代码执行路径。

尽管 Swift 提供了很多工具定义控制流 —— 例如 if, else, while 及 optional;这周,我们来看一下,如何通过 Swift 编译时错误来抛出和处理 model,来让控制流程更容易管理。

抛出可空的值#

可选值,作为 Swift 的重要特征,处理空的数据时可被合法的忽略;它也经常被用作给定函数的来源样板在控制流程中。

下面,重写了从 app 中 bundle 加载、调整图片的方法。由于每一步操作都返回了可空的图片,不得不写多个 guard 语句,告诉函数哪里可以退出:

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 来应对运行时错误 —— 这使得我们不得不在每一步都要解析结果,也隐藏了为什么这个错误发生的根本原因。

然后我们来看一下,如何通过抛出函数和错误重构控制流程来解决上面的问题。第一步定义一个包含处理图片的过程中可能出现的所有错误的 enum,如下:

enum ImageError: Error {
    case missing
    case failedToCreateContext
    case failedToRenderImage
    ...
}

然后修改函数失败时抛出上面定义的错误,而不是返回 nil。例如,修改 loadImage (named:) 方法,返回一个非空的 image 或抛出 ImageError.missing:

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

如果把其他的图片处理方法都这样修改了,那顶层的其他函数也可以依葫芦画瓢 —— 移除所有可选,使它们在操作中要不返回确定的图片,要不抛出一个错误:

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

上面的修改不仅使函数体更加简洁,也使得调试更加容易,因为如果有错误发生,我们会得到一个明确定义的错误 —— 而不是需要去查哪一步返回的 nil。

然而,事实是,并不是所有的地方都需要处理错误,所以不需要强制 do、try、catch 模式的使用;而且滥用 do、try、catch 又会导致我们为了尽量避免的样板代码 —— 在用到的时候仔细区分。

好消息是,我们随时可以回去用可空值即使我们用了抛出方法。所需要到只是在调用抛出方法时用 try? 关键字,然后我们就得到了可选值:

let optionalImage = try? loadImage {
    named: "Decoration",
    tintedWith: .brandColor,
    resizedTo: decorationSize
}

使用 try? 最棒的地方是兼具两种方式的优点。既可以在调用中得到个可空值 —— 同时也能用 throw、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 charaters"
        return
    }

    // Additional validation
    ...
    service.signUp(with: credentials) { result in
        ...
    }
}

尽管上面的代码只校验了两个条件,验证逻辑增长会超出预料。这种逻辑存在于 UI中(尤其是 view Controller 中)会变得更难测试 —— 所以,来看下如何解耦,并且提升代码控制流。

理想状况下,我们希望我们的代码可以自我包含。这样它就可以在隔绝中测试,也可以在我们的代码中使用。为了实现这个,先为所有验证逻辑创建一个指定类型。命名为 Validator,是一个结构体,里面是个给定 Value 的验证闭包:

struct Validator<Value> {
    let closure: (Value) throws -> Void
}

通过上面的代码,可以构建一个validators,在值验证不通过时,抛出一个错误。然而为每个验证进程都定义新的错误类型也会产生无用的样板(特别是我们想要这些错误展示给用户)—— 所以,定义一个函数,只需要传 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
        ...
    }
}
加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。