[譯] 如何在 Swift 5 中使用 Result

小顧Bruce發表於2019-03-23

[譯] 如何在 Swift 5 中使用 Result

SE-0235 在標準庫中引入了一個 Result 型別,使我們能夠更簡單、更清晰地處理複雜程式碼中的錯誤,比如非同步 API。這是人們在 Swift 早期就開始要求的東西,所以很高興看到它終於到來!

Swift 的 Result 型別被實現為一個列舉,它有兩種情況:successfailure。兩者都是使用泛型實現的,因此它們可以有您選擇的關聯值,但 failure 必須符合 Swift 的 Error 型別。

為了演示 Result,我們可以編寫一個網路請求函式來計算有多少未讀訊息在等待使用者。在這個例子程式碼中,我們將只有一個可能的錯誤,那就是請求的 URL 字串不是一個有效的 URL:

enum NetworkError: Error {
    case badURL
}
複製程式碼

這個函式將接受一個 URL 字串作為它的第一個引數,並接受一個 completion 閉包作為它的第二個引數。該 completion 閉包本身接受一個 Result,其中 success 將儲存一個整數,而 failure 案例將是某種 NetworkError。我們實際上並不打算在這裡連線到伺服器,但是使用一個 completion 閉包至少可以讓我們模擬非同步程式碼。

程式碼如下:

func fetchUnreadCount1(from urlString: String, completionHandler: @escaping (Result<Int, NetworkError>) -> Void)  {
    guard let url = URL(string: urlString) else {
        completionHandler(.failure(.badURL))
        return
    }
    
    // complicated networking code here
    print("Fetching \(url.absoluteString)...")
    completionHandler(.success(5))
}
複製程式碼

要使用該程式碼,我們需要檢查我們的 Result 中的值,看看我們的呼叫成功還是失敗,如下所示:

fetchUnreadCount1(from: "https://www.hackingwithswift.com") { result in
    switch result {
    case .success(let count):
        print("\(count) unread messages.")
    case .failure(let error):
        print(error.localizedDescription)
    }
}
複製程式碼

即使在這個簡單的場景中,Result 也給了我們兩個好處。首先,我們返回的錯誤現在是強型別的:它一定是某種 NetworkError。Swift 的常規丟擲函式是不檢查型別的,因此可以丟擲任何型別的錯誤。因此,如果您新增了一個 switch 語句來檢視他們的情況,您需要新增 default 情況,即使這種情況是不可能的。使用 Result 的強型別錯誤,我們可以通過列出錯誤列舉的所有情況來建立詳盡的 switch 語句。

其次,現在很清楚,我們要麼返回成功的資料要麼返回一個錯誤,它們兩個中有且只有一個一定會返回。如果我們使用傳統的 Objective-C 方法重寫 fetchUnreadCount1() 來完成 completion 閉包,你可以看到第二個好處的重要性:

func fetchUnreadCount2(from urlString: String, completionHandler: @escaping (Int?, NetworkError?) -> Void) {
    guard let url = URL(string: urlString) else {
        completionHandler(nil, .badURL)
        return
    }
    
    print("Fetching \(url.absoluteString)...")
    completionHandler(5, nil)
}
複製程式碼

這裡,completion 閉包將同時接收一個整數和一個錯誤,儘管它們中的任何一個都可能是 nil。Objective-C 之所以使用這種方法,是因為它沒有能力用關聯的值來表示列舉,所以別無選擇,只能將兩者都傳送回去,讓使用者自己去弄清楚。

然而,這種方法意味著我們已經從兩種可能的狀態變成了四種:一個沒有錯誤的整數,一個沒有整數的錯誤,一個錯誤和一個整數,沒有整數和沒有錯誤。最後兩種狀態應該是不可能的,但在 Swift 引入 Result 之前,沒有簡單的方法來表達這一點。

這種情況經常發生。URLSession 中的 dataTask() 方法使用相同的解決方案,例如:它用 (Data?, URLResponse?, Error?)。這可能會給我們提供一些資料、一個響應和一個錯誤,或者三者的任何組合 — Swift Evolution 的提議稱這種情況“尷尬不堪”。

可以將 Result 看作一個超級強大的 OptionalOptional 封裝了一個成功的值,但也可以封裝第二個表示沒有值的情況。然而,對於 Result,第二種情況還可以傳遞了額外的資料,因為它告訴我們哪裡出了問題,而不僅僅是 nil

