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