Result 還是 Result

我之前在ofollow,noindex" target="_blank">專欄文章 裡曾經發布這篇文章,由於這個話題其實還是挺重要的,可以說代表了 Swift 今後發展的方向流派,所以即使和專欄文章內容有些重複,我還是想把它再貼到部落格來。經過半年以後,自己對於這個問題也有了更多的實踐和想法,所以同時也更新了一下。我沒有直接改動原文,而是把新的想法和需要補充的說明,用類似這段話的引用的方式寫在合適的上下文裡。
開始先打個廣告
我個人經常會在數碼荔枝 用優惠價格購買面向中國使用者的一些軟體,相比於花美金直接購買,價格非常實惠。近年來國內的正版風氣和對知識智慧財產權的尊重的進步,是有目共睹的,這很大程度上也要歸功於像數碼荔枝這樣的分銷商可以和開發商討論出更適合國內的定價和銷售策略。讓我,或者讓像我一樣的開發者,能節省出一些奶粉錢和尿布錢…
最近我和數碼荔枝有一些接觸,有一些長期合作的推廣。如果大家對某款軟體有興趣,不妨先到數碼荔枝的店面找找看,也許能為你省下不少銀子。另外也可以訪問我的推廣頁面領取通用的優惠券 ,然後再使用優惠券購買任意你中意的軟體。優惠券也可以多次重複領取,任意使用。
最後,他們定期也會推出一些力度很大的半價促銷,比如 ¥94 就能買到 $79.99 的 PDF Expert 這種不可思議的事情..這個促銷到年底為止,如果有在 macOS 上看 PDF 又不滿足於系統預覽的羸弱功能和效能的小夥伴們可千萬不要錯過 。
背景知識
Cocoa API 中有很多接受回撥的非同步方法,比如URLSession
的dataTask(with:completionHandler:)
。
URLSession.shared.dataTask(with: request) { data, response, error in if error != nil { handle(error: error!) } else { handle(data: data!) } }
有些情況下,回撥方法接受的引數比較複雜,比如這裡有三個引數:(Data?, URLResponse?, Error?)
,它們都是可選值。當 session 請求成功時,Data
引數包含 response 中的資料,Error
為nil
;當發生錯誤時,則正好相反,Error
指明具體的錯誤 (由於歷史原因,它會是一個NSError
物件),Data
為nil
。
關於這個事實,dataTask(with:completionHandler:)
的文件的 Discussion 部分
有十分詳細的說明。另外,response: URLResponse?
相對複雜一些:不論是請求成功還是失敗,只要從 server 收到了response
,它就會被包含在這個變數裡。
這麼做雖然看上去無害,但其實存在改善的餘地。顯然data
和error
是互斥的:事實上是不可能存在data
和error
同時為nil
或者同時非nil
的情況的,但是編譯器卻無法靜態地確認這個事實。編譯器沒有制止我們在錯誤的if
語句中對nil
值進行解包,而這種行為將導致執行時的意外崩潰。
我們可以通過一個簡單的封裝來改進這個設計:如果你實際寫過 Swift,可能已經對Result
很熟悉了。它的思想非常簡單,用泛型將可能的返回值包裝起來,因為結果是成功或者失敗二選一,所以我們可以藉此去除不必要的可選值。
enum Result<T, E: Error> { case success(T) case failure(E) }
把它運用到URLSession
中的話,包裝一下URLSession
方法,上面呼叫可以變為:
// 如果 Result 存在於標準庫的話, // 這部分程式碼應該由標準庫的 Foundataion 擴充套件進行實現 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
當然,在回撥中對result
的處理也需要對應進行修改。
呼叫的時候看起來很棒,我們可以避免檢查可選值的情況,讓編譯器保證在對應的case
分支中有確定的非可選值。這個設計在很多存在非同步程式碼的框架中被廣泛使用,比如Swift Package Manager
,Alamofire
等中都可覓其蹤。
上面程式碼註釋中提到,「如果 Result 存在於標準庫的話,這部分程式碼應該由標準庫的 Foundataion 擴充套件進行實現」。但是考慮到原有的可選值引數 ((Data?, URLResponse?, Error?)
) 作為回撥的 API 將會共享同樣的函式名,所以上面的函式命名是不可取的,否則將導致衝突。在這類 public API 釋出後,如何改善和迭代確實是個難題。一個可行的方法是把 Foundation 的URLSession
deprecate 掉,提取出相關方法放到諸如 Network.framework 裡,並讓它跨平臺。另一種可行方案是通過自動轉換工具,強制 Swift 使用Result
的回撥,並保持 OC 中的多引數回撥。如果你正在打算使用Result
改善現有設計,並且需要考慮保持 API 的相容性時,這會是一個不小的挑戰。
錯誤型別泛型引數
如此常用的一個可以改善設計的定義,為什麼沒有存在於標準庫中呢?關於Result
,其實已經有相關的提案
:
這個提案中值得注意的地方在於,Result
的泛型型別只對成功時的值進行了型別約束,而忽略了錯誤型別。給出的Result
定義類似這樣:
enum Result<T> { case success(T) case failure(Error) }
很快,在 1 樓就有人質疑,問這樣做的意義何在,因為畢竟很多已存在的Result
實現都是包含了Error
型別約束的。確定的Error
型別也讓人在使用時多了一份“安全感”。
不過,其實我們實際類比一下 Swift 中已經存在的錯誤處理的設計。Swift 中的Error
只是一個協議,在 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 並不支援在特化時使用協議,Result<Response, Error>
這樣的型別是非法的)。這與現有的 Swift 錯誤處理機制是背道而馳的。
關於 Swift 是否應該丟擲帶有型別的錯誤,曾經存在過一段時間的爭論。最終問題歸結於,如果一個函式可以丟擲多種錯誤 (不論是該函式自身產生的錯誤,還是在函式中 try 其他函式時它們所帶來的更底層的錯誤),那麼throws
語法將會變得非常複雜且不可控 (試想極端情況下某個函式可能會丟擲數十種錯誤)。現在大家一致的看法是已有的用protocol Error
來定義錯誤的做法是可取的,而且這也編碼在了語言層級,我們對「依賴編譯器來確定try catch
會得到具體哪種錯誤」這件事,幾乎無能為力。
另外,半開玩笑地說,要是 Swift 能類似這樣extension Swift.Error: Swift.Error {}
,支援協議遵守自身協議的話,一切就很完美了,XD。
選擇哪個比較好?
兩種方式各有優缺點,特別在如果需要考慮 Cocoa 相容的情況下,更並說不上哪一個就是完勝。這裡將兩種寫法的優缺點簡單比較一下,在實踐中最好是根據專案情況進行選擇。
Result<T, E: Error>
優點
-
可以由編譯器幫助進行確定錯誤型別
當通過使用某個具體的錯誤型別擴充套件
Error
並將它設定為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
的型別已經可以被確定是UserRegisterError
,因此在failure
分支中的檢查變得相對容易。這種編譯器的型別保證給了 API 使用者相當強的信心,來從容進行錯誤處理。如果只是一個單純的
Error
型別,API 的使用者將面臨相當大的壓力,因為不翻閱文件的話,就無從知曉需要處理怎樣的錯誤,而更多的情況會是文件和事實不匹配…但是帶有型別的錯誤就相當容易了,檢視該型別的 public member 就能知道會面臨的情況了。在製作和釋出框架,以及提供給他人使用的 API 的時候,這一點非常重要。
-
按條件的協議擴充套件
使用泛型約束的另一個好處是可以方便地對某些情況的
Result
進行擴充套件。舉例來說,某些非同步操作可能永遠不會失敗,對於這些操作,我們沒有必要再使用 switch 去檢查分支情況。一個很好的例子就是
Timer
,我們設定一個在一段時間後執行的 Timer 後,如果不考慮人為取消,這個 Timer 總是可以正確執行完畢,而不會發生任何錯誤的。我們可能會選擇使用一個特定的型別來代表這種情況: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 完成。但是當回撥 block 有多個引數時,或者需要鏈式呼叫 (比如為Result
新增map
,filter
之類的支援時),類似NoError
這樣的擴充套件方式就會很有用。在 NSHipster 裡有一篇 關於
Never
的文章 ,提到使用Never
來代表無值的方式。其中就給出了一個和Result
一起使用的例子。我們只需要使extension Never: Error {}
就可以將它指定為Result<T, E: Error>
的第二個型別引數,從而去除掉程式碼中對.failure
case 的判斷。這是比NoError
更好的一種方式。當然,如果你需要一個只會失敗不會成功的
Result
的話,也可以將Never
放到第一個型別引數的位置:Result<Never, E: Error>
。
缺點
-
與 Cocoa 相容不良
由於歷史原因,Cocoa API 中表達的錯誤都是”無型別“的
NSError
的。如果你跳出 Swift 標準庫,要去使用 Cocoa 的方法 (對於在 Apple 平臺開發來說,這簡直是一定的),就不得不面臨這個問題。很多時候,你可能會被迫寫成Result<SomeValue, NSError>
的形式,這樣我們上面提到的優點幾乎就喪失殆盡了。 -
可能需要多層巢狀或者封裝
即使對於限定在 Swift 標準庫的情況來說,也有可能存在某個 API 產生若干種不同的錯誤的情況。如果想要完整地按照型別處理這些情況,我們可能會需要將錯誤巢狀起來:
// 使用者註冊可能產生的錯誤 // 當用戶註冊的請求完成且返回有效資料,但資料表明註冊失敗時觸發 enum UserRegisterError: Error { case duplicatedUsername case unsafePassword } // Server API 整體可能產生的錯誤 // 當請求成功但 response status code 不是 200 時觸發 enum APIResponseError: Error { case permissionDenied // 403 case entryNotFound// 404 case serverDied// 500 } // 所有的 API Client 可能發生的錯誤 enum APIClientError: Error { // 沒有得到響應 case requestTimeout // 得到了響應,但是 HTTP Status Code 非 200 case apiFailed(APIResponseError) // 得到了響應且為 200,但資料無法解析為期望資料 case invalidResponse(Data) // 請求和響應一切正常,但 API 的結果是失敗 (比如註冊不成功) case apiResultFailed(Error) }
上面的錯誤巢狀比較幼稚。更好的型別結構是將
UserRegisterError
和APIResponseError
定義到APIClientError
裡,另外,因為不會直接丟擲,因此沒有必要讓UserRegisterError
和APIResponseError
遵守Error
協議,它們只需要承擔說明錯誤原因的任務即可。對這幾個型別加以整理,並重新命名,現在我認為比較合理的錯誤定義如下 (為了簡短一些,我去除了註釋):
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) }
當然,如果隨著巢狀過深而縮排變多時,你也可以把內嵌的
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.") } } } } }
相信我,你不會想要寫這種程式碼的。
經過半年的實踐,事實是我發現這樣的程式碼並沒有想象中的麻煩,而它帶來的好處遠遠超過所造成的不便。
這裡程式碼中有唯一一個
as?
對UserRegisterError
的轉換,如果採用更上面引用中定義的ResultErrorReason
,則可以去除這個型別轉換,而使型別系統覆蓋到整個錯誤處理中。相較於對每個 API 都寫這樣一堆錯誤處理的程式碼,我們顯然更傾向於集中在一個地方處理這些錯誤,這在某種程度上“強迫”我們思考如何將錯誤處理的程式碼抽象化和一般化,對於減少冗餘和改善設計是有好處的。另外,在設計 API 時,我們可以提供一系列的便捷方法,來讓 API 的使用者能很快定位到某幾個特定的感興趣的錯誤,並作出處理。比如:
extension APIClientError { var isLoginRequired: Bool { if case .apiFailed(.permissionDenied) = self { return true } return false } }
用
error.isLoginRequired
即可迅速確定是否是由於使用者許可權不足,需要登入,產生的錯誤。這部分內容可以由 API 的提供者主動定義 (這樣做也起到一種指導作用,來告訴 API 使用者到底哪些錯誤是特別值得關心的),也可以由使用者在之後自行進行擴充套件。另一種”方便“的做法是使用像是
AnyError
的型別來對Error
提供封裝:struct AnyError: Error { let error: Error }
這可以把任意
Error
封裝並作為Result<Value, AnyError>
的.failure
成員進行使用。但是這時Result<T, E: Error>
中的E
幾乎就沒有意義了。Swift 中存在不少
Any
開頭的型別,比如AnyIterator
,AnyCollection
,AnyIndex
等等。這些型別起到的作用是型別抹消,有它們存在的歷史原因,但是隨著 Swift 的發展,特別是加入了 Conditional Conformance 以後,這一系列Any
型別存在的意義就變小了。使用
AnyError
來進行封裝 (或者說對具體 Error 型別進行抹消),可以讓我們丟擲任意型別的錯誤。這更多的是一種對現有 Cocoa API 的妥協。對於純 Swift 環境來說,AnyError
並不是理想中應該存在的型別。因此如果你選擇了Result<T, E: Error>
的話,我們就應該儘可能避免丟擲這種無型別的錯誤。那問題就回到了,對於 Cocoa API 丟擲的錯誤 (也就是以前的
NSError
),我們應該怎樣處理?一種方式是按照文件進行封裝,比如將所有NSURLSessionError
歸類到一個URLSessionErrorReason
,然後把從 Cocoa 得到的NSError
作為關聯值傳遞給使用者;另一種方式是在丟擲給 API 使用者之前,在內部就對這個 Cocoa 錯誤進行“消化”,將它轉換為有意義的特定的某個已經存在的 Error Reason。後者雖然減輕了 API 使用者的壓力,但是勢必會丟失一些資訊,所以如果沒有特別理由的話,第一種的做法可能更加合適。 -
錯誤處理的 API 相容存在風險
現在來說,為 enum 新增一個 case 的操作是無法做到 API 相容的。使用側如果枚舉了所有的 case 進行處理的話,在 case 增加時,原來的程式碼將無法編譯。(不過對於錯誤處理來說,這倒可能對強制開發者對應錯誤情況是一種督促 233..)
如果一個框架或者一套 API 嚴格遵守semantic version 的話,這意味著一個大版本的更新。但是其實我們都心知肚明,增加一個之前可能忽略了的錯誤情況,卻帶來一個大版本更新,帶來的麻煩顯然得不償失。
Swift 社群現在對於增加 enum case 時如何保持 API compatibility 也有一個成熟而且已經被接受了的提案 。將 enum 定義為
frozen
和nonFrozen
,並對nonFrozen
的 enum 使用unknown
關鍵字來保證原始碼相容。我們在下個版本的 Swift 中應該就可以使用這個特性了。
Result
不帶Error
型別的優缺點正好和上面相反。
相對於Result<T, E: Error>
,Result<T>
不在外部對錯誤型別提出任何限制,API 的建立者可以擺脫AnyError
,直接將任意的Error
作為.failure
值使用。
但同時很明顯,相對的,一個最重要的特性缺失就是我們無法針對錯誤型別的特點為Result
進行擴充套件了。
結論
因為 Swift 並沒有提供使用協議型別作為泛型中特化的具體型別的支援,這導致在 API 的強型別嚴謹性和靈活性上無法取得兩端都完美的做法。硬要對比的話,可能Result<T, E: Error>
對使用者更加友好一些,因為它提供了一個定義錯誤型別的機會。但是相對地,如果建立者沒有掌握好錯誤型別的程度,而將多層巢狀的錯誤傳遞時,反而會增加使用者的負擔。同時,由於錯誤型別被限定,導致 API 的變更要比只定義了結果型別的Result<T>
困難得多。
不過Result
暫時看起來不太可能被新增到標準庫中,因為它背後存在一個更大的協程和整個語言的非同步模型該如何處理錯誤的話題。在有更多的實踐和討論之前,如果沒有革命性和語言創新的話,對如何進行處理的話題,恐怕很難達成完美的共識。
結論:錯誤處理真的是一件相當艱難的事情。
最近這半年,在不同專案裡,我對Result<T, E: Error>
和Result<T>
兩種方式都進行了一些嘗試。現在看來,我會更多地選擇帶有錯誤型別的Result<T, E: Error>
的形式,特別是在開發框架或者需要嚴謹的錯誤處理的時候。將框架中可能丟擲的錯誤進行統一封裝,可以很大程度上減輕使用者的壓力,讓錯誤處理的程式碼更加健壯。如果設計得當,它也能提供更好的擴充套件性。
October 31, 2018 at 10:38AM via OneV’s Den https://ift.tt/2CUH29l
Rating: 0.0/5 (0 votes cast)
Rating:0 (from 0 votes)