知道 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 中一个很强大的特性,它能够帮助你很轻松的去构建一些解决方案。它能够帮助我们减少在相似逻辑中的代码复制,让代码更干净。但是它也可能会让你一不小心就写出了隐晦,阅读不友好的代码。
在引入自定义操作符或者是想要重载某个操作符的时候,还是需要好好想一想利弊。从其他同事或者同行那里寻求建议是一个非常有效的方法,新的操作符对你自己来说可能很好,但是别人看起来可能会觉得很奇怪。同其他很多的事情一样,这其实就是一个关于权衡的话题,我们需要为每种情况选择最合适的解决方案。