Swift 中關於操作符的那些事兒

CepheusSun發表於2018-02-09

知道 ObjectMapper 的人大概都見過在使用 Mappable 定義的模型中 func mapping(map: Map) {} 中需要寫很多 name <- map["name"] 這樣的程式碼。這裡的 <- 將模型中的屬性跟資料中的 key 對應了起來。

Swift 提供的這種特效能夠減少很多的程式碼量,也能極大的簡化語法。在標準庫或者是我們自己定義的一些型別中,有一些只是簡單的一些基本的值型別的容器,比如說 CGRectCGSizeCGPoint 這些東西。或者直接使用 John Sundell 的文章 Custom operators in Swift 中的例子。在某個策略類遊戲中,玩家能夠收集兩種資源木材還有金幣。為了要將兩種資源模型化,定義了 Resources 這個結構體。

struct Resources {
    var gold: Int
    var wood: Int
}
複製程式碼

當然這些資源都是一個具體的玩家來使用或者賺取的。

struct Player {
    var resources: Resources
}
複製程式碼

使用者可以通過訓練軍隊來使用這些資源。當使用者訓練軍隊的時候,都需要從使用者的 resources 裡面減去對應數量的金幣還有木材。比如使用者花費10個金幣20個木材訓練了一個弓箭手(Archer)。

我們先定義弓箭手這個容器:

protocol Armyable {
    var cost: Resources { get }
}

struct Archer: Armyable {
    var cost: Resources = Resources(gold: 10, wood: 20)
}
複製程式碼

在這個例子中我們首先定義了Armyable 這個協議來描述所有的軍隊型別。當然在這個例子裡面只有訓練花費的資源也就是 cost 這一個東西。Archer 這個結構體直接定義了訓練一個弓箭手需要耗費的資源量。

現在再在 Player 這個方法裡面定義訓練軍隊的方法。

    var board: [String]
    mutating func trainArmy(_ unit: Armyable) {
        
        resources.gold -= unit.cost.gold   // line 1
        resources.wood -= unit.cost.wood   // line 2
        board.append("弓箭手")
    }
複製程式碼

首先模擬的定義了一個陣列來存放當前的軍隊。然後定義了 trainArmy 這個方法來訓練軍隊。這樣就完成了訓練軍隊這個邏輯的編碼工作。但是可能你也想到了,在這類遊戲中,有很多的情況需要操作使用者的資源,也就是說上面 line1 line2 之類的程式碼會在這個遊戲裡寫很多次。如果你覺得只是重複寫點程式碼沒什麼的話,那麼以後需要新增另外的什麼資源的時候呢?恐怕就只能在整個程式碼庫中找到所有相關的地方了。

操作符過載

這時候要是能夠用到數學符號 +- 就完美了。Swift 也替我們想到了這點。我們可以自己定義一個操作符也可以過載一個已經有了的操作符。操作符過載跟方法過載一樣。我們先過載 -= 這個符號。

extension Resources {
    static func -= (lhs: inout Resources, rhs: Resources) {
        lhs.gold -= rhs.gold
        lhs.wood -= rhs.wood
    }
}
複製程式碼

Equatable 一樣,Swift 中的操作符過載只是一個簡單的靜態方法。在 -= 這個方法裡面,左邊的引數被標記成了inout, 這個引數就是我們需要改變的值。有了 -= 這個操作符,我們現在就可以像運算元字一樣操作 resource

resources -= unit.cost
複製程式碼

這麼些不僅僅看起來或者讀起來很友好,也能夠幫助我們減少類似的程式碼到處 copy 的問題。既然現在我們可以使用外部邏輯改變 resource ,現在甚至可以把 Resource 中的屬性改成只讀的。

struct Resources {
    private(set) var gold: Int
    private(set) var wood: Int
    
    init(gold: Int, wood: Int) {
        self.gold = gold
        self.wood = wood
    }
}
複製程式碼

當然我們也可以使用 mutating 方法來做這件事情。

extension Resources {
    mutating func reduce(by resources: Resources) {
        gold -= resources.gold
        wood -= resources.wood
    }
}
複製程式碼

上面兩種方法都各有優勢,你可以說使用 mutating 方法可以讓讀者更加明確程式碼的含義。但是你肯定也不想標準庫中的減法變成 5.reduce(by: 3) 這樣的。

佈局運算中的操作符過載

