Unexpected behavior with property wrappers and willSet/didSet

The proposal for property wrappers offers only this for explaining how they are intended to interact with willSet and didSet:

Those properties can have observing accessors (willSet /didSet ), but not explicitly-written getters or setters.

But since property wrappers and meant to blur the line between a public facing stored property and an underlying, hidden wrapper type, things get very murky with willSet/didSet.

Consider the following very simple property wrapper:

@propertyWrapper
struct Wrap<Value> {
  var wrappedValue: Value
  init(wrappedValue: Value) {
    self.wrappedValue = wrappedValue
  }
}

If one were to use it with didSet like so:

@Wrap var value = 0 {
  didSet { print("didSet") }
}

…then the didSet is called as expected. You can see this with the following test:

import Testing
@Test func accessors() async throws {
  @Wrap var count = 0 {
    didSet { Issue.record() }
  }

  withKnownIssue {
    count = 1  // ✅ Passes
  }
}

That proves that the didSet is called when mutating the count variable directly.

But there are other ways to mutate count. You can also go through _count.wrappedValue, and doing so does not trigger didSet:

withKnownIssue {  // ❌ Fails
  _count.wrappedValue = 1
}

It seems reasonable to me that any mutation of wrappedValue would trigger the observers on count.

This discrepancy in behavior becomes more problematic when dealing with projected values that may have mutating functions:

$count.compute()

If that compute method mutates the wrappedValue it will not trigger didSet.

This problem also affects SwiftUI, in which bindings to @State do not trigger didSet:

struct CounterView: View {
  @State var count = 0 {
    didSet { print("This will not be called") }
  }
  var body: some View {
    Form {
      Stepper("\(count)", value: $count)
    }
  }
}

Any change made to count via the binding will not trigger didSet.

Is any of this behavior documented somewhere? There is no mention of this in the proposal. And is any of the behavior surprising or possibly considered a bug? It is a source of a lot of confusion to want to tap into the didSet of a property wrapper field and find that only under certain circumstances will it actually trigger.

6 Likes

this is an interesting question... the supported/intended interactions between property wrappers and observing accessors do appear to be rather vague to me. the example from the evolution document gives the following description of how the wrapper 'expansion' works:

@Lazy var foo = 1738

// translates to

private var _foo: Lazy<Int> = Lazy<Int>(wrappedValue: 1738)
var foo: Int {
  get { return _foo.wrappedValue }
  set { _foo.wrappedValue = newValue }
}

but the fact that you can include an observing accessor on the wrapped property declaration means that the use of a property wrapper enables functionality that cannot be re-created by simply 'desugaring' the wrapper annotation itself. e.g. if we explicitly use the codegen pattern that the wrapper expansion is said to provide on the given example, it is rejected:

var _value: Wrap<Int> = Wrap<Int>(wrappedValue: 0)
var value: Int {
  get { _value.wrappedValue }
  set { _value.wrappedValue = newValue }
  didSet { print("didSet") }  // 🛑 'didSet' cannot be provided together with a getter
}

so it seems that in this manner, 'wrapped properties' are something of a hybrid between computed properties, which don't admit observing accessors, and stored properties, which do. if we think of these as essentially just slightly-special computed properties, then the current behavior/limitations perhaps makes more sense, but i can see arguments in both directions.

cc @John_McCall – since accessors have presumably been on your mind recently, do you have any insights to share on this matter?

The accessors vision describes the semantics of the observing accessors in terms of an arbitrary underlying implementation. I believe everything in that section still makes sense when that implementation comes from a property wrapper. If the implementation deviates from that, it’s arguably a bug.

Hi @John_McCall, I read the "Observing accessors" section of the accessors vision document, but it's still not clear to me that the code I have shared above would be considered expected behavior or a bug with that vision.

The crux of the problem is that property wrappers blur the line of what is actually the stored property. When you have @Wrap var count it makes you think that count is the stored property, but really it is computed and instead _count is the stored property. And so from that perspective it may be surprising that observing accessors are allowed on property wrapper fields at all since willSet/didSet are not allowed on computed properties. But, alas, they are, and so their behavior needs more explanation.

