StoredPropertyIterable


(Richard Wei) #1

Hi all,

@dan-zheng and I have been experimenting with a protocol that helps you iterate over all stored properties at runtime.

Here's the idea:

protocol StoredPropertyIterable {
    associatedtype AllStoredProperties : Collection
        where AllStoredProperties.Element == PartialKeyPath<Self>
    static var allStoredProperties: AllStoredProperties { get }
}

By conforming to StoredPropertyIterable, you get a type property that represents a collection of key paths to all stored properties defined in the conforming type. The conformance is compiler-derived.

struct Foo : StoredPropertyIterable {
    var x: Int
    var y: String

    // Compiler-synthesized:
    static let allStoredProperties: [PartialKeyPath<Foo>] {
        return [\.x, \.y]
    }
}

A protocol extension can provide a recursivelyAllStoredProperties computed property that returns an array of key paths to stored properties of this type and key paths to any nested stored properties whose parent also conforms to StoredPropertyIterable. This can also be a lazy collection based on allStoredProperties, of course.

extension StoredPropertyIterable {
    static var recursivelyAllStoredProperties: [PartialKeyPath<Self>]
}
struct Bar : StoredPropertyIterable {
    var foo: Foo 
    var z: Int
    var w: String
}
Bar.allStoredProperties
// => [\Bar.foo, \Bar.z, \Bar.w]
Bar.recursivelyAllStoredProperties
// => [\Bar.foo, \Bar.foo.x, \Bar.foo.y, \Bar.z, \Bar.w]

Why do we believe StoredPropertyIterable should be added to the standard library? It provides a canonical API for accessing all stored properties of a struct, and can define away the existing compiler synthesis for derived Hashable conformances. Here's @Joe_Groff's earlier idea: https://gist.github.com/jckarter/96d4ff1e90e4c41dfcb271f2d6df9206.

Advanced use cases lie in many fields, one of which I'm familiar with is machine learning. ML optimizers operate on bags of parameters (sometimes defined as stored properties, sometimes as collections), and apply an update algorithm to every parameter. Since this use case is more complex than stored properties and requires key paths to nested parameters, I won't go into details for now. If you are interested, you can look at CustomKeyPathIterable in the gist below.

The following gist demonstrates a simple StoredPropertyIterable and a more advanced protocol called CustomKeyPathIterable that lets you define key paths to non-compile-time elements. Comments are welcome!


#2

I wonder if it’s better to incorporate this into Mirror, esp. since it concerns mostly on stored properties. Their capabilities are eerily similar to me.


(Richard Wei) #3

That could be interesting! We intended to align our pitch with CaseIterable because we feel it's more approachable than reflection APIs.


(Joe Groff) #4

Having a way to get a collection of key paths for a type makes a lot of sense. I think it makes sense not to make this about stored properties per se, but about the set of properties that make up the logical "schema" of the type; the set of stored properties makes sense as the default schema for a struct, but it would be useful to be able to override that default with a set of computed properties or subscripts that cover the type.

For collections, there's also the issue that the schema is value-dependent rather than uniform for all instances of a type, so you might want to have separate static and instance properties in the protocol to model this:

protocol KeyPathSchema {
  static var typeSchema: [PartialKeyPath<Self>]
  var valueSchema: [PartialKeyPath<Self>]
}

let x = [1, 2, 3]
type(of: x).typeSchema // []
x.valueSchema // [\.[0], \.[1], \.[2]]

I could see this all being particularly useful with compile-time evaluation, since you could use the statically-known layout of types to generate per-field logic based on those layouts. However, with the limited closed hierarchy of key paths that exist now, it isn't ideal to use key paths as a seed for generating things like Hashable conformance, since there's no way to state the requirement that all the fields in a schema must themselves be Hashable for the default implementation to be viable. Eventually, if we had "protocol-oriented" keypaths and generalized existentials, it'd be nice to be able to express this as conditional constraints on the key path collection:

protocol KeyPathSchema {
  associatedtype Schema: Collection where Schema.Element: KeyPath
}

extension Hashable where Self: KeyPathSchema, Self.Schema.Element.Value: Hashable {
  ...
}

(Richard Wei) #5

I agree, though I feel the name "schema" is a little unintuitive. Any other naming suggestions?

I like the idea of having the both a type property and an instance property. The functionality is like a combination of StoredPropetyIterable and CustomKeyPathIterable in that gist. I definitely think having a single protocol is better if the protocol is defined around the concept of key paths instead of properties.

Great point!


(Gal Cohen) #6

Neat! This is exactly what I wished existed the other day. Currently solving the problem using Mirror, but this would be much nicer and performant for my use case.


(Daryle Walker) #8

I wonder if the recursive property list should include both the direct properties and second-level properties. If you walk the list, each second-level property could get touched twice (once directly and once as part of whatever function you apply on its direct container). It gets even worse once deeper levels get involved. Maybe we need a third property list, for all the direct and indirect properties that can't be broken down any further.


(Michael Pangburn) #9

Related idea—wouldn't need to be tied to this pitch, but just thought I'd throw it out for future consideration.

It'd be cool to have a compiler-synthesized failable initializer that takes a dictionary of partial keypaths to property values:

struct Person: StoredPropertyInitializable {
    var firstName: String
    var age: Int

    // compiler-synthesized:
    init?(propertyValues: [PartialKeyPath<Person>: Any]) {
        guard
            let firstName = propertyValues[\.firstName] as? String,
            let age = propertyValues[\.age] as? Int
        else {
            return nil
        }

        self.firstName = firstName
        self.age = age
    }
}

Such a feature would enable, for example, the creation of generic builder types.