還有一個場景就是剛剛提到了做 UI 佈局的時候,涉及到的 CGRect、 CGPoint 等等。在做佈局的時候經常會涉及到需要對這些值進行運算,如果能夠使用像上面那樣的方法來做這件事情不是很好的嗎?

extension CGSize {
    static func + (lhs: CGSize, rhs: CGSize) -> CGPoint {
        return CGPoint(x: lhs.width + rhs.width,
                       y: lhs.height + rhs.height)
    }
}
複製程式碼

這段程式碼,過載了 + 這個操作符,接受兩個 CGSize, 返回 CGPoint。然後就可以這樣寫了

label.frame.origin = imageView.bounds.size + CGSize(width: 10, height: 20)
複製程式碼

這樣已經很好的,但是必須要建立一個 CGSize 物件確實還不夠好。所以我們再多定義一個 + 這個操作符接受一個元組:

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)
// or
label.frame.origin = imageView.bounds.size + (10,20)
複製程式碼

知道現在我們都還在運算元字相關的東西,大多數的人都能夠很輕鬆的去理解和閱讀這些程式碼,但是如果是在涉及到一些特別的點,特別是需要引入新的操作符的時候,就需要好好去思考這樣做的必要性的。這是一個關於冗餘程式碼和可讀性程式碼的關鍵點。

作者 John Sundel 有一個庫 CGOperators 是很多關於 Core Graphics 中的類的。

異常處理中的自定義操作符

到現在,我們已經知道了如何去過載已有的操作符。有些時候我們還想要使用操作符來做一些操作,而在已經存在的操作符中找不到對應的,這種時候就需要自己去定義一個操作符了。

我們來舉個例子。 Swift 中的 dotrycatch 是非常好的異常處理機制。它讓我們能夠很安全的從發生了異常的方法裡退出,比如說下面這個從本地讀取資料的例子:

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
    }
}
複製程式碼

這麼些最大的缺陷就是在遇到異常的時候,我們給呼叫者直接丟擲了比較隱晦的異常。*“Providing a unified Swift error API” 這篇文章聊過減少一個 API 能夠丟擲異常的總量的好處。

這種情況下,我們想要的異常其實是有限的,這樣我們就能夠很輕鬆的處理每一種異常情況。但是,我們還是像捕獲到所有的異常,獲得每個異常的訊息,我們可以定義一個列舉:

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
    }
}
複製程式碼

這就好很多了,但是依然有很多異常處理相關的程式碼會干擾主邏輯。下面我們來看看引入新的操作符之後會是什麼樣的情況。

自定義操作符

我們現在來自定義一個操作符。我選擇了 ~>

infix operator ~>
複製程式碼
prefix operator  &*& {}     //定義左操作符
infix operator  ** {}       //定義中操作符
postfix operator  && {}     //定義右操作符

prefix func &*&(a: Int) -> Int { ... }
postfix func &&(a: Int) -> Int { ... }
// let c = 1&&
// let b = &*&1
// let a = 1 ** 2
複製程式碼

操作符能夠如此強大的原因在於它能夠捕獲到兩邊的上下文。結合 Swift 的 @autoclosure 特性我們就可以做一些很酷的事情了。

請我們來實現這個操作符吧!讓它接受一個能夠丟擲一場的表示式,以及一個異常轉換的表示式。返回原來的值或者是原來的異常。

func ~><T>(expression: @autoclosure () throws -> T,
           errorTransform: (Error) -> Error) throws -> T {
    do {
        return try expression()
    } catch {
        throw errorTransform(error)
    }
}
複製程式碼

這一段程式碼能夠讓我們很夠簡單的通過在操作和異常之間新增 ~> 來表達具體執行的任務以及可能遇到的異常。之前的程式碼就可以改成這樣了:

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 中一個很強大的特性,它能夠幫助你很輕鬆的去構建一些解決方案。它能夠幫助我們減少在相似邏輯中的程式碼複製,讓程式碼更乾淨。但是它也可能會讓你一不小心就寫出了隱晦,閱讀不友好的程式碼。

在引入自定義操作符或者是想要過載某個操作符的時候,還是需要好好想一想利弊。從其他同事或者同行那裡尋求建議是一個非常有效的方法,新的操作符對你自己來說可能很好,但是別人看起來可能會覺得很奇怪。同其他很多的事情一樣,這其實就是一個關於權衡的話題,我們需要為每種情況選擇最合適的解決方案。

相關文章