知道 ObjectMapper
的人大概都見過在使用 Mappable
定義的模型中 func mapping(map: Map) {}
中需要寫很多 name <- map["name"]
這樣的程式碼。這裡的 <-
將模型中的屬性跟資料中的 key
對應了起來。
Swift 提供的這種特效能夠減少很多的程式碼量,也能極大的簡化語法。在標準庫或者是我們自己定義的一些型別中,有一些只是簡單的一些基本的值型別的容器,比如說 CGRect
、CGSize
、CGPoint
這些東西。或者直接使用 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 中的 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
}
}
複製程式碼
這麼些最大的缺陷就是在遇到異常的時候,我們給呼叫者直接丟擲了比較隱晦的異常。*“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 中一個很強大的特性,它能夠幫助你很輕鬆的去構建一些解決方案。它能夠幫助我們減少在相似邏輯中的程式碼複製,讓程式碼更乾淨。但是它也可能會讓你一不小心就寫出了隱晦,閱讀不友好的程式碼。
在引入自定義操作符或者是想要過載某個操作符的時候,還是需要好好想一想利弊。從其他同事或者同行那裡尋求建議是一個非常有效的方法,新的操作符對你自己來說可能很好,但是別人看起來可能會覺得很奇怪。同其他很多的事情一樣,這其實就是一個關於權衡的話題,我們需要為每種情況選擇最合適的解決方案。