[Pitch] Type Wrappers

The thing that worries me is that some of the examples are starting to look like protocols where it defines a suite of behavior/capabilities applied to arbitrary types

@dynamicMemberLookup is an attribute to have the compiler support syntax sugar for accessing properties through a well-defined subscript, leaving much of the actual implementation up to the type to define, but this new wrapper is actually "hey, go generate a ton of properties and storage, but don't expose all these details unless someone knows to go looking for them or recognizes the synthesized nature of the generated code".

In the proposal there was mentioned that "this is boilerplate that would have to be duplicated for every object" and traditionally that type of space is solved with metaprogramming through either a template or macro system.

The fact that it's not composable, and in several cases hiding details about how the type works (side-effects), I'm very concerned we'd be introducing footguns that are going to have to be avoided primarily through documentation or strong API design by the implementers.

10 Likes

I think this is a great observation, and I wonder whether the addition of variadic generics will allow us to generalize property wrappers to abstract over a variable number of stored properties.

Type wrappers allow libraries to implement property storage and access patterns for all stored properties in a type, which can be opted into by applying a custom attribute to a type. The functionality is very similar to property wrappers, but there is one single wrapper instance that implements a set of stored properties in a type. Despite being so similar conceptually to property wrappers, type wrappers introduce a lot of new complexity into the language, including:

  • A new attribute @typeWrapper
  • A new attribute @typeWrapperIgnored
  • A new initializer form init(storage:)
  • A new indirect property accessor in the form of a subscript: subscript(propertyKeyPath:storageKeyPath:)
  • A synthesized $Storage struct specific to each type that is annotated with a type wrapper that stores each of the stored properties written in the wrapped type
  • A new transformation using init(storage:), the subscript, and the $Storage type that turns all stored properties into computed properties.
  • DI support for hand written member-wise initialization code that re-writes property initialization to initialization of the wrapper through init(storage:)
  • ...and so on

With variadic generics, there is no longer a need for the synthesized $Storage struct, and type wrappers can simply be parameterized directly on a pack of stored property types, much like how a property wrapper is parameterized directly on its wrapped value type. If we give property wrappers the capability to abstract over a variable number of stored properties, the majority of the bespoke language capabilities for type wrappers go away. We also get property-wise projections without any new concepts to the language if the property wrapper declares a var projectedValue, and the ability to store the values however you want, effectively allowing you to write your own $Storage type.

The property wrapper transform only needs to be slightly tweaked to project out a single element from the pack inside of the computed property accessor. Even without a builtin pack element projection feature, we can already express pack element projection on a variadic generic property wrapper using dynamic member lookup.

Among others, property wrappers that implement different storage patterns, such as CopyOnWrite should be easily generalizable to multiple wrapped values. For example, you might imagine that a variadic generic CopyOnWrite property wrapper looks like this:

class Box<Stored...> {
  var stored: Stored...  
}

@dynamicMemberLookup
@propertyWrapper
struct CopyOnWrite<Value...> {
  var box: Box<Value...>
  
  init(wrappedValue: Value...) {
    box = Box(stored: wrappedValue...)
  }
  
  var wrappedValue: Value... {
    get { box.stored... }
    
    set {
      if (!isKnownUniquelyReferenced(&ref)) {
        box = Box(newValue)
      } else {
        box.stored = newValue
      }
    }
  }
  
  // We might want to support pack element projection through a built-in pack
  // projection feature, but we can also support it through dynamic member lookup
  // using positional tuple key-paths.
  subscript<U>(dynamicMember keyPath: WritableKeyPath<(Value...), U>) -> U {
    get { (wrappedValue...)[keyPath: keyPath] }
    set { (wrappedValue...)[keyPath: keyPath] = newValue }
  }
}

@CopyOnWrite
struct Person {
  var name: String
  var birthdate: Date
}

// or

func test() {
  @CopyOnWrite var buffer: MyBufferType = ...
}

You can imagine that this code gets expanded to

struct Person {
  init(name: String, birthdate: Date) {
    _storage = CopyOnWrite(wrappedValue: name, birthdate)  
  }

  var _storage: CopyOnWrite<String, Date>

  var name: String {
    // using dynamic member lookup, but this could instead use a builtin pack element
    // projection on _storage.wrappedValue
    get { _storage.0 } 
    set { _storage.0 = newValue }
  }

  var birthdate: Date {
    get { _storage.1 }
    set { _storage.1 = newValue }
  }
}

The transformation can easily be tweaked for the experimental enclosing-self subscript by passing in the pack element key-path in addition to the wrappedKeyPath and storageKeyPath.

To my mind, generalizing property wrappers achieves the goals of this pitch while building on top of the concepts the language already has for implementing property access patterns.

10 Likes

I think this makes sense, variadic generics really help a lot in this case!

Just to complete the loop; one fairly useful case for type wrappers is to allow for protocol wrapping in extensions. This allows for the potential of side stepping any composability issues. @hborla came up with quite honestly a pretty darned brilliant strategy to account for this where we could write a type wrapper as:

@SomeTypeWrapper
extension SomeProtocol where SomeAssociatedType: SomeConstraint { }

This allows the type wrapper to provide default implementations and would be very useful not just for the observability pitch but I am sure may other applications.

5 Likes

I've been working on the implementation for attached macros, and I had the chance to implement @Observable from the Future Directions section of the observation pitch. I was able to replicate the functionality in this type wrapper pitch through composition of the various attached macro capabilities.

The macro approach provides more flexibility, because the macro author can decide what code to generate inside the wrapped type. The macro-expanded code then becomes much more transparent to the programmer than a subscript call, which isn't very informative in terms of what that call accomplishes. The macro still has the ability to add backing storage variables, initializers, nested types derived from stored properties of the wrapped type, and more, and the transformation can be customized depending on what kind of type the macro is attached to (e.g. a struct versus an actor).

After taking the motivating use cases surfaced in this pitch thread and implementing them as macros, I'm confident that macros can fully subsume type wrappers while providing more flexibility to library authors.

49 Likes

This is very exciting news. Thank you @hborla for the update.

3 Likes

Yes, thank you Holly! It's promising for macros to see them subsume the need for other complex language features. :slightly_smiling_face:

4 Likes