Observation optimizes away unnecessary callbacks for Equatable properties… sometimes

It seems like changing a property in an @Observable class triggers observation callbacks, but that is optimized away the value does not actually change and it is of an Equatable type. This makes a lot of sense and is a nice optimization.

But it seems like this optimization does not always work (edit: for value types like structs).

struct WrappedString: Equatable {
    var value: String
}

@Observable
final class Model {

    var wrappedName: WrappedString

    init(name: String) {
        self.wrappedName = .init(value: name)
    }
}

Specifically, assigning the whole WrappedString value does not trigger a callback (i.e. this uses the optimization):

model.wrappedName = .init(value: sameNameAsBefore)

…but assigning something inside the WrappedString value does trigger the callback (i.e. no optimization):

model.wrappedName.value = sameNameAsBefore

My guess would be that some kind of optimization that takes advantage of Equatable is bypassed when not assigning the whole property value, but by assigning something inside it. Maybe the @ObservationTracked macro uses the new mutate accesssor instead of using get+set and only set has that optimization?

I looked in the Swift Observation macro source code and I think the comment above this implementation of the generated _modify accessor explains the reason, right?

// Note: this accessor cannot test the equality since it would incur
// additional CoW's on structural types. Most mutations in-place do
// not leave the value equal so this is "fine"-ish.
// Warning to future maintence: adding equality checks here can make
// container mutation O(N) instead of O(1).
// e.g. observable.array.append(element) should just emit a change
// to the new array, and NOT cause a copy of each element of the
// array to an entirely new array.
let modifyAccessor: AccessorDeclSyntax =
  """
  _modify {
    access(keyPath: \\.\(identifier))
    \(raw: ObservableMacro.registrarVariableName).willSet(self, keyPath: \\.\(identifier))
    defer { \(raw: ObservableMacro.registrarVariableName).didSet(self, keyPath: \\.\(identifier)) }
    yield &_\(identifier)
  }
  """

I saw a previous thread and related PR (closed but unmerged), but those talk about implementing this kind of optimization, not this specific issue with it.

It would be nice if somebody could chime in on whether this is something that might be further optimized in the future. I can post more code to show this issue if necessary.

1 Like

What you are asking for does work as long as you are using reference types all the way down:

@Observable class ModelA {
  var modelB = ModelB()
}
@Observable class ModelB {
  var value = 0
}

Then an access like this:

modelA.modelB.value

…would trigger observation only if value is changed, or if modelB is wholly replaced by a new ModelB object. But mutations to other fields in modelB would not cause observation to be triggered.

It is not currently possible to extend this kind of behavior to value types, if that is what you are looking for. The PR you linked to does not try to solve this problem, and instead is a precursor to the functionality that skips observation when values don't change (see PR #78151).

2 Likes

Thanks for the reply! Yes, I was asking about this behavior in the context of value types – sorry for not being clearer about this, I’ll edit my original post.

I don’t have a problem with this behavior in the code I’m working on. We had equality checks in place before assigning properties on an @Observable type to avoid unnecessary observations and when checking whether this is actually necessary, I noticed that it seems to behave inconsistently – now there’s an explanation :slightly_smiling_face:

Do you have any further information on why this is currently not possible? Thanks!

There was quite a bit of back-and-forth in the original review, starting around here and ending here.

Thanks, much appreciated!