[譯] 在 Swift 中使用 errors 作為控制流

Swants發表於2019-03-19

我們在 App 和系統中對控制流的管理方式,會對我們程式碼的執行速度、Debug 的難易程度等方方面面產生巨大影響。我們程式碼中的控制流本質上是我們各種方法函式和語句的執行順序,以及程式碼最終將會進入到哪個流程分支。

Swift 為我們提供了很多定義控制流的工具 —— 如 if, elsewhile 語句,還有類似 Optional 這樣的結構。這周讓我們將目光放在如何使用 Swift 內建的錯誤丟擲和處理 Model,以使我們能夠更輕鬆地管理控制流。

撇開 Optional

Optional 作為一種重要的語言特性,也是資料建模時處理欄位缺失的一種良好方式。在涉及到控制流的特定函式內卻也成了大量重複樣板程式碼的源頭。

下面我寫了個函式來載入 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 來處理執行時的錯誤,這兩處地方都需要我們為每步操作結果進行解包,並且還使引發 error 的語句變得無從查詢。

讓我們看看如何通過 error 重構控制流來解決這兩個問題,而不是使用丟擲函式。我們將從定義一個列舉開始,它包含影像處理程式碼中可能發生的每個錯誤的情況——看起來像這樣:

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:

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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章