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

I found the missing link here.

With the following "access tracking" holder type:

@dynamicMemberLookup
struct AccessTracker<Value> {
    private var value: Value

    init(_ value: Value) {
        self.value = value
    }
    subscript<T>(dynamicMember keyPath: KeyPath<Value, T>) -> T {
        AccessTracker<Never>.readAccesses?.insert(keyPath)
        return value[keyPath: keyPath]
    }
    subscript<T>(dynamicMember keyPath: WritableKeyPath<Value, T>) -> T {
        get {
            AccessTracker<Never>.readAccesses?.insert(keyPath)
            return value[keyPath: keyPath]
        }
        set {
            AccessTracker<Never>.writeAccesses?.insert(keyPath)
            value[keyPath: keyPath] = newValue
        }
    }
}

The following static storage mechanism to track changes:

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
    }
}

For the following example data structure:

struct Foo {
    struct Bar {
        struct Baz {
            var qux: Int = 0
            var quux: Int = 0
        }
        var baz: [Baz] = [Baz(), Baz()]
    }
    var bar: [String: Bar] = ["hello" : Bar()]
}

I can now write the following code using normal looking read / write accesses without using keypaths explicitly:

var foo = AccessTracker(Foo())
AccessTracker.startReadAccessTracking()
let x = foo.bar["hello"]!.baz[0].qux
let readAccesses = AccessTracker.stopReadAccessTracking()

AccessTracker.startWriteAccessTracking()
foo.bar["hello"]!.baz[0].qux = 42
let writeAccesses = AccessTracker.stopWriteAccessTracking()

and know if write access has changed something that read access was depending upon:

let intersects = !readAccesses.isDisjoint(with: writeAccesses)
print(intersects) // true

Hurray :tada:


Edit:

Note that only a single data structure is supported in this code: only key paths are getting recorded, so if you read from "foo1" but write to "foo2" this code would be confused.

Also found a bug in AccessTracker:
do {
    var foo = AccessTracker(Foo())
    
    AccessTracker.startReadAccessTracking()
    let x = foo.bar["hello"]!.baz[0].qux
    let readAccesses = AccessTracker.stopReadAccessTracking()
    
    AccessTracker.startWriteAccessTracking()
    foo.bar["hello"]!.baz[1].qux = 42  // different key path
    let writeAccesses = AccessTracker.stopWriteAccessTracking()
    print(writeAccesses) // [Swift.WritableKeyPath<App.Foo, Swift.Dictionary<Swift.String, App.Foo.Bar>>]
    
    let intersects = !readAccesses.isDisjoint(with: writeAccesses)
    print(intersects) // true
}
Interestingly the code that doesn't use AccessTracker doesn't have this bug:
do {
    var foo = Foo()
    
    AccessTracker.startReadAccessTracking()
    let x = foo.bar["hello"]!.baz[0].qux
    AccessTracker.readAccesses!.insert(\Foo.bar["hello"]!.baz[0].qux)
    let readAccesses = AccessTracker.stopReadAccessTracking()
    
    AccessTracker.startWriteAccessTracking()
    foo.bar["hello"]!.baz[1].qux = 42  // different key path
    AccessTracker.writeAccesses!.insert(\Foo.bar["hello"]!.baz[1].qux)
    let writeAccesses = AccessTracker.stopWriteAccessTracking()
    print(writeAccesses) // [Swift.WritableKeyPath<App.Foo, Swift.Int>]

    let intersects = !readAccesses.isDisjoint(with: writeAccesses)
    print(intersects) // false
}

Notably, keyPaths look different in these two cases, which explains why the bug occurs, but I don't see yet how to fix it.

1 Like