Mutating and non-mutating Swift contexts

Swift帮助我们编写更健壮代码的方法之一是它的值类型概念,它限制了状态可以跨API边界共享的方式。这是因为,当使用值类型时,所有的mutations(默认情况下)只对我们正在处理的值的本地副本执行,并且实际执行mutations api必须清楚地标记为mutating

在本文中,让我们探讨这个关键字及nonmutating, 以及这些语言特性所提供的功能。

What can a mutating function do?

本质上,标记为mutating的函数可以更改其外围值中的任何属性。 mutating这个词在这里非常关键,因为Swift的结构化mutations概念只适用于值类型,而不适用于引用类型,如类和actor。

例如,下面的Meeting类型的cancel方法正在发生变化,因为它修改了其外围类型的state和reminderDate属性:

struct Meeting {
    var name: String
    var state: MeetingState
    var reminderDate: Date?
    ...

    mutating func cancel(withMessage message: String) {
        state = .cancelled(message: message)
        reminderDate = nil
    }
}

除了修改属性,可变上下文还可以给self分配一个全新的值,这在向枚举(不能包含任何存储的实例属性)添加一个改变方法时非常有用。 例如,这里我们创建了一个API,让添加一个操作到另一个操作变得容易:

enum Operation {
    case add(Item)
    case remove(Item)
    case update(Item)
    case group([Operation])
}

extension Operation {
    mutating func append(_ operation: Operation) {
        self = .group([self, operation])
    }
}

上面的技术也适用于其他类型的值,比如结构体,如果我们想要将一个值重设为它的默认属性集,或者如果我们想要整体地改变一个更复杂的值,这可能会非常有用——例如:

struct Canvas {
    var backgroundColor: Color?
    var foregroundColor: Color?
    var shapes = [Shape]()
    var images = [Image]()

    mutating func reset() {
        self = Canvas()
    }
}

事实上,我们可以在一个发生变化的函数中给self分配一个全新的值,这一事实最初可能看起来有点奇怪,但我们必须记住,Swift的structs实际上只是值 - 就像我们可以通过给一个Int值赋一个新数字来替换它一样,我们也可以对任何其他struct(或enum)做同样的事情。

Mutating protocol requirements

虽然区分mutatingapi和non-mutating的api的概念是值类型所特有的,但我们仍然可以将mutating函数作为协议的一部分,即使该协议最终可能被引用类型(如类)所采用。类在遵循这样的协议时可以简单地省略mutating关键字,因为它们本身是可变的。

然而,非常有趣的是,如果我们用一个默认实现的 mutating 函数来扩展一个协议,那么我们就可以实现上面的reset API,而实际上不知道我们要重置的是什么类型的值——就像这样:

protocol Resettable {
    init()
    mutating func reset()
}

extension Resettable {
    mutating func reset() {
        self = Self()
    }
}

struct Canvas: Resettable {
    var backgroundColor: Color?
    var foregroundColor: Color?
    var shapes = [Shape]()
    var images = [Image]()
}

Performing mutations within initializers

当我们想要修改值类型的内部状态时(无论是属性还是整个值本身),函数总是需要显式地标记为mutating,默认情况下初始化器总是在mutating。这意味着,除了给类型的属性赋初始值外,初始化器还可以调用突变方法来执行它的工作(只要self已经事先完全初始化)。

例如,下面ProductGroup调用自己的添加方法,以将所有的产品传递到其初始化—— 这使得我们可以使用单一的代码路径来实现逻辑,无论它被作为初始化过程的一部分运行与否:

struct ProductGroup {
    var name: String
    private(set) var products = [Product]()
    private(set) var totalPrice = 0
    
    init(name: String, products: [Product]) {
        self.name = name
        products.forEach { add($0) }
    }

    mutating func add(_ product: Product) {
        products.append(product)
        totalPrice += product.price
    }
}

Non-mutating properties

到目前为止,我们看到的所有示例都是关于可变上下文的,但Swift也提供了一种方法来标记某些上下文为明确的non-mutating 。 虽然与选择mutations相比,这样做的用例肯定更有限,但在某些情况下,它仍然是一个有用的工具。

举个例子,让我们看看这个简单的SwiftUI视图,它在每次点击按钮时都会增加@ state标记的value属性:

struct Counter: View {
    @State private var value = 0

    var body: some View {
        VStack {
            Text(String(value)).font(.largeTitle)
            Button("Increment") {
                value += 1
            }
        }
    }
}

现在,如果我们不仅仅把上面的看作一个SwiftUI View,而是看作一个标准的Swift struct(它确实是),那么我们的代码编译起来会很奇怪。 为什么我们可以在闭包中这样改变value属性,而不是在同步的,可变的上下文中调用?

如果我们再看一下State属性包装器的声明,就会更加神秘,它也是一个结构体,就像我们的视图:

@frozen @propertyWrapper public struct State<Value>: DynamicProperty {
    ...
}

那么,在基于struct的视图中使用的基于struct的属性包装器,如何能够在non-mutating的上下文中发生变化呢? 答案就在State包装器的wrappedValue的声明中,该值已经用nonmutating关键字标记:

public var wrappedValue: Value { get nonmutating set }

尽管这是我们目前为止在没有访问SwiftUI源代码的情况下所能研究的,State很可能在内部使用了某种形式的基于引用的存储, 这反过来又使得它可以选择不使用Swift的标准值mutation 语义(使用nonmutating关键字)——因为当我们赋值一个新的属性值时,State包装器本身实际上并没有被改变。

如果我们愿意,我们也可以将这个功能添加到我们自己的一些类型中。为了演示,下面的persistdflag包装器使用UserDefaults存储其底层的Bool值,这意味着当我们给它赋一个新值时(通过它的wrappedValue属性),我们实际上也没有在这里执行任何基于值的更改。因此,该属性可以被标记为nonmutating,这给了persistdflag与State相同的变异能力:

@propertyWrapper struct PersistedFlag {
    var wrappedValue: Bool {
        get {
            defaults.bool(forKey: key)
        }
        nonmutating set {
            defaults.setValue(newValue, forKey: key)
        }
    }

    var key: String
    private let defaults = UserDefaults.standard
}

因此,就像@State-marked属性一样,任何我们用@PersistedFlag标记的属性现在都可以被写入,即使是在non-mutating上下文中,比如在转义闭包中。 但是,需要注意的是,nonmutating 关键字可以让我们绕过Swift值语义的关键方面,所以它绝对应该只在非常特定的情况下使用。

Conclusion

我希望这篇文章能让您对如何区分mutating上下文和nonmutating上下文、mutating上下文实际上具有什么样的功能以及Swift相对较新的nonmutating关键字做什么有一些了解。