didChange observer

SwiftUI seems to have gained a new onChange function that lets you execute code when a given value changes.

I think this should be more generally available as a language feature, in the form of a didChange observer. Does this seem like a good idea to anyone?

5 Likes

It’s definitely a common pattern for me to write

var someProperty: Foo {
    didSet {
        if someProperty != oldValue {
            // ...
        }
    }
}

My main concern would be that didChange is easily confusable with didSet, so it would potentially make code less readable (particularly for beginners). Although the vanilla didSet version is more verbose, it’s very clear what’s going on.

It would also only be available on certain types—would we want the same Equatable constraint as onChange? Should it be available for all reference types vía ===? Would a different (or additional) constraint like Identifiable make sense? Could these constraints be explained in a way that doesn’t make the complexity of the feature for beginners outweigh its (debatable) utility?

1 Like

Yeah, I think it would make sense for it to be only available on Equatable values. I haven't thought of expanding it to all reference types via ===, it's an interesting idea, but I think it would be simpler to just limit it to Equatable (do you have any example where using === would be useful?):

var value1: NonEquatableType = foo {
  didChange {  // ❌ error: using 'didChange' observer on 'value1' requires 'NonEquatableType' to conform to 'Equatable'
    print(newValue)
  }
}

var value2: EquatableType = bar {
  didChange { âś…
    print(newValue)
  }
}
3 Likes

The utility of the === variant is somewhat mitigated by the fact that NSObject provides an Equatable conformance based on ===, but IMO the motivation for this feature with reference types is quite similar to the motivation for value types—you want to trigger an update only when the identity of an object changes:

struct Foo { ... }
protocol FooProviderDelegate: AnyObject { ... }

class FooProvider {
    var latestFoo: Foo
    weak var delegate: FooProviderDelegate

    // ...
}

class Bar {
    var provider: FooProvider {
        didChange {
            self.provider.delegate = self
            self.processFoo(self.provider.latestFoo)
        }
    }
}

If external actors to Bar can update provider, we may not want to re-process latestFoo if two separate callers give us the same FooProvider instance, nor do we need to update the delegate.

2 Likes

Okay, that makes sense. With that included, the observer would have to be synthesised like this:

  1. If Foo is Equatable or inherits from NSObject, synthesise a == check in the didChange body.
  2. If Foo is not Equatable and is a reference type, synthesise a === check in the didChange body.
  3. Else, emit an error diagnostic.
var foo1: AnEquatableType {  // 1 (a)
  didChange(oldValue, newValue) {
    // synthesised code
    if oldValue == newValue { return }
    // user code
    doSomethingWithChangedValue(newValue)
  }
}
var foo2: TypeThatInheritsFromNSObject {  // 1 (b)
  didChange(oldValue, newValue) {
    // synthesised code
    if oldValue == newValue { return }
    // user code
    doSomethingWithChangedValue(newValue)
  }
}
var foo1: JustASimpleSwiftClassType {  // 2
  didChange(oldValue, newValue) {
    // synthesised code
    if oldValue === newValue { return }
    // user code
    doSomethingWithChangedValue(newValue)
  }
}
var foo4: NonEquatableValueType { // ❌ error: using 'didChange' observer on 'foo4' requires 'NonEquatableValueType' to conform to 'Equatable' or be a class or class-constrained type.
  didChange(oldValue, newValue) {
    // user code
    doSomethingWithChangedValue(newValue)
  }
}
// Slightly different diagnostic for protocols
var foo5: NonEquatableExistentialType { // ❌ error: using 'didChange' observer on 'foo4' requires 'NonEquatableExistentialType' to inherit from 'Equatable' or a reference type.
  didChange(oldValue, newValue) {
    // user code
    doSomethingWithChangedValue(newValue)
  }
}
1 Like

That all seems reasonable to me. I think that

is redundant, since NSObject conforms to Equatable, so the first rule can really just be:

  1. If Foo is Equatable, synthesize a == check in the didChange body.

That leaves us with pretty straightforward, explainable semantics for the synthesis/behavior of the didChange observer.

A couple other thoughts/questions:

  • Why does didChange get oldValue and newValue? Shouldn't oldValue be enough?
  • Should we also provide the willChange complement to didChange?
  • We use the "class-bound" terminology when e.g., applying weak to a non-class-bound protocol, I think it makes sense to utilize it for the protocol diagnostic, rather than talking about "inherit[ing] from a reference type".

Other than that, I still have concerns about whether the difference between didChange and didSet is too subtle (both feature-name-wise and actual-semantics-wise), but that's something that should be determined from broader community feedback!

1 Like