Swift設(shè)計思想Result<T>與Result<T,?E:?Error>類型解析
背景知識
Cocoa API 中有很多接受回調(diào)的異步方法,比如 URLSession
的 dataTask(with:completionHandler:)
。
URLSession.shared.dataTask(with: request) { data, response, error in if error != nil { handle(error: error!) } else { handle(data: data!) } }
有些情況下,回調(diào)方法接受的參數(shù)比較復雜,比如這里有三個參數(shù):(Data?, URLResponse?, Error?)
,它們都是可選值。當 session 請求成功時,Data
參數(shù)包含 response 中的數(shù)據(jù),Error
為 nil
;當發(fā)生錯誤時,則正好相反,Error
指明具體的錯誤 (由于歷史原因,它會是一個 NSError
對象),Data
為 nil
。
關(guān)于這個事實,dataTask(with:completionHandler:)
的文檔的 Discussion 部分有十分詳細的說明。另外,response: URLResponse?
相對復雜一些:不論是請求成功還是失敗,只要從 server 收到了 response
,它就會被包含在這個變量里。
這么做雖然看上去無害,但其實存在改善的余地。顯然 data
和 error
是互斥的:事實上是不可能存在 data
和 error
同時為 nil
或者同時非 nil
的情況的,但是編譯器卻無法靜態(tài)地確認這個事實。編譯器沒有制止我們在錯誤的 if
語句中對 nil
值進行解包,而這種行為將導致運行時的意外崩潰。
我們可以通過一個簡單的封裝來改進這個設(shè)計:如果你實際寫過 Swift,可能已經(jīng)對 Result
很熟悉了。它的思想非常簡單,用泛型將可能的返回值包裝起來,因為結(jié)果是成功或者失敗二選一,所以我們可以藉此去除不必要的可選值。
enum Result<T, E: Error> { case success(T) case failure(E) }
把它運用到 URLSession
中的話,包裝一下 URLSession
方法,上面調(diào)用可以變?yōu)椋?/p>
// 如果 Result 存在于標準庫的話, // 這部分代碼應該由標準庫的 Foundataion 擴展進行實現(xiàn) extension URLSession { func dataTask(with request: URLRequest, completionHandler: @escaping (Result<(Data, URLResponse), NSError>) -> Void) -> URLSessionDataTask { return dataTask(with: request) { data, response, error in if error != nil { completionHandler(.failure(error! as NSError)) } else { completionHandler(.success((data!, response!))) } } } } URLSession.shared.dataTask(with: request) { result in switch result { case .success(let (data, _)): handle(data: data) case .failure(let error): handle(error: error) } }
這里原文代碼中 completionHandler
里 (Result<(Data, URLResponse), NSError>) -> Void)
這個類型是錯誤的。Data
存在時 URLResponse
一定存在,但是我們上面討論過,當 NSError
不為 nil
時,URLResponse
也可能存在。原文代碼忽略了這個事實,將導致 error 狀況時無法獲取到可能的 URLResponse
。正確的類型應該是 (Result<(Data), NSError>, URLResponse?) -> Void
當然,在回調(diào)中對 result
的處理也需要對應進行修改。
調(diào)用的時候看起來很棒,我們可以避免檢查可選值的情況,讓編譯器保證在對應的 case
分支中有確定的非可選值。這個設(shè)計在很多存在異步代碼的框架中被廣泛使用,比如 Swift Package Manager,Alamofire 等中都可覓其蹤。
上面代碼注釋中提到,「如果 Result 存在于標準庫的話,這部分代碼應該由標準庫的 Foundataion 擴展進行實現(xiàn)」。但是考慮到原有的可選值參數(shù) ((Data?, URLResponse?, Error?)
) 作為回調(diào)的 API 將會共享同樣的函數(shù)名,所以上面的函數(shù)命名是不可取的,否則將導致沖突。在這類 public API 發(fā)布后,如何改善和迭代確實是個難題。一個可行的方法是把 Foundation 的 URLSession
deprecate 掉,提取出相關(guān)方法放到諸如 Network.framework 里,并讓它跨平臺。另一種可行方案是通過自動轉(zhuǎn)換工具,強制 Swift 使用 Result
的回調(diào),并保持 OC 中的多參數(shù)回調(diào)。如果你正在打算使用 Result
改善現(xiàn)有設(shè)計,并且需要考慮保持 API 的兼容性時,這會是一個不小的挑戰(zhàn)。
錯誤類型泛型參數(shù)
如此常用的一個可以改善設(shè)計的定義,為什么沒有存在于標準庫中呢?關(guān)于 Result
,其實已經(jīng)有相關(guān)的提案:
這個提案中值得注意的地方在于,Result
的泛型類型只對成功時的值進行了類型約束,而忽略了錯誤類型。給出的 Result
定義類似這樣:
enum Result<T> { case success(T) case failure(Error) }
很快,在 1 樓就有人質(zhì)疑,問這樣做的意義何在,因為畢竟很多已存在的 Result
實現(xiàn)都是包含了 Error
類型約束的。確定的 Error
類型也讓人在使用時多了一份“安全感”。
不過,其實我們實際類比一下 Swift 中已經(jīng)存在的錯誤處理的設(shè)計。Swift 中的 Error
只是一個協(xié)議,在 throw 的時候,我們也并不會指明需要拋出的錯誤的類型:
func methodCanThrow() throws { if somethingGoesWrong { // 在這里可以 throw 任意類型的 Error } } do { try methodCanThrow() } catch { if error is SomeErrorType { // ... } else if error is AnotherErrorType { // ... } }
但是,在帶有錯誤類型約束的 Result<T, E: Error>
中,我們需要為 E
指定一個確定的錯誤類型 (或者說,Swift 并不支持在特化時使用協(xié)議,Result<Response, Error>
這樣的類型是非法的)。這與現(xiàn)有的 Swift 錯誤處理機制是背道而馳的。
關(guān)于 Swift 是否應該拋出帶有類型的錯誤,曾經(jīng)存在過一段時間的爭論。最終問題歸結(jié)于,如果一個函數(shù)可以拋出多種錯誤 (不論是該函數(shù)自身產(chǎn)生的錯誤,還是在函數(shù)中 try 其他函數(shù)時它們所帶來的更底層的錯誤),那么 throws
語法將會變得非常復雜且不可控 (試想極端情況下某個函數(shù)可能會拋出數(shù)十種錯誤)?,F(xiàn)在大家一致的看法是已有的用 protocol Error
來定義錯誤的做法是可取的,而且這也編碼在了語言層級,我們對「依賴編譯器來確定 try catch
會得到具體哪種錯誤」這件事,幾乎無能為力。
另外,半開玩笑地說,要是 Swift 能類似這樣 extension Swift.Error: Swift.Error {}
,支持協(xié)議遵守自身協(xié)議的話,一切就很完美了,XD。
選擇哪個比較好?
兩種方式各有優(yōu)缺點,特別在如果需要考慮 Cocoa 兼容的情況下,更并說不上哪一個就是完勝。這里將兩種寫法的優(yōu)缺點簡單比較一下,在實踐中最好是根據(jù)項目情況進行選擇。
Result<T, E: Error>
優(yōu)點
可以由編譯器幫助進行確定錯誤類型
當通過使用某個具體的錯誤類型擴展 Error
并將它設(shè)定為 Result
的錯誤類型約束后,在判斷錯誤時我們就可以比較容易地檢查錯誤處理的完備情況了:
enum UserRegisterError: Error { case duplicatedUsername case unsafePassword } userService.register("user", "password") { result: Result<User, UserRegisterError> in switch result { case .success(let user): print("User registered: \(user)") case .failure(let error): if error == .duplicatedUsername { // ... } else if error == .unsafePassword { // ... } } }
上例中,由于 Error
的類型已經(jīng)可以被確定是 UserRegisterError
,因此在 failure
分支中的檢查變得相對容易。
這種編譯器的類型保證給了 API 使用者相當強的信心,來從容進行錯誤處理。如果只是一個單純的 Error
類型,API 的用戶將面臨相當大的壓力,因為不翻閱文檔的話,就無從知曉需要處理怎樣的錯誤,而更多的情況會是文檔和事實不匹配…
但是帶有類型的錯誤就相當容易了,查看該類型的 public member 就能知道會面臨的情況了。在制作和發(fā)布框架,以及提供給他人使用的 API 的時候,這一點非常重要。
按條件的協(xié)議擴展
使用泛型約束的另一個好處是可以方便地對某些情況的 Result
進行擴展。
舉例來說,某些異步操作可能永遠不會失敗,對于這些操作,我們沒有必要再使用 switch 去檢查分支情況。一個很好的例子就是 Timer
,我們設(shè)定一個在一段時間后執(zhí)行的 Timer 后,如果不考慮人為取消,這個 Timer 總是可以正確執(zhí)行完畢,而不會發(fā)生任何錯誤的。我們可能會選擇使用一個特定的類型來代表這種情況:
enum NoError: Error {} func run(after: TimeInterval, done: @escaping (Result<Timer, NoError>) -> Void ) { Timer.scheduledTimer(withTimeInterval: after, repeats: false) { timer in done(.success(timer)) } }
在使用的時候,本來我們需要這樣的代碼:
run(after: 2) { result in switch result { case .success(let timer): print(timer) case .failure: fatalError("Never happen") } }
但是,通過對 E
為 NoError
的情況添加擴展,可以讓事情簡單不少:
extension Result where E == NoError { var value: T { if case .success(let v) = self { return v } fatalError("Never happen") } } run(after: 2) { // $0.value is the timer object print($0.value) }
這個 Timer
的例子雖然很簡單,但是可能實際上意義不大,因為我們可以直接使用 Timer.scheduledTimer
并使用簡單的 block 完成。但是當回調(diào) block 有多個參數(shù)時,或者需要鏈式調(diào)用 (比如為 Result
添加 map
,filter
之類的支持時),類似 NoError
這樣的擴展方式就會很有用。
在 NSHipster 里有一篇關(guān)于 Never 的文章,提到使用 Never
來代表無值的方式。其中就給出了一個和 Result
一起使用的例子。我們只需要使 extension Never: Error {}
就可以將它指定為 Result<T, E: Error>
的第二個類型參數(shù),從而去除掉代碼中對 .failure
case 的判斷。這是比 NoError
更好的一種方式。
當然,如果你需要一個只會失敗不會成功的 Result
的話,也可以將 Never
放到第一個類型參數(shù)的位置:Result<Never, E: Error>
。
缺點
與 Cocoa 兼容不良
由于歷史原因,Cocoa API 中表達的錯誤都是”無類型“的 NSError
的。如果你跳出 Swift 標準庫,要去使用 Cocoa 的方法 (對于在 Apple 平臺開發(fā)來說,這簡直是一定的),就不得不面臨這個問題。很多時候,你可能會被寫成 Result<SomeValue, NSError>
的形式,這樣我們上面提到的優(yōu)點幾乎就喪失殆盡了。
可能需要多層嵌套或者封裝
即使對于限定在 Swift 標準庫的情況來說,也有可能存在某個 API 產(chǎn)生若干種不同的錯誤的情況。如果想要完整地按照類型處理這些情況,我們可能會需要將錯誤嵌套起來:
// 用戶注冊可能產(chǎn)生的錯誤 // 當用戶注冊的請求完成且返回有效數(shù)據(jù),但數(shù)據(jù)表明注冊失敗時觸發(fā) enum UserRegisterError: Error { case duplicatedUsername case unsafePassword } // Server API 整體可能產(chǎn)生的錯誤 // 當請求成功但 response status code 不是 200 時觸發(fā) enum APIResponseError: Error { case permissionDenied // 403 case entryNotFound // 404 case serverDied // 500 } // 所有的 API Client 可能發(fā)生的錯誤 enum APIClientError: Error { // 沒有得到響應 case requestTimeout // 得到了響應,但是 HTTP Status Code 非 200 case apiFailed(APIResponseError) // 得到了響應且為 200,但數(shù)據(jù)無法解析為期望數(shù)據(jù) case invalidResponse(Data) // 請求和響應一切正常,但 API 的結(jié)果是失敗 (比如注冊不成功) case apiResultFailed(Error) }
上面的錯誤嵌套比較幼稚。更好的類型結(jié)構(gòu)是將 UserRegisterError
和 APIResponseError
定義到 APIClientError
里,另外,因為不會直接拋出,因此沒有必要讓 UserRegisterError
和 APIResponseError
遵守 Error
協(xié)議,它們只需要承擔說明錯誤原因的任務(wù)即可。
對這幾個類型加以整理,并重新命名,現(xiàn)在我認為比較合理的錯誤定義如下 (為了簡短一些,我去除了注釋):
enum APIClientError: Error { enum ResponseErrorReason { case permissionDenied case entryNotFound case serverDied } enum ResultErrorReason { enum UserRegisterError { case duplicatedUsername case unsafePassword } case userRegisterError(UserRegisterError) } case requestTimeout case apiFailed(ResponseErrorReason) case invalidResponse(Data) case apiResultFailed(ResultErrorReason) }
當然,如果隨著嵌套過深而縮進變多時,你也可以把內(nèi)嵌的 Reason
enum 放到 APIClientError
的 extension 里去。
上面的 APIClientError
涵蓋了進行一次 API 請求時所有可能的錯誤,但是這套方式在使用時會很痛苦:
API.send(request) { result in switch result { case .success(let response): //... case .failure(let error): switch error { case .requestTimeout: print("Timeout!") case .apiFailed(let apiFailedError): switch apiFailedError: { case .permissionDenied: print("403") case .entryNotFound: print("404") case .serverDied: print("500") } case .invalidResponse(let data): print("Invalid response body data: \(data)") case .apiResultFailed(let apiResultError): if let apiResultError = apiResultError as? UserRegisterError { switch apiResultError { case .duplicatedUsername: print("User already exists.") case .unsafePassword: print("Password too simple.") } } } } }
相信我,你不會想要寫這種代碼的。
經(jīng)過半年的實踐,事實是我發(fā)現(xiàn)這樣的代碼并沒有想象中的麻煩,而它帶來的好處遠遠超過所造成的不便。
這里代碼中有唯一一個 as?
對 UserRegisterError
的轉(zhuǎn)換,如果采用更上面引用中定義的 ResultErrorReason
,則可以去除這個類型轉(zhuǎn)換,而使類型系統(tǒng)覆蓋到整個錯誤處理中。
相較于對每個 API 都寫這樣一堆錯誤處理的代碼,我們顯然更傾向于集中在一個地方處理這些錯誤,這在某種程度上“強迫”我們思考如何將錯誤處理的代碼抽象化和一般化,對于減少冗余和改善設(shè)計是有好處的。另外,在設(shè)計 API 時,我們可以提供一系列的便捷方法,來讓 API 的用戶能很快定位到某幾個特定的感興趣的錯誤,并作出處理。比如:
extension APIClientError { var isLoginRequired: Bool { if case .apiFailed(.permissionDenied) = self { return true } return false } }
用 error.isLoginRequired
即可迅速確定是否是由于用戶權(quán)限不足,需要登錄,產(chǎn)生的錯誤。這部分內(nèi)容可以由 API 的提供者主動定義 (這樣做也起到一種指導作用,來告訴 API 用戶到底哪些錯誤是特別值得關(guān)心的),也可以由使用者在之后自行進行擴展。
另一種”方便“的做法是使用像是 AnyError
的類型來對 Error
提供封裝:
struct AnyError: Error { let error: Error }
這可以把任意 Error
封裝并作為 Result<Value, AnyError>
的 .failure
成員進行使用。但是這時 Result<T, E: Error>
中的 E
幾乎就沒有意義了。
Swift 中存在不少 Any
開頭的類型,比如 AnyIterator
,AnyCollection
,AnyIndex
等等。這些類型起到的作用是類型抹消,有它們存在的歷史原因,但是隨著 Swift 的發(fā)展,特別是加入了 Conditional Conformance 以后,這一系列 Any
類型存在的意義就變小了。
使用 AnyError
來進行封裝 (或者說對具體 Error 類型進行抹消),可以讓我們拋出任意類型的錯誤。這更多的是一種對現(xiàn)有 Cocoa API 的妥協(xié)。對于純 Swift 環(huán)境來說,AnyError
并不是理想中應該存在的類型。因此如果你選擇了 Result<T, E: Error>
的話,我們就應該盡可能避免拋出這種無類型的錯誤。
那問題就回到了,對于 Cocoa API 拋出的錯誤 (也就是以前的 NSError
),我們應該怎樣處理?一種方式是按照文檔進行封裝,比如將所有 NSURLSessionError
歸類到一個 URLSessionErrorReason
,然后把從 Cocoa 得到的 NSError
作為關(guān)聯(lián)值傳遞給使用者;另一種方式是在拋出給 API 使用者之前,在內(nèi)部就對這個 Cocoa 錯誤進行“消化”,將它轉(zhuǎn)換為有意義的特定的某個已經(jīng)存在的 Error Reason。后者雖然減輕了 API 使用者的壓力,但是勢必會丟失一些信息,所以如果沒有特別理由的話,第一種的做法可能更加合適。
- 錯誤處理的 API 兼容存在風險
- 現(xiàn)在來說,為 enum 添加一個 case 的操作是無法做到 API 兼容的。使用側(cè)如果枚舉了所有的 case 進行處理的話,在 case 增加時,原來的代碼將無法編譯。(不過對于錯誤處理來說,這倒可能對強制開發(fā)者對應錯誤情況是一種督促 233..)
- 如果一個框架或者一套 API 嚴格遵守 semantic version 的話,這意味著一個大版本的更新。但是其實我們都心知肚明,增加一個之前可能忽略了的錯誤情況,卻帶來一個大版本更新,帶來的麻煩顯然得不償失。
- Swift 社區(qū)現(xiàn)在對于增加 enum case 時如何保持 API compatibility 也有一個成熟而且已經(jīng)被接受了的提案。將 enum 定義為
frozen
和nonFrozen
,并對nonFrozen
的 enum 使用unknown
關(guān)鍵字來保證源碼兼容。我們在下個版本的 Swift 中應該就可以使用這個特性了。
Result
不帶 Error
類型的優(yōu)缺點正好和上面相反。
相對于 Result<T, E: Error>
,Result<T>
不在外部對錯誤類型提出任何限制,API 的創(chuàng)建者可以擺脫 AnyError
,直接將任意的 Error
作為 .failure
值使用。
但同時很明顯,相對的,一個最重要的特性缺失就是我們無法針對錯誤類型的特點為 Result
進行擴展了。
結(jié)論
因為 Swift 并沒有提供使用協(xié)議類型作為泛型中特化的具體類型的支持,這導致在 API 的強類型嚴謹性和靈活性上無法取得兩端都完美的做法。硬要對比的話,可能 Result<T, E: Error>
對使用者更加友好一些,因為它提供了一個定義錯誤類型的機會。但是相對地,如果創(chuàng)建者沒有掌握好錯誤類型的程度,而將多層嵌套的錯誤傳遞時,反而會增加使用者的負擔。同時,由于錯誤類型被限定,導致 API 的變更要比只定義了結(jié)果類型的 Result<T>
困難得多。
不過 Result
暫時看起來不太可能被添加到標準庫中,因為它背后存在一個更大的協(xié)程和整個語言的異步模型該如何處理錯誤的話題。在有更多的實踐和討論之前,如果沒有革 命性和語言創(chuàng)新的話,對如何進行處理的話題,恐怕很難達成完美的共識。
結(jié)論:錯誤處理真的是一件相當艱難的事情。
最近這半年,在不同項目里,我對 Result<T, E: Error>
和 Result<T>
兩種方式都進行了一些嘗試?,F(xiàn)在看來,我會更多地選擇帶有錯誤類型的 Result<T, E: Error>
的形式,特別是在開發(fā)框架或者需要嚴謹?shù)腻e誤處理的時候。將框架中可能拋出的錯誤進行統(tǒng)一封裝,可以很大程度上減輕使用者的壓力,讓錯誤處理的代碼更加健壯。如果設(shè)計得當,它也能提供更好的擴展性。
以上就是Swift設(shè)計思想Result<T>與Result<T, E: Error>類型解析的詳細內(nèi)容,更多關(guān)于Swift Result類型設(shè)計的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Swift中static和class關(guān)鍵字的深入講解
這篇文章主要給大家介紹了關(guān)于Swift中static和class關(guān)鍵字的相關(guān)資料,文中通過示例代碼介紹的非常詳細,對大家的學習或者使用Java具有一定的參考學習價值,需要的朋友們下面來一起學習學習吧2019-03-03