Using property observers with Observation

I thought it didn't work, but my experiment shows it works fine.

@Observable
final class Foo {
    var x: Int = 0 {
        didSet {
            print("didSet was called")
        }
    }
}

func test() {
    var foo = Foo()

    withObservationTracking {
        let _ = foo.x
    } onChange: {
        print("x was changed")
    }

    foo.x = 1
}

test()

// Output:
// x was changed
// didSet was called

So I expand macro in Xcode and find the following

@ObservationTracked var x: Int = 0 {
        didSet {
            print("didSet was called")
        }
    }

is expanded to:

var x: Int = 0 {@storageRestrictions(initializes: _x)
        init(initialValue) {
            _x = initialValue
        }

        get {
            access(keyPath: \.x)
            return _x
        }

        set {
            withMutation(keyPath: \.x) {
                _x = newValue
            }
        }
        didSet {
            print("didSet was called")
        }
    }

I knew didSet couldn't be used with computed variable before, so I thought this was a new feature. However, when I try to reproduce the behavior with my own code, it can't compile.

struct Bar {
    var _x: Int = 0
    var x: Int {
        @storageRestrictions(initializes: _x)
        init(initialValue) {
            _x = initialValue
        }

        get {
            return _x
        }

        set {
            _x = newValue
        }

        // Compiler error: 'didSet' cannot be provided together with an init accessor
        didSet {
            print("didSet was called")
        }
    }
}

Any idea why it works in macro expanded code but not in my own code? Thanks.

To answer my own question, the willSet/didSet support is by design, because there is a section about it in SE-0395. The doc also described how to implement it:

var a: Int {
    get {
        self.access(keyPath: \.a)
        return _a 
    }
    set {
        self.withMutation(keyPath: \.a) {
            _a = newValue
        }
    }
}

var _a = 0 {
    willSet { print("will set triggered") }
    didSet { print("did set triggered") }
}

However, it seems the actual approach is different. From what I can tell from ObservableMacro code, it doesn't deal with willSet/didSet accessors at all. My hypothesis is that the compiler has internal support for computer property's willSet/didSet accessors (IIUIC property wrapper needs it). But it doesn't allow user to input it explicitly. That's why my manual code can't compile. If the hypothesis is correct, I should be able to reproduce this behavior (transforming a stored property to computed property and expect its willSet/didSet accessors still work) by writing my own macro. I'll give it a try after resolving the macro development issue on my MBP.

1 Like

My mistake. I didn't realize it's implicitly supported by this line. The test below also verifies the didSet accessor is attached to the storage, instead of the computed variable.

@Observable
final class Foo {
    var x: Int =  0 {
        didSet {
            print("didSet was triggered")
        }
    }

    func modifyStorage(_ value: Int) {
        _x = value
    }
}

func test() {
    let foo = Foo()

    withObservationTracking {
        let _ = foo.x
    } onChange: {
        print("x was changed")
    }

    foo.modifyStorage(3)
}

test()

// Output:
// didSet was triggered