Pitch: Key-Path Member Lookup

This introduces some interesting use cases with generics and tuples. This could allow, for instance, a generic type to wrap a poorly-typed dictionary with its expected key-value mapping expressed as a tuple. Combined with StoredPropertyIterable and the ability to extract the names of key paths, you could write a wrapper that checks the contents of a dictionary on construction, and provides strongly-typed access to its contents:

struct WellTypedDictionary<Schema> {
  var dictionary: [String: Any]

  init?(_ dictionary: [String: Any]) {
    for key in keys(Schema.self) {
      guard
        let value = dictionary[key.name],
        type(of: value) == key.valueType
      else {
        return nil
      }
    }
    self.dictionary = dictionary
  }

  subscript<U>(keyPathMember: WritableKeyPath<Schema, U>) -> U {
    get {
      return dictionary[keyPathMember.name] as! U
    }
    set {
      dictionary[keyPathMember.name] = newValue
    }
  }
}

let record = ["name": "Julius", "job": "Caesar", age: 55, "leastFavoriteDish": "et-tu-ffée"]

guard let checkedRecord = WellTypedDictionary<(name: String, age: Int)>(record) else {
  print("invalid record")
  return
}

print("\(checkedRecord.name) is \(checkedRecord.age)")
17 Likes

It is, however, truly a dynamic member, and our naming guidelines tell us not to repeat types in labels. In this case I think the different shades of “dynamic membership” would be eloquently communicated by the signature without having to resort to distinct labels.

2 Likes

Would subscripts also qualify for this, if there wasn't a valid subscript defined on the lookup-able type?

let lens: Lens<Array<Int>> = ...
lens.count    // evaluated as lens[keyPathMember: \.count]
lens[0]       // evaluated as lens[keyPathMember: \.[0]]

Maybe it could work for functions too:

let lens: Lens<Array<Int>> = ...
lens.call(me) // evaluated as lens[keyPathMember: \.call](me)

But would this work with argument names? or inout?

We’ve talked a bit about supporting method access through keypaths in the past; I think that would be a separate feature, and once we had it, we could talk about extending this to methods.

(But it could make sense to name the subscript dynamicProperty: instead of dynamicMember: to reflect this limitation.)

I wasn't planning on supporting subscripts, because they aren't currently supported by the string-based @dynamicMemberLookup.

Doug

This is a really really interesting feature and design, I love how two separately considered things are composing together here.

This looks effectively like the uber type safe "overload dot syntax" proposal we've needed for some time. I think the design you've proposed is totally sensible. Some random thoughts:

  • I probably should know this, but what is the overload ambiguity situation w.r.t. conflicts between declared members and dynamicly looked up members, hopefully it is a straight-out ambiguity? ((edit)) I just remembered that we follow the 'more specific' rule and pick the concrete decl, which seems fine.((/edit))
  • I assume a type can't be both keypath and stringly dynamic member lookup'd, it would be good to clarify that in the proposal that it is an error and include a testcase.
  • I agree we should merge these both under dynamicMember: as @beccadax suggests.
  • I may be missing something, but I don't think there is any need to worry about subscripts since they can already be overloaded, but I may be overlooking something.
  • Have you considered extending UnsafePointer and related types to use these, eliminating the awkardness with .memory?
  • The C++ community has had uhm ... more than one... discussion about overloading dot operators, smart references, etc. Are there any relevant use cases that could be merged into this discussion?
  • Is there a theoretical future world where weak/unowned pointers become types in the standard library, with a class-constrained element type? E.g. weak var x: T would be sugar for var x: WeakReference<T>?
  • Are there any other hacky member lookup things going on in the type checker that could be popped out to the stdlib with this feature?

In any case, overall I love the direction.

-Chris

4 Likes

Yeah, @dynamicMemberLookup only kicks in when there is no named member with that type.

We could say that the key-path based lookup should take priority over the string-based lookup, if both are defined on the same @dynamicMemberLookup type. I'll ban it until someone comes up with a use case.

I had not considered this, but that's a great idea!

