Swift 中关于操作符的那些事儿

image

知道 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 ~>(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 中一个很强大的特性,它能够帮助你很轻松的去构建一些解决方案。它能够帮助我们减少在相似逻辑中的代码复制,让代码更干净。但是它也可能会让你一不小心就写出了隐晦,阅读不友好的代码。

在引入自定义操作符或者是想要重载某个操作符的时候,还是需要好好想一想利弊。从其他同事或者同行那里寻求建议是一个非常有效的方法,新的操作符对你自己来说可能很好,但是别人看起来可能会觉得很奇怪。同其他很多的事情一样,这其实就是一个关于权衡的话题,我们需要为每种情况选择最合适的解决方案。

你可能感兴趣的:(Swift 中关于操作符的那些事儿)