So the question is, should didSet be invoked in these two cases:

@Wrap var count = 0 { didSet { print("didSet") } }
count = 1
_count.wrappedValue = 2

?

It does in the first mutation, but not the second, even though count = 1 is just sugar for _count.wrappedValue = 1.

To me it makes the most sense for the desugaring process to look like this:

@Wrap var count = 0 { 
  didSet { print("didSet") }
}

// desugars to:

var _count = Wrap(wrappedValue: 0) { 
  didSet { print("didSet") }
}
var count: Int {
  get { _count.wrappedValue }
  set { _count.wrappedValue = newValue }
}

While the didSet is defined on the @Wrap var count, when it desugars it is placed on the var _count property. If that was the desugaring process then everything would work the way I expect. Mutating either count or _count.wrappedValue will cause didSet to be called.

1 Like

I don't think it's as straightforward as that. Wrap could be a class or its wrappedValue could have a nonmutating set as is the case for @State in SwiftUI. In neither of those cases would didSet be called.

The way you seem to be thinking of this is that observing accessors can only be added to stored properties, so when you add one to a property with a property wrapper, it must be added to the underlying stored property. That is not the right way to think about it. (It would also imply that the old/new value arguments to the observers would have to be values of the wrapper type, which I think people would find surprising.)

Observers can be added to an arbitrary property implementation. (This has always been true because of overrides.) The way this works is as described in the prospective vision: mutating accesses to the observed property call the observer around an access to the underlying implementation. For a property with a wrapper, directly accessing the underlying stored property bypasses the observer, just like accessing super.property would bypass observers added in an override.

Thanks for this, it is helps to draw the analogy with subclasses overriding a property, which is not something I was thinking about.

While I see that this is clearly the case for overrides in a subclass, it does seem to be the case that observers are not allowed for computed properties, except when used with property wrappers. And that does make property wrappers a bit more than simple syntactic sugar, which is what I was previously thinking they were.

I read the "Observing accessors" section again and still don't quite see the connection between what its describing and the behavior being seen with property wrappers. This line right here could mention property wrappers as another non-stored underlying implementation:

Usually, the underlying implementation is a stored variable, but it can also be inherited from a superclass if the accessor is added in an override.

And lastly, all of this makes me wonder if willSet/didSet is ever the correct thing to do with property wrappers. It seems to always be a foot gun waiting to go off. I don't think people are going to be familiar with these intricate details in order to understand why didSet is called when mutating @State directly but not when mutating through a Binding.

1 Like

The fact that Binding bypasses observers is unfortunate, yeah. I've long wondered whether Binding would be better as a language feature, the same way KeyPath is, rather than a library feature.

1 Like

While this makes sense, it does not feel very intuitive when the behavior is applied to property wrappers.

In practice, the subclassing example works the way I'd expect. Folks aren't typically calling super.property without a very specific purpose, and subclasses with a didSet will still observe changes made directly by the superclass:

class Parent {
  var count = 0
  func increment() {
    count += 1
  }
}
class Child: Parent {
  override var count: Int {
    didSet {
      print("didSet")
    }
  }
}
let child = Child()
child.increment()  // Prints "didSet"

Meanwhile, the property wrapper behavior feels more inscrutable, to the point where it seems like folks should avoid them:

@propertyWrapper struct Parent {
  var wrappedValue = 0
  mutating func increment() {
    wrappedValue += 1
  }
  // ...
}

@Parent var child {
  didSet {
    print("didSet")
  }
}
$child.increment()  // Does not print

So with the subclassing example, subclasses are afforded the ability to observe changes to the overridden property (with an explicit super. required to bypass). But with the property wrapper example, instances are not afforded any such ability, and bypassing is the norm rather than the exception.

Given this, is there reason to reconsider the current behavior? Should property observers even work with property wrappers at all given this subtlety? Can the current behavior at the very least be better documented and motivated? I'd love an example of why it should work the way it does and a good use case for the current behavior (that wouldn't be better if wrappedValue was fully observed, as a subclass can observe an overridden property of a superclass).

1 Like