The semantics of property observer when used with property wrapper

I wonder what's the semantics of property observer when the property is wrapped by a property wrapper? I'd expect it to be triggered when wrapped value changes, but that's not always true. See the example below. Modifying the wrapped value through wrappedValue's setter triggers property observer; but modifying it through projectedValue doesn't.

I wonder what's the expected behavior?

(I wrote the example to demonstrate a similar behavior with @State in SwiftUI).

@propertyWrapper
struct Foo {
    private var _value: Int = 0

    init(wrappedValue value: Int) {
        self._value = value
    }

    var wrappedValue: Int {
        get { _value }
        set { _value = newValue }
    }

    var projectedValue: Int {
        get { _value }
        set { _value = newValue }
    }
}

struct Bar {
    @Foo var n = 1 {
        willSet {
            print(newValue)
        }
    }
}

var bar = Bar()
bar.n = 4 // This triggers willSet
bar.$n = 5 // This doesn't
1 Like

I think the behavior you're observing makes sense when you think about how the compiler translates a property declaration with a property wrapper. SE-0258 describes that the compiler translates this:

@Foo var n = 1

into this:

private var _n: Foo = Foo(wrappedValue: 1)
var n: Int {
    get { _n.wrappedValue }
    set { _n.wrappedValue = newValue }
}

The willSet on @Foo var n in your example is apparently applied to the computed property var n: Int in the translated code. Since $n (which is a shorthand for _n.projectedValue) doesn't go through the computed property, the willSet doesn't fire.

(I couldn't find a description in SE-0258 of the exact semantics of willSet/didSet observers on property wrappers, but I don't think the compiler has a choice to do anything differently here: if the property wrapper definition were in a different module than the use site, the compiler couldn't attach an observer on the property wrapper type's wrappedValue property.)

2 Likes

Ole, thanks for your reply.

I just checked SE-0258. It says projected value is also expanded to a computed property. Below is an example excepted from it.

@Field(name: "first_name") public var firstName: String

expands to:

private var _firstName: Field = Field(name: "first_name")

public var firstName: String {
get { _firstName.wrappedValue }
set { _firstName.wrappedValue = newValue }
}

public var $firstName: Field {
get { _firstName.projectedValue }
set { _firstName.projectedValue = newValue }
}

Also, I don't understand why the difference matters. No matter if it's implemented as a syntax shorthand or a computed property, the code need to access the internal storage in the end. Shouldn't the compiler notice that a property observer is attached to that storage and trigger it? (I don't know the internals of the Swift compiler, so this is just my intuition).

Could you please elaborate it a bit? Do you mean if Foo property wrapper is defined in, say, a Swift package, the following code won't work? I did an experiment just now and it worked fine.

import FooModule

struct Bar {
    @Foo var n = 1 {
        willSet {
            print(newValue)
        }
    }
}
var bar = Bar()
bar.n = 4 // This triggers willSet

Or do you mean a) projected value isn't a computed property, b) so to support the behavior I expected, the compiler has to attach an observer on wrapped value, c) but that's impossible when the property wrapper is defined in a different module? Thanks.

It looks like the compiler attaches the willSet observer to the computed property n, not the stored property _n. So your code:

struct Bar {
    @Foo var n = 1 {
        willSet {
            print(newValue)
        }
    }
}

gets translated into this pseudocode (pseudcode because computed properties can't have observers, so the willSet code would be included in the setter):

struct Bar {
    private var _n: Foo = Foo(wrappedValue: 1)
    var n: Int {
        get { _n.wrappedValue }
        willSet {
            print(newValue)
        }
        set {
            _n.wrappedValue = newValue
        }
    }
}

The projected value doesn't go through this computed property, that's why it doesn't trigger the willSet. You're right that all paths access the internal storage in the end, but the willSet isn't attached to that storage.

Yes, b and c.

I suppose another option would have been to attach the observers on the stored property private var _n: Foo instead of the computed property var n: Int. That would match your expectations because mutating Foo.wrappedValue (through any code path) is also a mutation of Foo (if Foo is a value type). But then the willSet observer would fire for any mutation of Foo, not just when wrappedValue is mutated. This would probably be equally or even more surprising than the existing behavior. Regardless, I doubt the behavior can be changed now because it could break existing code.

1 Like