今是昨非

今是昨非

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

在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 = "用戶名必須包含至少3個字符"
        return
    }

    guard credentials.password.count >= 7 else {
        errorLabel.text = "密碼必須包含至少7個字符"
        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: "密碼必須包含至少7個字符")

            try validate(string.lowercased() != string, errorMessage: "密碼必須包含一個大寫字符")

            try validate(string.uppercased() != string, errorMessage: "密碼必須包含一個小寫字符")
        }
    }
}

為了做的徹底,重載一個新的 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
        ...
    }
}
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。