很少有Swift功能像使用自定义操作符那样引发如此激烈的争论。有些人认为它们对于减少代码冗长或实现轻量级语法扩展非常有用,而另一些人则认为应该完全避免使用它们。

喜欢或讨厌它们——无论哪种方式,我们都可以用自定义操作符做一些非常有趣的事情-我们是重载现有的还是定义我们自己的。本周,让我们来看看一些可以使用自定义操作符的情况,以及使用它们的优缺点。

Numeric containers

有时我们定义的值类型实际上只是其他更原始值的容器。例如,在我正在开发的一款战略游戏中,玩家可以收集两种资源——木材和金子。为了在代码中建模这些资源,我使用了一个资源结构,作为一对木材和黄金价值的容器,像这样:

struct Resources {
    var gold: Int
    var wood: Int
}

每当我引用一组资源时,我就会使用这个结构——例如,跟踪一个玩家当前可用的资源:

struct Player {
    var resources: Resources
}

你可以在游戏中投入资源的一件事就是为你的军队训练新的单位。 当执行这一操作时,我只需从当前玩家的资源中减去该单位的黄金和木材成本:

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
}

上述做法完全可行,但因为游戏中有许多行动会影响玩家的资源,所以代码库中有许多地方需要重复使用黄金和木材的减法。这不仅会让我们很容易漏掉这些值的减法,还会让我们很难引入新的资源类型(比如silver),因为我必须遍历整个代码库,更新所有处理资源的地方。

Operator overloading

让我们尝试使用操作符重载来解决上述问题。在使用大多数语言(包括Swift)中的操作符时,您有两种选择。 要么重载现有的操作符,要么创建一个新的操作符。重载的工作方式与方法重载类似,因为您可以创建具有新输入或输出的操作符的新版本。

在本例中,我们将定义-=操作符的重载,它作用于两个资源值,如下所示:

extension Resources {
    static func -=(lhs: inout Resources, rhs: Resources) {
        lhs.gold -= rhs.gold
        lhs.wood -= rhs.wood
    }
}

就像符合Equatable一样,Swift中的操作符重载只是可以在类型上声明的普通静态函数。 在-=的情况下,操作符的左边是一个inout形参,它是我们要修改的值。

操作符重载到位后,我们现在可以直接在当前玩家的资源上调用-=,就像我们在任何原始数值上所做的那样:

currentPlayer.resources -= kind.cost

这不仅读起来很好,还帮助我们消除了代码重复的问题。因为我们总是希望所有外部逻辑作为一个整体来改变资源实例,所以我们可以继续,让所有其他类型的gold和wood属性为只读:

struct Resources {
    private(set) var gold: Int
    private(set) var wood: Int

    init(gold: Int, wood: Int) {
        self.gold = gold
        self.wood = wood
    }
}
```swift

上述工作归功于Swift 4的改变,它赋予了在相同文件中定义的扩展名私有特权。 因此,我们的-=操作符重载(以及我们为资源定义的任何其他操作符或api)可以改变属性,而不需要它们是公开可变的。. Pretty sweet 👍!

## Mutating functions as an alternative

解决上述资源问题的另一种方法是使用变异函数,而不是操作符重载。我们可以添加一个函数,通过另一个实例减少资源值的属性,像这样:
```swift
extension Resources {
    mutating func reduce(by resources: Resources) {
        gold -= resources.gold
        wood -= resources.wood
    }
}

这两种解决方案都有各自的优点,可以认为突变函数方法更明确。 然而,您也不希望数字的标准减法API是5.reduce(by: 3) 之类的,因此,在这种情况下,重载操作符可能是很有意义的。

Layout calculations

让我们看看另一个场景,在这个场景中,使用操作符重载可能会很好。尽管我们有自动布局和强大的布局锚点API,有时候,我们会发现自己需要手动进行布局计算。

在这种情况下,必须对二维值(如CGPoint、CGSize和CGVector)进行计算是很常见的。例如,我们可能需要通过使用图像视图的大小和一些额外的边距来计算标签的起源,如下所示:

label.frame.origin = CGPoint(
    x: imageView.bounds.width + 10,
    y: imageView.bounds.height + 20
)

如果我们可以简单地将它们加起来(就像我们对资源结构所做的那样),而不是总是需要扩展点和大小来使用它们的底层组件,这不是很好吗?🤔

要做到这一点,我们可以重载+操作符,以接受两个CGSize实例作为输入,并输出一个CGPoint值:

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

有了上面的内容,我们现在可以这样写我们的布局计算:

label.frame.origin = imageView.bounds.size + CGSize(width: 10, height: 20)

这很酷,但是要为页边距创建CGSize有点奇怪。一种更好的方法是定义另一个+重载,它接受一个size和一个包含两个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
        )
    }
}

让我们用这两种方式来写布局计算

// Using a tuple with labels:
label.frame.origin = imageView.bounds.size + (x: 10, y: 20)

// Or without:
label.frame.origin = imageView.bounds.size + (10, 20)

非常紧凑和漂亮!👍但是现在我们正在接近引起这么多关于操作符争论的核心问题——平衡冗长和可读性。因为我们仍然在和数字打交道,我想大多数人会发现上面的内容很容易读懂,但随着我们转向更多的自定义使用,尤其是当我们开始引入全新的运营商时,情况就会变得更加复杂。

要了解更多核心图形类型的操作符重载,请查看CGOperators

A custom operator for error handling

到目前为止,我们只是向现有的操作符添加了重载。但是,如果我们想开始为不能真正映射到现有操作符的功能使用操作符,我们需要定义自己的操作符。

让我们看另一个例子。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
    }
}

执行上述操作的唯一主要缺点是,我们将直接向函数的调用者抛出任何潜在错误。 就像我在第一篇博客文章中写到的那样——“提供一个统一的Swift错误API”——减少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)
        }
    }
}

我认为没有人想读上面的代码😅。一种选择是引入一个执行函数(就像我在上面提到的帖子中所做的那样),我们可以使用它来将一个错误转换成另一个错误:

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
    }
}

更好了,但是我们仍然有很多错误转换代码混淆了我们的实际逻辑。让我们看看引入一个新的运算符是否能帮助我们清理这段代码。

Adding a new operator

我们将从定义新运算符开始。在本例中,我们将选择~>; 作为符号(动机是这是一个替代返回类型,所以我们正在寻找类似于->;)由于这是一个可以在两边工作的操作符,我们将其定义为中缀,如下所示:

infix operator ~>

操作符之所以如此强大,是因为它们可以自动捕捉到双方的上下文。把它和Swift的@autoclosure功能结合起来,我们可以为自己打造一些非常酷的东西。

让我们实现~ >;作为操作符,接受抛出表达式和错误转换,并抛出或返回与原始表达式相同的类型:

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

那么上面的例子让我们做什么呢? 由于带关联值的枚举case在Swift中也是静态函数,我们可以简单地添加~>; 在抛出表达式和我们希望将任何潜在错误转换为的错误情况之间的操作符,如下所示:

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
    }
}

这很酷!🎉通过使用运算符,我们从逻辑中删除了大量的“混乱”和语法,使我们的代码更加集中。然而,缺点是我们引入了一种新的错误处理语法,这对于未来可能加入我们项目的新开发人员来说可能是完全陌生的。

Conclusion

自定义操作符和操作符重载是一个非常强大的特性,它可以让我们构建真正有趣的解决方案。它可以让我们在不需要嵌套函数调用的情况下减少冗长,这可能会给我们更清晰的代码。 然而,它也可能导致我们写出晦涩难懂的代码,让其他开发人员感到害怕和困惑。

就像以更高级的方式使用第一类函数一样,我认为在引入新操作符或创建额外重载之前,三思是很重要的。 从其他开发者那里获得反馈也非常有价值,因为一个对你来说完全有意义的新运营商可能会让别人觉得完全陌生。和很多事情一样,我们需要理解权衡,并为每种情况选择最合适的工具。

原文链接