如何在 Swift 中自定義操作符
前言
很少有Swift功能能和使用自定義操作符的一樣產生如此多的激烈辯論。雖然有些人發現它們真的有用,可以降低代碼冗余,或實施輕量級語法擴展,但其他人認為應該完全避免它們。
愛它們或者恨它們 —— 無論哪種方式都有一些真正有趣的事情,我們可以與自定義操作一起做 ——無論我們是否重載現有的東西或定義自己的東西。本周,讓我們來看看可以使用自定義操作符的一些情況,以及使用它們的一些優點。
數字容器
有時我們定義了實質上只是容器的值類型其容納著更加原始的值。例如,在一個戰略游戲中,玩家可以收集兩種資源 ——木材和金幣。要在代碼中建模這些資源,我使用作為木材和金幣值的容器的 Resource 結構體,如下所示:
- struct Resources {
- var gold: Int
- var wood: Int
- }
每當我引用一組資源時,我就會使用此結構 —— 例如,要跟蹤玩家當前可用的資源:
- struct Player {
- var resources: Resources
- }
您可以在游戲中花費資源的一件事是為您的軍隊培訓新單位。執行此類動作時,我只需從當前的玩家的資源中減去該單元的金幣和木材成本:
- func trainUnit(ofKind kind: Unit.Kind) {
- let unit = Unit(kind: kind)
- board.add(unit)
- currentPlayer.resources.gold -= kind.cost.gold
- currentPlayer.resources.wood -= kind.cost.wood
- }
做到上面的完全有效,但由于游戲中有許多影響玩家資源的動作,代碼中有許多地方必須重復金幣和木頭的兩個減法。
這不僅使得很容易忘記減少其中一個值,同時它還使得引入一種新的資源類型更難(例如,銀幣),因為我必須通過查看整個代碼并更新所有處理資源的地方。
操作符重載
讓我們嘗試使用操作符重載來解決上述問題。使用大多數語言(包括Swift)的操作符時,您有都有兩個選項,重載現有運算符,或者創建一個新的運算符。重載工作就像方法重載,您可以使用新的輸入或輸出創建新版本的操作符。
在這種情況下,我們將定義-=運算符的過載,它們適用于兩個 Resources 值,如下所示:
- extension Resources {
- static func -=(lhs: inout Resources, rhs: Resources) {
- lhs.gold -= rhs.gold
- lhs.wood -= rhs.wood
- }
- }
就像遵守 Equatable 協議的時候一樣,Swift 中的操作符重載只是可以在類型上聲明的一個正常靜態函數。在此處 -= 中,操作符的左側是一個 inoiut 參數,這是我們要修改的值。
通過我們的操作符重載,我們現在可以直接在當前的玩家的資源上簡單地調用 -= ,就像我們將其放在在任何原始數值上:
- currentPlayer.resources -= kind.cost
這不僅很好閱讀,它還有助于我們消除代碼重復問題。由于我們總是希望所有外部邏輯修改完整的 Resource 實例,因此我們可以將金幣 gold 和木材 wood 屬性作為只讀屬性開放給外部其他類:
- struct Resources {
- private(set) var gold: Int
- private(set) var wood: Int
- init(gold: Int, wood: Int) {
- self.gold = gold
- self.wood = wood
- }
- }
另一種實現方法 — 可變函數
另一種我們可以解決上面的 Resources 問題的方法是使用可變函數而不是操作符重載。我們可以添加一個函數,通過另一個實例減少 Resources 值的屬性,如下所示:
- extension Resources {
- mutating func reduce(by resources: Resources) {
- gold -= resources.gold
- wood -= resources.wood
- }
- }
這兩個解決方案都有它們的優點,您可以爭辯說可變函數方法更明確。但是,您也不希望數學的標準減法API變成:5.reduce(by: 3),所以也許這是一個運算符重載表現完美的地方。
布局計算
讓我們來看看另一種方案,其中使用操作符重載可能非常好。盡管我們擁有自動布局和強大的布局API,但有時我們發現自己在某些情況下需要進行手動布局計算。
在這樣的情況下,它非常常見,必須在二維值上進行數學操作 —— 如 CGPoint,CGSize 和 CGVector。例如,我們可能需要通過使用圖像視圖的大小和一些額外的邊距來計算標簽的原點,如下所示:
- label.frame.origin = CGPoint(
- x: imageView.bounds.width + 10,
- y: imageView.bounds.height + 20
- )
如果我們可以簡單地添加它們,而不是必須始終展開 point 和 size 來使用他們的底層組件,這會不會很好(就像上面對 Resources 的操作一樣)?
為了能夠這樣做,我們可以通過重載+運算符來接受兩個 CGSize 實例作為輸入,并輸出 CGPoint 值:
- extension CGSize {
- static func +(lhs: CGSize, rhs: CGSize) -> CGPoint {
- return CGPoint(
- x: lhs.width + rhs.width,
- y: lhs.height + rhs.height
- )
- }
- }
通過上面的代碼,我們現在可以寫下我們的布局計算:
- label.frame.origin = imageView.bounds.size + CGSize(width: 10, height: 20)
這很酷,但必須為我們的位置創造 CGSize 會感到有點奇怪。使這個有點更好的一種方法可以是定義另一個 + 重載,該 + 重載接受包含兩個 CGFloat 值的元組,如下所示:
- extension CGSize {
- static func +(lhs: CGSize, rhs: (x: CGFloat, y: CGFloat)) -> CGPoint {
- return CGPoint(
- x: lhs.width + rhs.x,
- y: lhs.height + rhs.y
- )
- }
- }
這讓我們在這兩種方式中的任何一個寫下我們的布局計算:
- // 使用元組標簽:
- label.frame.origin = imageView.bounds.size + (x: 10, y: 20)
- // 或者不寫:
- label.frame.origin = imageView.bounds.size + (10, 20)
那非常緊湊,很好!但現在我們正在接近導致操作符的爭論出現的核心問題 —— 平衡冗余程度和可讀性。由于我們仍然處理數字,我認為大多數人會發現上面的易于閱讀和理解,但隨著我們繼續自定義操作符的用途,它變得更加復雜,特別是當我們開始引入全新的操作符時。
處理錯誤的自定義運算符
到目前為止,我們還只是簡單的重載了系統已經存在的操作符。但是,如果我們想開始使用無法真正映射到現有的功能的操作符,我們需要定義自己的。
讓我們來看看另一個例子。Swift 的 do,try,catch 錯誤處理機制在處理無法使用的同步操作時超級漂亮。它可以讓我們在出現錯誤后,輕松安全地退出函數。例如在加載磁盤上保存的數據模型時:
- class NoteManager {
- func loadNote(fromFileNamed fileName: String) throws -> Note {
- let file = try fileLoader.loadFile(named: fileName)
- let data = try file.read()
- let note = try Note(data: data)
- return note
- }
- }
做出像上面的唯一主要的缺點是我們直接向我們功能的調用者拋出出任何潛在的錯誤,需要減少 API 可以拋出的錯誤量,否則做有意義的錯誤處理和測試變得非常困難。
理想情況下,我們想要的是給定 API 可以拋出的有限錯誤,這樣我們就可以輕松地單獨處理每種情況。讓我們說我們也想捕捉所有潛在的錯誤,讓我們同時擁有所有好的事情。因此,我們使用顯式 cases 定義一個錯誤枚舉,每個錯誤的枚舉都使用底層錯誤的關聯值,如下所示:
- extension NoteManager {
- enum LoadingError: Error {
- case invalidFile(Error)
- case invalidData(Error)
- case decodingFailed(Error)
- }
- }
但是,捕獲潛在的錯誤并將它們轉換為自己類型是棘手的。我們必須寫下類似的標準錯誤處理機制:
- class NoteManager {
- func loadNote(fromFileNamed fileName: String) throws -> Note {
- do {
- let file = try fileLoader.loadFile(named: fileName)
- do {
- let data = try file.read()
- do {
- return try Note(data: data)
- } catch {
- throw LoadingError.decodingFailed(error)
- }
- } catch {
- throw LoadingError.invalidData(error)
- }
- } catch {
- throw LoadingError.invalidFile(error)
- }
- }
- }
我不認為有人想要閱讀像上面的代碼。一個選項是介紹一個 perform 函數,我們可以用來把一個錯誤轉換為另一個錯誤:
- class NoteManager {
- func loadNote(fromFileNamed fileName: String) throws -> Note {
- let file = try perform(fileLoader.loadFile(named: fileName),
- orThrow: LoadingError.invalidFile)
- let data = try perform(file.read(),
- orThrow: LoadingError.invalidData)
- let note = try perform(Note(data: data),
- orThrow: LoadingError.decodingFailed)
- return note
- }
- }
- func perform<T>(_ expression: @autoclosure () throws -> T,
- errorTransform: (Error) -> Error) throws -> T {
- do {
- return try expression()
- } catch {
- throw errorTransform(error)
- }
- }
更好一點了,但我們仍然有很多錯誤轉換代碼會對我們的實際邏輯造成混亂。讓我們看看引入新的操作符是否可以幫助我們清理此代碼。
添加新的操作符
我們首先定義我們的新運營商。在這種情況下,我們將選擇 〜> 作為符號(具有替代返回類型的動機,所以我們正在尋找類似于 ->)的東西。由于這是一個將在兩側工作操作符,因此我們將其定義為 infix,如下所示:
- infix operator ~>
使操作符如此強大的是它們可以自動捕捉它們兩側的上下文。將其與Swift 的 @autoclosure 功能相結合,我們可以創建一些非常酷的東西。
讓我們實現 〜> 作為傳遞表達式和轉換錯誤的操作符,拋出或返回與原始表達式相同的類型:
- func ~><T>(expression: @autoclosure () throws -> T,
- errorTransform: (Error) -> Error) throws -> T {
- do {
- return try expression()
- } catch {
- throw errorTransform(error)
- }
- }
那么上述這個操作符能夠讓我們做什么呢?由于枚舉具有關聯值的靜態函數在Swift中也是靜態函數,我們可以簡單地在我們的拋出表達式和錯誤情況之間添加〜>操作符,我們希望將任何底層錯誤轉換為如下形式:
- class NoteManager {
- func loadNote(fromFileNamed fileName: String) throws -> Note {
- let file = try fileLoader.loadFile(named: fileName) ~> LoadingError.invalidFile
- let data = try file.read() ~> LoadingError.invalidData
- let note = try Note(data: data) ~> LoadingError.decodingFailed
- return note
- }
- }
這很酷!通過使用操作符,我們已從我們的邏輯中刪除了大量的繁瑣代碼和語法,使我們的代碼更為聚焦。然而,缺點是我們引入了一個新的錯誤處理語法,這可能是任何可能在未來加入我們項目的新開發人員完全不熟悉的。
結論
自定義操作符和操作符重載是一個非常強大的功能,可以讓我們構建非常有趣的解決方案。它可以讓我們降低呈現型函數調用的冗長,這可能會給我們清潔代碼。然而,它也可以是一個滑坡,可以引導我們編寫隱秘的和難以閱讀的代碼,這對其他開發人員來說變得非常令人恐懼和混淆。
就像以更高級的方式使用第一類函數時,我認為在引入新的運算符或創建額外的重載前,需要三思而后行。從其他開發人員獲得反饋也可以超級有價值,作為一種新的操作符,對您的感覺和對別人的感覺完全不一樣。與如此多的事情一樣,理解權衡并試圖為每種情況挑選最合適的工具。
本文轉載自微信公眾號「Swift社區」,可以通過以下二維碼關注。轉載本文請聯系Swift社區公眾號。