Their operator. worked a little differently, forwarding to an instance of some other type. For example, ref.foo in the operator. proposal would desugar to ref.operator.().foo, where operator.() could return some other type. So the C++ design was better at forwarding (for building wrapper types, simplifying the C++ pImpl idiom, etc.) but never got to see the actual member that would be referenced. So you couldn't do the Lens thing we're talking about here. I don't think the authors of those proposals ever considered using C++ member pointers, which are the closest thing C++ has to key-paths.

I'll take this as a nudge to provide more use cases in the proposal, however. My Lens one is cool and general, but something concrete would improve the document.

I think it's something we could build toward, but this feature doesn't really get us any closer.

If we got the extension to make this feature work for forwarding methods as well, we could use it to implement that hacky thing where NSString members are available on String, e.g.,

extension String {
  subscript<U>(keyPathMember keyPath: KeyPath<NSString, U>) -> U {
    get {
      return (self as NSString)[keyPath: keyPath]
    }
  }
}

Thanks for the feedback!

Doug

6 Likes

Got it, makes sense, thanks!

+1 for banning both kinds of lookup until a use-case is identified.

-Chris

@Douglas_Gregor Would it be worth mentioning this in the proposal? I think it can be a real eye-opener on the usefulness of this proposal.

That sounds very promising.

Extending UnsafePointer with this functionality is indeed a good idea, though there is a hazard of ambiguity; would pointer.member load member, or give you another pointer to member? We don't currently provide a standard way to do the latter (although it's now possible to cobble one together using MemoryLayout.offset(of:)).

I can think of a few other interesting ways this could play out with reflection features. For one, maybe we could use this to obsolete default CodingKeys synthesis. A default implementation of CodingKeys could use key path member lookup, along with reflection capabilities on the key paths to recover the fields' ordinal and/or name to be used as an indexed or named coding key.

2 Likes

Maybe include subscripts in future directions? The key-path version is already more powerful than string-based lookup, since it can descend through multiple levels of properties (e.g. lens.topLeft.y). If the intention is to pass through the read/write surface area of a wrapped type, subscripts are an important part of that.

I'll include subscripts in future directions. However, I'd like to point out that the string-based @dynamicMemberLookup can also handle multiple levels of properties, so long as the thing returned from subscript[dynamicMember:] also supports @dynamicMemberLookup. I suspect that's the common case.

Doug

Totally right, thanks!

Yes, I'll pull this example into the proposal.

Doug

That wouldn’t actually work, even if static subscript(dynamicMember:) was supported. The init(from:)/encode(to:) syntheses use the declared cases of CodingKeys as their source of truth; that’s what allows you to explicitly declare CodingKeys with a different subset of your properties and get the rest of the implementation synthesized accordingly. Dynamic member lookup doesn’t create declarations, so there would be nothing to see.

Even if we worked around that, this default CodingKeys would also create keys for computed properties. We’d really like it to only create keys for stored properties, but that constraint can’t be stated in the type system.

What about let lens: Lens<Lens<Point>>? I would suspect two possibilities:

  • lens can only access the properties of the type Lens<Point>
  • lens can access both, the Lens<Point> and Point properties
2 Likes

I have opened a pull request ([SE-0252][TypeChecker] Keypath Dynamic Member Lookup by xedin · Pull Request #23436 · apple/swift · GitHub) with implementation of this pitch for properties. I think with some more refactoring I can make subscripts happen as well.

6 Likes

I'm wondering if this could be useful in the context of an ORM. Does something like this seem like it would work?

struct Column<Value> {
  var name: String
}

@keyPathMemberLookup
struct Row<Schema> {
  let schema: Schema
  let output: [String: Any]
  
  subscript<Value>(keyPathMember keyPath: KeyPath<Schema, Column<Value>>) -> Value? {
    get { 
      let column = schema[keyPath: keyPath]
      return output[column.name] as? Value
    }
    set {
      let column = schema[keyPath: keyPath]
      output[column.name] = newValue
    }
  }
}

struct User {
  let id = Column<UUID>(name: "id")
  let name = Column<String>(name: "name")
  let age = Column<Int>(name: "age")
}

var user: Row<User> = ...
print(user.name) // String?
user.name = "Foo"
4 Likes