Swift中自定義運算子

大雄45發表於2021-07-20
導讀 自定義運算子和運算子過載是一個非常強大的功能,可以讓我們構建非常有趣的解決方案。它可以讓我們降低呈現型函式呼叫的冗長,這可能會給我們清潔程式碼
前言

很少有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(_ expression: @autoclosure () throws -> T, 
                errorTransform: (Error) -> Error) throws -> T { 
    do { 
        return try expression() 
    } catch { 
        throw errorTransform(error) 
    } 
}

更好一點了,但我們仍然有很多錯誤轉換程式碼會對我們的實際邏輯造成混亂。讓我們看看引入新的運算子是否可以幫助我們清理此程式碼。

新增新的運算子

我們首先定義我們的新運營商。在這種情況下,我們將選擇 〜> 作為符號(具有替代返回型別的動機,所以我們正在尋找類似於 ->)的東西。由於這是一個將在兩側工作運算子,因此我們將其定義為 infix,如下所示:

infix operator ~>

使運算子如此強大的是它們可以自動捕捉它們兩側的上下文。將其與Swift 的 @autoclosure 功能相結合,我們可以建立一些非常酷的東西。

讓我們實現 〜> 作為傳遞表示式和轉換錯誤的運算子,丟擲或返回與原始表示式相同的型別:

func ~>(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 
    } 
}

這很酷!透過使用運算子,我們已從我們的邏輯中刪除了大量的繁瑣程式碼和語法,使我們的程式碼更為聚焦。然而,缺點是我們引入了一個新的錯誤處理語法,這可能是任何可能在未來加入我們專案的新開發人員完全不熟悉的。

結論

自定義運算子和運算子過載是一個非常強大的功能,可以讓我們構建非常有趣的解決方案。它可以讓我們降低呈現型函式呼叫的冗長,這可能會給我們清潔程式碼。然而,它也可以是一個滑坡,可以引導我們編寫隱秘的和難以閱讀的程式碼,這對其他開發人員來說變得非常令人恐懼和混淆。

就像以更高階的方式使用第一類函式時,我認為在引入新的運算子或建立額外的過載前,需要三思而後行。從其他開發人員獲得反饋也可以超級有價值,作為一種新的運算子,對您的感覺和對別人的感覺完全不一樣。與如此多的事情一樣,理解權衡並試圖為每種情況挑選最合適的工具。

原文來自: https://www.linuxprobe.com/custom-operators-in-swift.html

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69955379/viewspace-2782180/,如需轉載,請註明出處,否則將追究法律責任。

相關文章