如何在 Swift 中自定义操作符

2021-11-26 14:52:57 浏览数 (1)

前言

很少有Swift功能能和使用自定义操作符的一样产生如此多的激烈辩论。虽然有些人发现它们真的有用,可以降低代码冗余,或实施轻量级语法扩展,但其他人认为应该完全避免它们。

爱它们或者恨它们 —— 无论哪种方式都有一些真正有趣的事情,我们可以与自定义操作一起做 ——无论我们是否重载现有的东西或定义自己的东西。本周,让我们来看看可以使用自定义操作符的一些情况,以及使用它们的一些优点。

数字容器

有时我们定义了实质上只是容器的值类型其容纳着更加原始的值。例如,在一个战略游戏中,玩家可以收集两种资源 ——木材和金币。要在代码中建模这些资源,我使用作为木材和金币值的容器的 Resource 结构体,如下所示:

代码语言:javascript复制
struct Resources {
    var gold: Int
    var wood: Int
}

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

代码语言:javascript复制
struct Player {
    var resources: Resources
}

您可以在游戏中花费资源的一件事是为您的军队培训新单位。执行此类动作时,我只需从当前的玩家的资源中减去该单元的金币和木材成本:

代码语言:javascript复制
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
}

做到上面的完全有效,但由于游戏中有许多影响玩家资源的动作,代码中有许多地方必须重复金币和木头的两个减法。

这不仅使得很容易忘记减少其中一个值,同时它还使得引入一种新的资源类型更难(例如,银币),因为我必须通过查看整个代码并更新所有处理资源的地方。

操作符重载

让我们尝试使用操作符重载来解决上述问题。使用大多数语言(包括Swift)的操作符时,您有都有两个选项,重载现有运算符,或者创建一个新的运算符。重载工作就像方法重载,您可以使用新的输入或输出创建新版本的操作符。

在这种情况下,我们将定义-=运算符的过载,它们适用于两个 Resources 值,如下所示:

代码语言:javascript复制
extension Resources {
    static func -=(lhs: inout Resources, rhs: Resources) {
        lhs.gold -= rhs.gold
        lhs.wood -= rhs.wood
    }
}

就像遵守 Equatable 协议的时候一样,Swift 中的操作符重载只是可以在类型上声明的一个正常静态函数。在此处 -= 中,操作符的左侧是一个 inoiut 参数,这是我们要修改的值。

通过我们的操作符重载,我们现在可以直接在当前的玩家的资源上简单地调用 -= ,就像我们将其放在在任何原始数值上:

代码语言:javascript复制
currentPlayer.resources -= kind.cost

这不仅很好阅读,它还有助于我们消除代码重复问题。由于我们总是希望所有外部逻辑修改完整的 Resource 实例,因此我们可以将金币 gold 和木材 wood 属性作为只读属性开放给外部其他类:

代码语言:javascript复制
struct Resources {
    private(set) var gold: Int
    private(set) var wood: Int

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

另一种实现方法 — 可变函数

另一种我们可以解决上面的 Resources 问题的方法是使用可变函数而不是操作符重载。我们可以添加一个函数,通过另一个实例减少 Resources 值的属性,如下所示:

代码语言:javascript复制
extension Resources {
    mutating func reduce(by resources: Resources) {
        gold -= resources.gold
        wood -= resources.wood
    }
}

这两个解决方案都有它们的优点,您可以争辩说可变函数方法更明确。但是,您也不希望数学的标准减法API变成:5.reduce(by: 3),所以也许这是一个运算符重载表现完美的地方。

布局计算

让我们来看看另一种方案,其中使用操作符重载可能非常好。尽管我们拥有自动布局和强大的布局API,但有时我们发现自己在某些情况下需要进行手动布局计算。

在这样的情况下,它非常常见,必须在二维值上进行数学操作 —— 如 CGPointCGSizeCGVector。例如,我们可能需要通过使用图像视图的大小和一些额外的边距来计算标签的原点,如下所示:

代码语言:javascript复制
label.frame.origin = CGPoint(
    x: imageView.bounds.width   10,
    y: imageView.bounds.height   20
)

如果我们可以简单地添加它们,而不是必须始终展开 point 和 size 来使用他们的底层组件,这会不会很好(就像上面对 Resources 的操作一样)?

为了能够这样做,我们可以通过重载 运算符来接受两个 CGSize 实例作为输入,并输出 CGPoint 值:

代码语言:javascript复制
extension CGSize {
    static func  (lhs: CGSize, rhs: CGSize) -> CGPoint {
        return CGPoint(
            x: lhs.width   rhs.width,
            y: lhs.height   rhs.height
        )
    }
}

通过上面的代码,我们现在可以写下我们的布局计算:

代码语言:javascript复制
label.frame.origin = imageView.bounds.size   CGSize(width: 10, height: 20)

这很酷,但必须为我们的位置创造 CGSize 会感到有点奇怪。使这个有点更好的一种方法可以是定义另一个 重载,该 重载接受包含两个 CGFloat 值的元组,如下所示:

代码语言:javascript复制
extension CGSize {
    static func  (lhs: CGSize, rhs: (x: CGFloat, y: CGFloat)) -> CGPoint {
        return CGPoint(
            x: lhs.width   rhs.x,
            y: lhs.height   rhs.y
        )
    }
}

这让我们在这两种方式中的任何一个写下我们的布局计算:

代码语言:javascript复制
// 使用元组标签:
label.frame.origin = imageView.bounds.size   (x: 10, y: 20)

// 或者不写:
label.frame.origin = imageView.bounds.size   (10, 20)

那非常紧凑,很好!但现在我们正在接近导致操作符的争论出现的核心问题 —— 平衡冗余程度和可读性。由于我们仍然处理数字,我认为大多数人会发现上面的易于阅读和理解,但随着我们继续自定义操作符的用途,它变得更加复杂,特别是当我们开始引入全新的操作符时。

处理错误的自定义运算符

到目前为止,我们还只是简单的重载了系统已经存在的操作符。但是,如果我们想开始使用无法真正映射到现有的功能的操作符,我们需要定义自己的。

让我们来看看另一个例子。Swift 的 do,try,catch 错误处理机制在处理无法使用的同步操作时超级漂亮。它可以让我们在出现错误后,轻松安全地退出函数。例如在加载磁盘上保存的数据模型时:

代码语言:javascript复制
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
    }
}

