The Algebra of KeyPaths and @dynamicMemberLookup

Consider the following:

struct A { }
struct B { var a: A }
struct C { var b: B }
struct D { var c: C; var a: A }

let kp1: KeyPath<D, C> = \D.c
let kp2: KeyPath<D, A> = \D.c.b.a
let kp3: KeyPath<D, A> = \D.a

print("\(kp1)")
print("\(kp2)")
print("\(kp3)")

For somewhat esoteric reasons having to do with a type using @dynamicMemberLookup, I'd like to know that kp1 lies-along kp2. I can actually determine this by getting the debugDescription, and manually comparing the strings, bc the debugDescription reads exactly as the code above. But that will only work in Debug builds not in Release builds.

Interestingly, pasting the above into a playground in Xcode 15.3b3 yields:

\D.c
\D.c.b.a
\D.c

Which seems like, well, a pretty ugly bug. But I'm more concerned about what happens to this at runtime in RELEASE builds. Using String(reflecting:), I lose the interior structure of kp2 and only know that kp2 starts on a D and ends on an A.

I can imagine lots of other operations of this type that I'd like to do, but the API of KeyPath precludes asking these questions. Am I missing something obvious?

5 Likes

debugDescription is only a best effort rendering of the KeyPath contents using available symbol and reflection information. The intent is to aid logging, but you can't build load-bearing logic on top of what it prints. It probably prints \D.a as \D.c because a is an empty type, so it takes up no space in D and appears to have the same offset as c within D. That's a bug that's at least partially fixable by having the printer match up the element type along with the offset when it tries to recover the component name.

3 Likes

What are you planning to do with this information? Since key paths can go through computed properties, your only answers for “does key path X overlap key path Y” are “yes” and “maybe”.

(Okay, technically the run-time key path representation also knows if every component is stored, so it could answer “no” sometimes. But I don’t know if it preserves hierarchy when you access a series of stored properties \D.c.b.)

2 Likes

There should be enough information to answer "does this overlap" accurately for inline struct stored properties, even hierarchically.

5 Likes

I have a dictionary keyed by KeyPaths. I'd like to remove all entries which lie below some point. i.e. given:

let d = [
    \A.b.c1.d1: value,
    \A.b.c1.d2: value,
    \A.b.c1.d3: value,
    \A.b.c2.d4: value,
    \A.b.c2.d5: value,
    \A.b.c3.d6: value,
]

remove all entries prefixed by \A.b.c1. Didn't seem like an unreasonable ask when I agreed to do it :slight_smile:

1 Like

Not sure where I can get that information is my problem. Any pointers greatly appreciated.

Okay, but why do you want that? What should the behavior be in this case?

extension A {
  var alsoB: B {
    b
  }
}

let d = [
  \A.b.c1: value,
  \A.alsoB.c1: value,
]

I have a large-ish data structure that provides some configuration information. The idea is that many things want access to the contents of the structure but almost every one those many want to override values from the base structure.

So the design is that there's a single instance of the large structure that many things reference, but that each reference keeps a small dictionary of its overrides. Given a keypath into the large structure, use that keypath to check the small overrides dictionary and if no entry use the keypath into the large structure.

Now occasionally one of the referrers wants to remove all overrides it may have of a certain type and that's where this request comes in.

In your example: b and alsoB are considered completely different values even though their types are the same.

4 Likes

for a more concrete example: imagine having the large structure be the associated value of a SwiftUI EnvironmentKey. Now arbitrarily many different views can efficiently access that key without making a huge number of copies.

1 Like

For stored properties, you can use MemoryLayout.offset(of: \Struct.field) to get the starting byte of the field within the aggregate, and MemoryLayout<FieldType>.size to get the number of bytes it occupies. If the [offset, offset+size) interval overlaps for two fields then they overlap, otherwise they don't. If one or the other isn't a stored struct property, then you'll need to track it manually though.

5 Likes