為何不使用 throws

當你第一次看到 Result 時,你常常會想知道它為什麼有用,尤其是自從 Swift 2.0 以來,它已經有了一個非常好的 throws 關鍵字來處理錯誤。

你可以通過讓 completion 閉包接受另一個函式來實現幾乎相同的功能,該函式會丟擲或返回有問題的資料,如下所示:

func fetchUnreadCount3(from urlString: String, completionHandler: @escaping  (() throws -> Int) -> Void) {
    guard let url = URL(string: urlString) else {
        completionHandler { throw NetworkError.badURL }
        return
    }
    
    print("Fetching \(url.absoluteString)...")
    completionHandler { return 5 }
}
複製程式碼

然後,您可以使用一個接受要執行的函式的 completion 閉包呼叫 fetchUnreadCount3(),如下所示:

fetchUnreadCount3(from: "https://www.hackingwithswift.com") { resultFunction in
    do {
        let count = try resultFunction()
        print("\(count) unread messages.")
    } catch {
        print(error.localizedDescription)
    }
}
複製程式碼

這也能解決問題,但讀起來要複雜得多。更糟的是,我們實際上並不知道呼叫 result() 函式是做什麼的,所以如果它不僅僅返回一個值或丟擲一個值,那麼就有可能導致它自己的問題。

即使使用更簡單的程式碼,使用 throws 也常常迫使我們立即處理錯誤,而不是將錯誤儲存起來供以後處理。有了 Result,這個問題就消失了,錯誤被儲存在一個值中,我們可以在準備好時讀取這個值。

處理 Result

我們已經瞭解了 switch 語句如何讓我們以一種乾淨的方式評估 Resultsuccessfailure 案例,但是在開始使用它之前,還有五件事您應該知道。

首先,Result 有一個 get() 方法,如果存在則返回成功值,否則丟擲錯誤。這允許您將 Result 轉換為一個常規丟擲呼叫,如下所示:

fetchUnreadCount1(from: "https://www.hackingwithswift.com") { result in
    if let count = try? result.get() {
        print("\(count) unread messages.")
    }
}

複製程式碼

其次,如果您願意,可以使用常規的 if 語句來讀取列舉的情況,儘管有些人覺得語法有點奇怪。例如:

fetchUnreadCount1(from: "https://www.hackingwithswift.com") { result in
    if case .success(let count) = result {
        print("\(count) unread messages.")
    }
}
複製程式碼

第三,Result 有一個接受可能會丟擲錯誤的閉包的初始化器:如果閉包成功返回一個值,該值用於 success 情況,否則丟擲的錯誤將被放入 failure 情況。

例如:

let result = Result { try String(contentsOfFile: someFile) }
複製程式碼

第四,您還可以使用一般的 Error 協議,而不是使用您建立的特定錯誤列舉。事實上,Swift Evolution 的提議說:“預計 Result 的大多數用法都將使用 Swift.Error 作為 Error 型別引數。”

因此,可以使用 Result<Int, Error> 而不是 Result<Int, NetworkError>。雖然這意味著您失去了型別丟擲的安全性,但是您獲得了丟擲各種不同錯誤列舉的能力 —— 您更喜歡哪種錯誤列舉實際上取決於您的編碼風格。

最後,如果你已經在你的專案中有了一個自定義的 Result型別(任何你自己定義的或者從 GitHub 上的自定義 Result 型別匯入的),那麼它們將自動代替 Swift 自己的 Result 型別。這將允許您在不破壞程式碼的情況下升級到 Swift 5.0,但理想情況下,隨著時間的推移,您將遷移到 Swift 自己的 Result 型別,以避免與其他專案不相容。

轉換 Result

Result 有另外四個可能被證明有用的方法:map()flatMap()mapError()flatMapError()。這幾個方法都能夠以某種方式轉換成功或錯誤,前兩種方法和 Optional 上的同名方法行為類似。

map() 方法檢視 Result 內部,並使用指定的閉包將成功值轉換為另一種型別的值。但是,如果它發現失敗,它只直接使用它,而忽略您的轉換。

為了演示這一點,我們將編寫一些程式碼,生成 0 到最大值之間的隨機數,然後計算該數的因數。如果使用者請求一個小於零的隨機數,或者這個隨機數恰好是素數,即它沒有其他因數,除了它自己和 1,我們會認為這些都是失敗情況。