做出像上面的唯一主要的缺点是我们直接向我们功能的调用者抛出出任何潜在的错误,需要减少 API 可以抛出的错误量,否则做有意义的错误处理和测试变得非常困难。

理想情况下,我们想要的是给定 API 可以抛出的有限错误,这样我们就可以轻松地单独处理每种情况。让我们说我们也想捕捉所有潜在的错误,让我们同时拥有所有好的事情。因此,我们使用显式 cases 定义一个错误枚举,每个错误的枚举都使用底层错误的关联值,如下所示:

代码语言:javascript复制
extension NoteManager {
    enum LoadingError: Error {
        case invalidFile(Error)
        case invalidData(Error)
        case decodingFailed(Error)
    }
}

但是,捕获潜在的错误并将它们转换为自己类型是棘手的。我们必须写下类似的标准错误处理机制:

代码语言:javascript复制
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 函数,我们可以用来把一个错误转换为另一个错误:

代码语言:javascript复制
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
    }
}

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

更好一点了,但我们仍然有很多错误转换代码会对我们的实际逻辑造成混乱。让我们看看引入新的操作符是否可以帮助我们清理此代码。

添加新的操作符

我们首先定义我们的新运营商。在这种情况下,我们将选择 〜> 作为符号(具有替代返回类型的动机,所以我们正在寻找类似于 ->)的东西。由于这是一个将在两侧工作操作符,因此我们将其定义为 infix,如下所示:

代码语言:javascript复制
infix operator ~>

使操作符如此强大的是它们可以自动捕捉它们两侧的上下文。将其与Swift 的 @autoclosure 功能相结合,我们可以创建一些非常酷的东西。

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

代码语言:javascript复制
func ~><T>(expression: @autoclosure () throws -> T,
           errorTransform: (Error) -> Error) throws -> T {
    do {
        return try expression()
    } catch {
        throw errorTransform(error)
    }
}

那么上述这个操作符能够让我们做什么呢?由于枚举具有关联值的静态函数在Swift中也是静态函数,我们可以简单地在我们的抛出表达式和错误情况之间添加〜>操作符,我们希望将任何底层错误转换为如下形式:

代码语言:javascript复制
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
    }
}

这很酷!通过使用操作符,我们已从我们的逻辑中删除了大量的繁琐代码和语法,使我们的代码更为聚焦。然而,缺点是我们引入了一个新的错误处理语法,这可能是任何可能在未来加入我们项目的新开发人员完全不熟悉的。

结论

自定义操作符和操作符重载是一个非常强大的功能,可以让我们构建非常有趣的解决方案。它可以让我们降低呈现型函数调用的冗长,这可能会给我们清洁代码。然而,它也可以是一个滑坡,可以引导我们编写隐秘的和难以阅读的代码,这对其他开发人员来说变得非常令人恐惧和混淆。

就像以更高级的方式使用第一类函数时,我认为在引入新的运算符或创建额外的重载前,需要三思而后行。从其他开发人员获得反馈也可以超级有价值,作为一种新的操作符,对您的感觉和对别人的感觉完全不一样。与如此多的事情一样,理解权衡并试图为每种情况挑选最合适的工具。

0 人点赞