Swift 5 新功能 - Result Type

Result Type 是在 Swift 5 新引入的一個 enum 的型別,主要是要簡化非同步或是複雜的操作間,錯誤傳遞的問題。既有的 Swift 錯誤控制 (Error Handling),主要是仰賴 try/catch 語法,來做到將 error 在上下層物件之間傳遞 (propergation)。try/catch 這個語法雖然好用,不需要明確定義錯誤傳遞方式也能夠做到將錯誤讓更高層次的物件處理,但仍然有個小問題,就是它能處理的範疇,多被侷限在同步的任務裡(SE-0235 - Motivation)。如果是非同步的錯誤傳遞,我們通常都需要自己設計方法來達到。

這是一個許多人引頸期盼的功能,也有許多人拿來跟 Promise/Future 或 async/await 做比較。當然 Result 想解決的問題可以說是 async/await 想解決的問題的一個子集合,但不表示它就沒有被實作的必要,而且 Result type 的提前出現也可以對於既有或未來的 async/await 的實用有相當大的幫助(Revised SE-0235),至少我們可以樂見在各種非同步請求的程式中,對於錯誤還有資料的統一化介面所帶來的各種好處。

因為 Result 是一個非常簡單的、非語法層面的實作,相信很多人已經在自己的專案上都有在使用,但是在這次改變中,仍然有許多巧妙的設計,值得介紹一下,所以小弟還是花點時間拋個磚頭,歡迎各路高手補充!另外為了求正確性及求有所本,資料大多引用自 Swfit Evolution ,但還是有可能因為小弟英文不佳或睡眠不足而解讀錯誤,如果發現有錯或不清楚都歡迎指正!

甚麼是 Result?

首先,我們先從 Result 的原始碼來看起:

public enum Result<Success, Failure> where Failure : Error {

    /// A success, storing a `Success` value.
    case success(Success)

    /// A failure, storing a `Failure` value.
    case failure(Failure)
}

Result 其實就是一個簡單的 enum ,代表一個 “成功” 或 “失敗” 的執行結果,而成功或失敗的結果內容,就被夾帶在 Associated Value 裡面。而它的用法也相當簡單,假設我們原本有個非同步請求的程式,是要發一個 request 到 server,然後等待 server 的回應來做接下來的事情。讓我們先定義好錯誤類型:

enum CustomizedError: Error {
    case authFail
    case permissionDenied
    case unknowError
}


接著來看看,在 Result type 出現以前,我們處理非同步請求的程式:

func fetchData(complete: (String?, CustomizedError?) -> Void) {
    // ... 用你喜歡的方式發一個 request 到 server ...

    // 成功的話就這樣呼叫 complete 
    complete("It works!", nil)

    // 失敗的話就透過 complete 把錯誤回傳
    complete(nil, .unknowError)
}

上面的 func 會像下面這樣使用:

fetchData { (response, error) in
    if let error = error {
        print(error)
    } else if let response = response {
        print(response)
    } else {
    }
}

有了 Result type 之後,我們可以這樣修改我們的 fetchData :

func fetchData(result: (Result<String, CustomizedError>) -> Void) {
    // ... 一樣毫不手軟地發 request 出去 ...

    // 成功
    result(.success("It works!"))

    // 失敗
    result(.failure(.unknowError))
}

而使用上就會變成:

fetchData { (result) in
    switch result {
    case .success(let response):
        print(response)
    case .failure(let error):
        print(error)
    }
}

喜愛玩威利在那裡的你,一定馬上就發現,有沒有 Result 在呼叫的程式碼上,行數跟本就一樣XD 當然工程師從來就不是靠行數在吃飯的(是靠臉),藉由引入 Result,我們的非同步接口變得非常明確,在決定如何處理非同步結果的部份,不再是透過確認 error 是否存在或是 response 是否存在(nullity check)來判斷,而是明確地指出了成功與失敗,在可讀性上面可以說是比以往做法要好上不少。

簡化後的非同步資料傳遞

我們再來看一個螞蟻大的例子:

// 以往非同步的資料需要引用兩個 property
var response: String?
var error: Error?

func doSomeWork() {
    // ... 某個很花時間的工作 ...
    // 如果成功
        response = “It works!”
    // 如果失敗
        error = CustomizedError.unknowError
}

doSomeWork()

if let response = response {
    print(response)
} else if let error = error {
    print(error)
}

現在可以改成:

let aResult: Result<String, Error> = Result { () throws -> String in
    // ... 一個耗時的工作,像是利用AI人工智慧演算法算出 pi 的最後一個數字 ...
    // 如果成功
        return "It works!"
    // 如果失敗
        throw CustomizedError.unknowError
}

switch aResult {
case .success(let data):
    print(data)
case .failure(let error):
    print(error)
}

現在不需要再引入額外的 property,也能夠處理非同步或需要長時間運作的程式了🤖(SE-0235 - Delayed handling)!另外,你也可以發現,原本的工作已經被包裝成一個 型別為 Result 的物件,它可以在真正被執行前,被自由傳遞到不同的地方,能夠有效地降低在處理複雜任務時的心理負擔。

巧妙地封裝錯誤/資料處理

回頭看一下剛剛的例子,會發現這個 let aResult = Result <String, Error> ... 這一行語法非常特別,這個是 Result type 的 initializer,也是小弟覺得 Result 設計的非常畫龍點睛的地方,我們來看看它的定義:

public enum Result<Success, Failure> where Failure : Error {
    ...
    ... 
    ... 
    
    init(catching body: () throws -> Success)
}

它是一個簡單的 init ,參數是一個會丟出例外的 closure 。用上面的例子來看,在傳進去的 closure 裡,如果成功就會利用 return 把資料回傳,反應到 aResult 這個變數,就會是 .success 這個 case 。如果失敗,就觸發 exception ,把錯誤丟出去,反應到 aResult 這個變數上,就會是 .failure 這個 case。

這樣設計最特別的地方,在於它可以把一般 try/catch error handling ,無痛轉變成 Result pattern,用例子說明可能比較好懂。原本 String(contentesOfFile: String) 這個 function 在呼叫的時候,是需要用 try/catch 去處理的:

var content1: String = ""
do {
    try content1 = String(contentsOfFile: "file/path")
} catch {
    handleWithDefaultContent(default: defaultContent1)
}
print(content1)

現在我們可以寫成這樣:

let result1 = Result<String, Error> { try String(contentsOfFile: "file/path1") }

switch result1 {
case .success(let content):
    print(content: content)
case .failure(let error):
    handleWithDefaultContent(default: defaultContent1)
}

一旦利用 Result 包裝起來後,我們就不需要馬上用 try/catch 去處理錯誤,而是可以把錯誤拉到真正需要處理的層級去處理。用上面的例子來說,原本需要馬上 try/catch 來處理的錯誤,現在可以移到接受 result1 的物件中去處理,實作上彈性就會變得很大。(Preserving the Results of a Throwing Expression)

總結

Result 不算是一個非常大的改動,跟隨著這個實作出現的 Error self-conformance 說不定對大多數人來說更有感XD,尤其考量到現在很多人都已經在官方實作前先實作一發了,但是對於整體 Swift 開發來說,仍然是一件好事,也可以視為幫未來 async/await 鋪上了非常平坦的道路。

歡迎一起討論關於 Result 還是各種非同步處理、錯誤控制等等有趣的議題!

About me

純正台灣人,目前在東京掙扎求生的 Swift 工程師。喜愛技術與研究新事物但僅止於喜歡,Github有上無數的還沒做完也沒在做的專案,Chrome上也總是有 20 個以上待看文章。東京科技聚會 WebHack 的共同組織者,可以在現場找到野生的作者。偏好傳遞精準的文字,同時是 AppCoda TW 固定作者,也是 Flawless App Stories 作者。歡迎追蹤我的 Twitter

Reference

Error Handling Rationale and Proposal

[Revised] SE-0235: Add Result to the Standard Library

Show Comments

Get the latest posts delivered right to your inbox.