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?
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)
}
}
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.
Okay, that makes sense. With that included, the observer would have to be synthesised like this:
If Foo is Equatable or inherits from NSObject, synthesise a == check in the didChange body.
If Foo is not Equatable and is a reference type, synthesise a === check in the didChange body.
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)
}
}
is redundant, since NSObject conforms to Equatable, so the first rule can really just be:
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!