"Observing" changes to arbitrary key paths on move-only structs

I don't see how to fix AccessTracker, for now will be using the next best thing - explicit keypath usage. Full example that simulates simple model, "view" and "rendering loop" inside OS:

AccessTracker, partly commented out as it doesn't work properly
// @dynamicMemberLookup
struct AccessTracker<Value> {
    // doesn't work 🤔
    /*
    private var value: Value

    init(_ value: Value) {
        self.value = value
    }
    subscript<T>(dynamicMember keyPath: KeyPath<Value, T>) -> T {
        print(keyPath)
        return value[keyPath: keyPath]
    }
    subscript<T>(dynamicMember keyPath: WritableKeyPath<Value, T>) -> T {
        get {
            print(keyPath)
            return value[keyPath: keyPath]
        }
        set {
            print(keyPath)
            value[keyPath: keyPath] = newValue
        }
    }
    */
}

extension AccessTracker where Value == Never {
    static var readAccesses: Set<AnyKeyPath>?
    static var writeAccesses: Set<AnyKeyPath>?
    
    static func startReadAccessTracking() {
        readAccesses = []
    }
    static func stopReadAccessTracking() -> Set<AnyKeyPath> {
        guard let accesses = readAccesses else {
            fatalError("read access tracking has not been started")
        }
        readAccesses = nil
        return accesses
    }
    static func startWriteAccessTracking() {
        writeAccesses = []
    }
    static func stopWriteAccessTracking() -> Set<AnyKeyPath> {
        guard let accesses = writeAccesses else {
            fatalError("write access tracking has not been started")
        }
        writeAccesses = nil
        return accesses
    }
}
AccessTrackable helper protocol for manual key path access tracking
protocol AccessTrackable {}

extension AccessTrackable {
    subscript<T>(keyPath: KeyPath<Self, T>) -> T {
        AccessTracker.readAccesses!.insert(keyPath)
        return self[keyPath: keyPath]
    }
    subscript<T: Equatable>(keyPath: WritableKeyPath<Self, T>) -> T {
        get {
            AccessTracker.readAccesses!.insert(keyPath)
            return self[keyPath: keyPath]
        }
        set {
            // #1 - always record write access:
            AccessTracker.writeAccesses!.insert(keyPath)
            self[keyPath: keyPath] = newValue
            
            // #2 - only record write access on change:
            // (T needs to be Equatable for this to work)
            let oldValue = self[keyPath: keyPath]
            if oldValue != newValue {
                AccessTracker.writeAccesses!.insert(keyPath)
                self[keyPath: keyPath] = newValue
            }
        }
    }
}
Example user model struct conforming to AccessTrackable
struct Foo {
    struct Bar {
        struct Baz {
            var qux = 1
            var quux = 2
        }
        var baz: [Baz] = [Baz(), Baz()]
    }
    var bar: [String: Bar] = ["hello" : Bar()]
}

extension Foo: AccessTrackable {}
View simulation
struct MyView {
    //var foo = AccessTracker(Foo())
    var foo = Foo()

    var body: String {
        let x = foo[\.bar["hello"]!.baz[0].qux]
        return "\(x)"
    }
    mutating func action() {
        let index = Int.random(in: 0 ... 1)
        let value = Int.random(in: 0 ... 1000)
        if Bool.random() {
            print("changing index: \(index) qux to \(value)")
            foo[\.bar["hello"]!.baz[index].qux] = value
        } else {
            print("changing index: \(index) quux to \(value)")
            foo[\.bar["hello"]!.baz[index].quux] = value
        }
    }
}
Render loop simulation
func renderLoop() {
    
    struct ViewRecord {
        var view: MyView
        var content: String = ""
        var readAccesses: Set<AnyKeyPath> = []
        var dirty = true
    }
    
    var views = [ViewRecord(view: MyView())]

    while true {
        for i in 0 ..< views.count {
            if views[i].dirty {
                views[i].dirty = false
                AccessTracker.startReadAccessTracking()
                views[i].content = views[i].view.body
                print("🟢 rendered view: \(views[i].content)")
                views[i].readAccesses = AccessTracker.stopReadAccessTracking()
            }
        }
        
        var hasDirtyViews = false
        for i in 0 ..< views.count {
            // simulating a button press
            if Int.random(in: 0 ... 2) == 0 {
                AccessTracker.startWriteAccessTracking()
                views[i].view.action()
                let writeAccesses = AccessTracker.stopWriteAccessTracking()
                views[i].dirty = !views[i].readAccesses.isDisjoint(with: writeAccesses)
                print(views[i].dirty ? "🟠 will rerender" : "unrelated change - won't rerender")
                hasDirtyViews = hasDirtyViews || views[i].dirty
            } else {
                print("won't call action")
            }
        }
        
        if !hasDirtyViews {
            sleep(1)
        }
    }
}

renderLoop()

If anyone can see a way how to fix AccessTracker or otherwise go from:

foo[\.bar["hello"]!.baz[0].qux] = 42

to

foo.bar["hello"]!.baz[0].qux = 42

please shout. The amount of boilerplate here is not massive (3 extra characters) but it's still unfortunate and having it will be quite error prone in practice as it would be very easy forgetting using keypath form and access tracking won't work.

If this is impossible to do with the currently available language constructs perhaps we could consider introducing a pinpoint language support for this feature.


How does this method answer the questions raised in the other thread:

  1. "would the body be recalculated on state.unrelated changes" - No. Only relevant changes would cause body recalculation, even if they happen deep inside the data structure. †
  2. "if I set state.related variable to the same value, would it cause body recalculation" - could be Yes (with #1 above) or No (with #2 above).

† - the change to the "parent" ketpath e.g. [\.bar["hello"]!.baz[0] or [\.bar["hello"] should be considered changing "child" keypaths like [\.bar["hello"]!.baz[0].qux. We'd probably need to expose some new methods on keypaths to allow this.