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