Calling a mutating func mutates even if actual func is a nop

Given the following:

import Foundation

struct Person {
    mutating func nopSometimes(_ val: Int) {
        if val == 3 { return }
        // Actually mutate down below
    }
}

class Test {
    var person = Person() {
        didSet {
            print("person did set")
        }
    }

    init() {}

    func asyncCall() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
            // "person did set" happens due to this call...
            // Why when it's a nop?
            self.person.nopSometimes(3)
        }
    }
}

var g = Test()
g.asyncCall()

Why does calling nopSometimes() still mutate the struct and is there a way to keep nop() as mutating but not cause the mutation to happen in this example?

I assume that simply calling a mutating func is enough for Swift to generate a new struct to be applied to the underlying value even if nothing happens but I'm curious as to why / more details and if it can be worked around.

1 Like

mutating in Swift is essentially the same as inout but for self, i.e. it means that self has copy-in/copy-out semantics. (This doesn’t mean that the value will actually be copied, just that it will behave as if it was.) So didSet and willSet will always be fired whether the function actually mutates self or not.

If you only need to perform some work when the value has actually changed, you can always check the value in the didSet:

var person = Person() {
    didSet {
        if person != oldValue {
            print("person did change")
        }
    }
}

(Of course, that requires that the value is Equatable, so it may or may not work in your case.)

4 Likes

The didSet observer is not a didChange observer, that is, it will be called regardless whenever the variable is set, even if nothing has changed. Since mutating functions formally act something like:

var temp = self.person
temp.nopSometimes(3)
self.person = temp

there will always be a set to self.person when you call nopSometimes.

To detect whether there's an actual change, you can conform Person to Equatable (to give the struct a notion of what constitutes the "same" value) and then bail out of the didSet early if self.person == oldValue.

ETA: beaten to the punch by @hisekaldma :slight_smile:

2 Likes

As per #3 below, isn't self always copied out? Here

(This doesn’t mean that the value will actually be copied, just that it will behave as if it was.)

you seem to imply its not always copied out? If it's always copied out, no matter what the mutating func does, then it does makes sense to me why didSet/willSet are always called.

“In-Out Parameters
In-out parameters are passed as follows:

  1. When the function is called, the value of the argument is copied.

  2. In the body of the function, the copy is modified.

  3. When the function returns, the copy’s value is assigned to the original argument.”

Excerpt From
The Swift Programming Language (Swift 5.5)
Apple Inc.

I believe that the distinction @hisekaldma is making is between the formal semantics of the Swift language (which, as you note, specify that the behavior is always copy-in, copy-out) and whether there will actually be a memory-level copy happening in the compiled binary.

The compiler/optimizer is free to eliminate formal copies as long as the program behaves as if the copy were happening. That means that in situations where, e.g., there are no property observers, or where the didSet doesn't reference oldValue, the compiler might perform the mutation in place since there's no difference in behavior as far as the program is concerned.

3 Likes

Got it, regardless of whether the memory-level copy happens though the willSet/didSet observers do get called, right?

2 Likes

Yes, if there are property observers then they should always be called when a mutating func is applied.

1 Like

So now that we have that answered, time for the more expanded problem :slight_smile: .

Given the following code:

import Foundation

protocol Updatable { 
    mutating func update(from new: Self)
}

class Person: Updatable, Equatable { 
    @Published var age: Int
    
    init(age: Int) { 
        self.age = age
    }
    
    static func == (lhs: Person, rhs: Person) -> Bool { 
        lhs.age == rhs.age
    }
    
    func update(from new: Person) {
        if age != new.age { age = new.age }
    }
}

extension Optional: Updatable where Wrapped: Updatable & Equatable {
    mutating func update(from new: Self) {
        // Nop when the value being set is equal to what we have.
        // Copy in / Copy out semantics means that didSet will get called for this case though.
        guard self != new else { return }
        
        // Actually mutate down below
        switch (self, new) {
            case (.some, .none):
            self = nil
            case let (.none, .some(_new)):
            self = _new
            case let (.some, .some(_new)):
            self?.update(from: _new)
            default: break
        }
    }
}

class Test {
    @Published var person: Person? {
        didSet {
            print("person did set")
        }
    }

    init() {
       self.person = Person(age: 3)
    }

    /// A call that simulates fetching or being called with a new person object from some db in which
    /// we want to update our current person object and trigger callbacks to all observers of our current
    /// person. update() is used instead of simply replacing the entire reference so that observers of
    // person and person.age get callbacks when we get the new Person instance in this call.
    func asyncCall() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
            let newPerson = Person(age: 3)
            // "person did set" happens due to this call... 
            // We know why :) but it also means our person observers will get unnecessary callbacks
            self.person.update(from: newPerson)
        }
    }
}

var g = Test()
g.asyncCall()

We already established that "person did set" will get called even for the cases in which Test.person.update is doing a nop (is not truly being updated with a different value).

I want to avoid "person did set" getting called because now that also means that subscribers to Test.person get unnecessary callbacks due didSet being called and copy-in copy-out semantics of mutating func.

How hard would it be to write a custom @propertyWrapper that basically does everything @Published does but with two differences:

  1. It never calls subscribers if the value being set is equal to the oldValue.
  2. It always calls on didSet instead of willSet. <--- I'm ok if this can't be done or is too difficult.

I obviously haven't tried but wonder how I would go about grabbing most of the implementation of @Published without its source code.

Making it do ALL of the things @Published does (specifically the interaction with ObservableObject automatic objectWillChange) won't fully be possible to replicate. However, you can make a property wrapper that does use a CurrentValueSubject and offer out a projected value of that subject w/ a removeDuplicates tacked on it.

import Combine

@propertyWrapper
public struct ChangeTracked<T: Equatable> {
  let subject: CurrentValueSubject<T, Never>
  
  public init(wrappedValue: T) {
    subject = CurrentValueSubject(wrappedValue)
  }
  
  public var wrappedValue: T {
    get { subject.value }
    set { subject.value = newValue }
  }
  
  public var projectedValue: AnyPublisher<T, Never> {
    subject.removeDuplicates().eraseToAnyPublisher()
  }
}

I think this will work great! I was thinking of writing something pretty much just like this conceptually lol just haven't played with writing any property wrappers before.

Ok So I'm getting Thread 1: Simultaneous accesses to 0x28382c0c0, but modification requires exclusive access on

set { subject.value = newValue }

huh that is odd... do you have an example of where that fails? CurrentValueSubject's value is definitely guarded to be exclusive via a lock.

Having a hard time reproducing that in a sample project. 100% reproducible in actual project :slight_smile:

So.. Found this: Mimic Swift Combine @Published to create @PublishedAppStorage - Stack Overflow

And setting both the getter/setters on @ChangedTracked to nonmutating gets rid of the Simultaneous accesses errors. So far so good for now.