我們可以從編寫程式碼開始,對兩種可能的失敗案例進行建模:使用者試圖生成一個小於 0 的隨機數和生成的隨機數是素數:

enum FactorError: Error {
    case belowMinimum
    case isPrime
}
複製程式碼

接下來,我們將編寫一個函式,它接受一個最大值,並返回一個隨機數或一個錯誤:

func generateRandomNumber(maximum: Int) -> Result<Int, FactorError> {
    if maximum < 0 {
       // creating a range below 0 will crash, so refuse
            return .failure(.belowMinimum)
        } else {
            let number = Int.random(in: 0...maximum)
            return .success(number)
        }
    }
複製程式碼

當它被呼叫時,我們返回的 Result 要麼是一個整數,要麼是一個錯誤,所以我們可以使用 map() 來轉換它:

let result1 = generateRandomNumber(maximum: 11)
let stringNumber = result1.map { "The random number is: \($0)." }
複製程式碼

當我們傳入一個有效的最大值時,result1 將是一個成功的隨機數。因此,使用 map() 將獲取這個隨機數,並將其與字串插值一起使用,然後返回另一個 Result 型別,這次的型別是 Result< string, FactorError>

但是,如果我們使用了 generateRandomNumber(maximum: -11),那麼 result1 將被設定為 FactorError.belowMinimum 的失敗情況。因此,使用 map() 仍然會返回 Result<String, FactorError>,但是它會有相同的失敗情況和相同的 FactorError.belowMinimum 錯誤。

既然您已經瞭解了 map() 如何讓我們將成功型別轉換為另一種型別,那麼讓我們繼續,我們有一個隨機數,因此下一步是計算它的因數。為此,我們將編寫另一個函式,它接受一個數字並計算其因數。如果它發現數字是素數,它將返回一個帶有 isPrime 錯誤的失敗 Result,否則它將返回因數的數量。

這是程式碼:

func calculateFactors(for number: Int) -> Result<Int, FactorError> {
    let factors = (1...number).filter { number % $0 == 0 }
    
    if factors.count == 2 {
        return .failure(.isPrime)
    } else {
        return .success(factors.count)
    }
}

複製程式碼

如果我們想使用 map() 來轉換 generateRandomNumber() 生成隨機數後再 calculateFactors() 的輸出,它應該是這樣的:

let result2 = generateRandomNumber(maximum: 10)
let mapResult = result2.map { calculateFactors(for: $0) }

複製程式碼

然而,這使得 mapResult 成為一個相當難看的型別:Result<Result<Int, FactorError>, FactorError>。它是另一個 Result 內部的一個 Result

就像可選值一樣,現在是 flatMap() 方法起作用的時候了。如果你的轉換閉包返回一個 ResultflatMap() 將直接返回新的 Result,而不是包裝在另一個 Result 內:

let flatMapResult = result2.flatMap { calculateFactors(for: $0) }
複製程式碼

因此,其中 mapResult 是一個 Result<Result<Int, FactorError>, FactorError>flatMapResult 被展平成 Result<Int, FactorError> – 第一個原始成功值(一個隨機數)被轉換成一個新的成功值(因數的數量)。就像 map() 一樣,如果其中一個 Result 失敗,那麼 flatMapResult 也將失敗。

至於 mapError()flatMapError(),除了轉換 error 值而不是 success 值外,它們執行類似的操作。

接下來?

我寫過一些關於 Swift 5 其他一些很棒的新功能的文章,你可能想看看:

您可能還想嘗試我的 What’s new in Swift 5.0 playground,它允許您互動式地嘗試 Swift 5 的新功能。

如果您想了解更多 Swift 中的 result 型別,您可能想檢視 GitHub 上的 antitypical/Result 的原始碼,這是最流行的 result 實現之一。

我還強烈推薦閱讀 Matt Gallagher 的 excellent discussion of Result,這本書已經有幾年的歷史了,但仍然很有用,也很有趣。

你已經在忙著為 Swift 4.2 和 iOS 12 更新你的應用程式了,為什麼不讓 Instabug 幫你發現和修復 bug 呢?只需新增兩行程式碼 到您的專案中,就可以收到全面的報告,其中包含您釋出世界級應用程式所需的所有反饋 — 單擊此處瞭解更多資訊!

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章