[Pitch] Type Wrappers

Contents

Introduction

There are some code patterns that require access to storage go through a centralized or externally managed location (e.g. for mocking, proxying, or introducing additional data transformations/effects). This proposal introduces a mechanism called type wrappers to achieve just that. A type wrapper abstracts over the declared stored properties of the type it is applied to (the wrapped type) where all access to those properties goes through a central operation in the type wrapper instance.

Motivation

The existing language tools are insufficient when a use-case requires some collective action on a set of properties of a type. To demonstrate, let’s build a type that keeps track of the number of modifications that are performed to an instance’s properties by storing a generation count.

Let's start by declaring the type that we want to track the modifications to:

class Document {
  let title: String
  var content: String? = nil
}

Document’s title is immutable, so generation count applies only to its content modifications:

// generation 0
var document = Document(name: "Draft #1")

// generation 1
document.content = "Lorem ipsum"

Generation tracking could be implemented in a couple of different ways that introduce a fair amount repetitive boilerplate and/or additional storage which is not always acceptable. Let’s look at a few of the most prevalent solutions:

  • Hide the storage and turn content into a computed property:
class Document {
  private var generation: Int = 0
 
  let title: String
  
  var content: String? {
    get { _value }
    set { 
      _content = newValue 
      generation += 1
    }
  }
 
  private var _content: String? = nil
}

The publicly exposed content is referring to the hidden _content to access the storage. This does the job but when a type has more than one property (i.e. with introduction of timestamp) the effect from the boilerplate code associated with tracking increases exponentially and can lead to errors where some of the properties are not tracked.

  • Use SE-0252 @dynamicMemberLookup feature with hidden storage:
@dynamicMemberLookup
class Document {
  var generation: Int = 0
 
  let title: String
 
  struct _Storage {
    var content: String? 
  }
 
  private var storage: _Storage
 
  init(title: String, content: String? = nil) {
    self.title = name
    self.storage = _Storage(content: content)
  } 
 
  subscript<U>(dynamicMember keyPath: WritableKeyPath<_Storage, U>) -> U {
    get { storage[keyPath: keyPath] }
    set { 
      storage[keyPath: keyPath] = newValue
      generation += 1
    }
  }
}

This is a more significant refactoring that requires a separate _Storage type, splitting the state between Document and _Storage, and re-implementation for every type. On the other hand, this approach allows removing most of the boilerplate because the access is routed through a subscript, which is big improvement over the first approach.

  • Use a property wrapper:
@propertyWrapper
struct GenerationTracked<Value> {
  var value: Value
  var generation: Int = 0

  init(wrappedValue: Value) {
    self.value = wrappedValue
  }

  var projectedValue: Self { return self }

  var wrappedValue: Value {
    get { self.value }
    set {
      self.value = newValue
      generation += 1
    }
  }
}

GenerationTracker is going to keep a generation count per property, and could be used like this:

class Document {
  let title: String
  @GenerationTracked var content: String? = nil
}

GenerationTracker property wrapper greatly improves readability at the declaration site of each property comparing to both previous approaches and doesn’t require code duplication per-type - this kind of repetitive pattern is what inspired property wrapper introduction to the language!

But there is a catch - with each property comes additional storage which adds up pretty quickly for types with a large number of properties, and more importantly, exposing the "current generation value" becomes a more complex operation as number of tracked properties grows:

class Document {
  let title: String
    
  @GenerationTracked var content: String? = nil
  @GenerationTracked var timestamp: UInt64
}

The generation value is a sum of content and timestamp:

extension Document {
  var generation: Int {
    $content.generation + $timestamp.generation
  }
}

This also means that with addition of every new property generation would have to be updated as well which could be error prone.

Another interesting example is an anti-pattern which is very easy to reach for because there is, currently, no better way to express the use-case in the language. Let’s say that instead of trying to track changes made to a Document, the developer wants to synchronize access to its mutable properties using a queue. They’d like to have a type confined to a single queue which is going to make all access to the properties serial. For this example we are going to skip over the computed properties and @dynamicMemberLookup approaches because they look exactly the same ditto var generation is now var queue: DispatchQueue and jump directly to the property wrapper approach instead:

@propertyWrapper
struct Confined<Value> {
  var value: Value
  
  private var queue = DispatchQueue(label: "com.example.confined.\(Value.self)")

  init(wrappedValue: Value) {
    self.value = wrappedValue
  }

  var projectedValue: Self { return self }

  var wrappedValue: Value {
    get { 
      self.queue.sync { self.value }
    }
    set {
      self.queue.sync { self.value = newValue }
    }
  }
}

Newly added @Confined could be attached to every mutable property of the Document just like in our previous example with the GenerationTracker:

class Document {
  let title: String
    
  @Confined var content: String? = nil
  @Confined var timestamp: UInt64
}

But unlike @GenerationTracker, that only adds additional storage, @Confined has more serious implications for performance, because now every property is handled by a dedicated queue which is not a light-weight mechanism and access pattern to the type itself is not serial!

Using either a property wrappers or @dynamicMemberLookup is the best answer for Document class but each comes with some significant drawbacks. Nevertheless the best parts of these approaches could be combined into a type wrapper concept that could be used to cover all mutable stored properties of a type.

Proposed solution

Type wrappers provide a way to abstract a type’s storage so that it can be managed in some other manner. Let's convert the example from Motivation to use a type wrapper.

The type wrapper will be defined with the @typeWrapper attribute, like this:

@typeWrapper
struct GenerationTracker<S> {
  /// The instance of the underlying storage the wrapper operates on.
  var underlying: S
  /// The generation count that gets increased after every modification.
  var generation: Int = 0
  
  init(memberwise storage: S) {
    self.underlying = storage
  }

  subscript<V>(storageKeyPath path: WritableKeyPath<S, V>) -> V {
    get { 
      return underlying[keyPath: path]
    }
    set { 
      underlying[keyPath: path] = newValue
      generation += 1
    }
  }
}

The type wrapper can be used as a custom attribute on the definition of a Document:

@GenerationTracker
class Document {
  let title: String
  var content: String? = nil
  var timestamp: UInt64
}

The @GenerationTracker attribute applies the type wrapper transformation to the Document. This transformation does several things:

  • It creates a new struct $Storage that contains the mutable stored properties that are declared in the Document class. The struct would look like this:
struct $Storage {
  var content: String?
  var timestamp: UInt64
}
  • Then, it replaces the declared stored properties in the Document with a single stored property of type GenerationTracker<$Storage>, like this:
class Document {
  // synthesized
  private var $_storage: GenerationTracker<$Storage>
 
  ... see below
}
  • And then turns each of the declared stored properties into a computed property that goes through the storage subscript, like this:
var content: String? {
  get { $_storage[storageKeyPath: \$Storage.content] }
  set { $_storage[storageKeyPath: \$Storage.content] = newValue }
}

Since $_storage is accessible from the wrapped type, it's pretty easy to add a computed property to surface the current generation value of a Document:

extension Document {
  var generation: Int { $_storage.generation }
}

Now, let’s take a look at @Confined example from the previous section. Type wrappers can help to synchronize access to the Document using a single queue because they wrap the whole type:

@typeWrapper
struct Confined<S> {
  /// The instance of the underlying storage the wrapper operates on.
  var underlying: S
 
  /// A single queue to sync access to all of the properties
  private var queue = DispatchQueue(label: "com.apple.example.confined.\(S.self)")
  
  init(memberwise storage: S) {
    self.underlying = storage
  }

  subscript<V>(storageKeyPath path: WritableKeyPath<S, V>) -> V {
    get { 
      self.queue.sync { underlying[keyPath: path] }
    }
    set { 
      self.queue.sync { underlying[keyPath: path] = newValue }
    }
  }
}

Applied to Document it looks like this:

@Confined
class Document {
  let title: String
  var content: String? = nil
  var timestamp: UInt64
}

With type wrapper a single queue would be used to manage access to all properties of the Document .

Type wrappers address all drawbacks associated with property wrappers for this particular use-case:

  • Avoid duplicate storage.
  • Provides access indirection to all mutable stored properties (with a possibility to explicitly opt-out).
  • Remove boilerplate through access centralization.
  • Type wrapper state (i.e. value of generation) is easily accessible from the wrapped type.

Detailed design

Type Wrapper types

A type wrapper type is a type that can be used to manage access to the storage of the wrapped type. There are a few requirements for a type wrapper type:

  1. The type wrapper type must be defined with the attribute @typeWrapper. The attribute indicates that the type is meant to be used as a type wrapper type, and provides a point at which the compiler can verify any other consistency rules.

  2. The type wrapper type must have:

    • A single generic parameter that represents a type of underlying storage (the $Storage of a particular type a wrapper is applied to).
    • init(memberwise: <#T#>) - to initialize underlying storage property.
    • subscript<V>(storageKeyPath path: WritableKeyPath<#T#>, V>) -> V - to access the underlying storage given a key path.

Custom attributes

Type wrappers are a form of custom attribute, where the attribute syntax is used to refer to entities declared in Swift. Grammatically, the use of type wrappers is described as follows:

attribute ::= '@' type-identifier

The type-identifier must refer to a type wrapper type, which cannot include generic arguments. It could be beneficial to extend this definition to support generic arguments, which is a possible future direction.

Applying type wrapper to a type

Introducing a type wrapper to a type makes all of its mutable stored properties computed (with a getter/setter) and adds:

  • A member struct $Storage that mirrors all the stored properties of a type.
  • A stored property $_storage - instance of type wrapper type which is used for all storage access. The name is chosen to avoid a potential clash with a user-defined variable storage with a (projection capable) property wrapper which is going to get $storage synthesized for it.

The transformation from stored to computed properties happens as follows:

  • A new member is introduced to $Storage which uses the name and type of the original property.
  • The original property is marked as computed and the compiler synthesizes:
    • A getter: get { $_storage[storageKeyPath: \$Storage.<name>] }
    • A setter: set { $_storage[storageKeyPath: \$Storage.<name>] = newValue }
  • The default value of a property (if any) is moved to a parameter of implicitly synthesized initializer or injected into a user-defined initializer, see Initialization section for more details.

Properties with property wrappers

When property has one or more property wrappers, the type wrapper transformation applies only to the compiler synthesized backing property because it represents the underlying storage of the wrapped property.

To demonstrate this in action, let's consider the following example:

@GenerationTracker
struct Person {
  @Wrapper var favoredColor: Color
}

Applying @Wrapper to favoredColor results in the following transformation (for more details please see property wrapper proposal):

@GenerationTracker
struct Person {
  var favoredColor: Color {
    get { _favoredColor.wrappedValue }
    set { _favoredColor.wrappedValue = newValue }
  }
  
  /// !!! - exists only if `Wrapper` supports projection.
  var $favoredColor: Wrapper<Color> {
    get { _favoredColor.projectedValue }
  }
  
  /// Backing property is stored
  private var _favoredColor: Wrapper<Color>
}

Both favoredColor and $favoredColor are computed properties which means that type wrapper only applies to _favoredColor and routes it through the $_storage:

@GenerationTracker
struct Person {
  struct $Storage {
    internal var _favoredColor: Wrapper<Color>
  }
  
  var $_storage: GenerationTracker<$Storage>
  
  /// !!! - Backing property is now routed through the `$_storage`
  private var _favoredColor: Wrapper<Color> {
    get { $_storage[storageKeyPaht: \$Storage._favoredColor] }
    set { $_storage[storageKeyPath: \$Storage._favoredColor] = newValue }
  }
  
  var favoredColor: Color {
    get { _favoredColor.wrappedValue }
    set { _favoredColor.wrappedValue = newValue }
  }
  
  // Note: only if `Wrapper` supports projection.
  var $favoredColor: Wrapper<Color> {
    get { _favoredColor.projectedValue }
  }
}

This composes well with multiple property wrappers and means that type wrapper is applied before any property wrapper operation can occur. There are some interesting considerations regarding how such properties are initialized which are discussed in Initialization section.

Initialization

Memberwise initializer for stored properties

If there are no user-defined initializers, instead of the usual memberwise initializer, the type wrapper transformation will synthesize a special initializer that covers all of the stored properties in the following fashion:

  1. Any stored properties not handled by a type wrapper are initialized by direct assignment - self.<name> = <name>
  2. All other properties are initialized through $_storage initialization via calling init(memberwise:) and passing an instance of fully initialized $Storage to it.
  3. If a property has a default value it’s going to be used as a default value expression:
@GenerationTracker
struct Person {
  let name: String
  
  /// `= 30` is subsumed by the type wrapper
  var age: Int = 30
  
  /// synthesized init
  init(name: String, age: Int **= 30**) {
    self.name = name
    self.$_storage = .init(memberwise: $Storage(age: age))
  }
}

Let’s return to our Person example with a couple more properties:

@GenerationTracker
struct Person {
  let name: String
  
  var age: Int = 30
  @Wrapper(arg: 42) var favoredColor: Color
}

The compiler would synthesize the following initializer for Person:

init(name: String, age: Int = 30, **@Wrapper(arg: 42)** favoredColor: Color) {
  self.name = name
  self.$_storage = .init**(memberwise: $Storage(age: age, favoredColor: favoredColor))**
}

For properties without property wrappers synthesis is straightforward - synthesize a parameter with a default expression (if property had one) but with property wrappers it becomes a bit more complicated because the type of the parameter depends on the capabilities of the wrapper as outlined in Memberwise Initialization section of the property wrapper proposal.

If it’s possible to use a wrapped type then the corresponding parameter is going to get the wrapper attribute (@Wrapper(arg: 42) in our example) as well to make it easier to synthesize a backing variable _favoredColor that is passed to the $Storage initializer.

If use of wrapped type is not allowed, parameter is going to have a wrapper type:

init(name: String, age: Int = 42, favoredColor _favoredColor: Wrapped<Color>) {
  self.name = name
  self.$_storage = .init(memberwise: $Storage(age: age, favoredColor: _favoredColor))
}

Handling user-defined initializers

All of the user-provided designated initializers are going to be transformed by the compiler to inject initialization of the $_storage property. Convenience initializers are required to ultimately call a designated initializer which would make sure that $_storage is always initialized.

The initializer transformation involves:

  • A new local variable for $Storage_storage. This variable collects initial values of all type wrapper managed properties.
  • A transformation of initial user-written assignments to self.<name> to use _storage instead until self.$_storage could be fully initialized and used.
    • If a property has a property wrapper then type wrapper transformation happens after property wrapper has already been applied.

To demonstrate how the transformation could look like in the surface language, let’s consider the following example:

@<typeWrapper>
struct Person {
  let name: String
  var age: Int
  var favoriteColor: Color

  init(name: String = "Arthur Dent", age: Int = 30) {
    self.name = name
    self.age = age
    self.favoriteColor = .yellow

    self.age = 42
    print(self.age)
  }
}

Assignments to age and favoredColor have to happen on a temporary variable that represents partially initialized $Storage type because $_storage could only be initialized using init(memberwise:) after all of the properties managed by a type wrapper are initialized:

init(name: String = "Arthur Dent", age: Int = 30) {
   // !!! - injected by the compiler
   var _storage: $Storage

   // `name` is not accessed via type wrapper so it's ignored
   self.name = name

   // !!! - transformation of `self.age = age`
   _storage.age = age
   
   // !!! - transformation of `self.favoriteColor = .yellow`
   _storage.favoriteColor = .yellow
    
   // !!! - since `_storage` is now completely initialized
   // (via initializing both `age` and `favoredColor`) the
   // compiler can inject `$_storage` initialization.      
   self.$_storage = .init(memberwise: _storage)

   // !!! - As soon as `$_storage` is initialized all access 
   // to type wrapper managed properties can happen using
   // their getter/setter.
    
   self.age = 42
   print(self.age)
}

Compiler injects $_storage = .init(memberwise: _storage) as soon as _storage is fully initialized which means that all access to managed properties is always going to go through the type wrapper type instance which is consistent with compiler synthesize default initializer.

Access control

  • $Storage is an internal type because it could be used as a type witness as described in the following section.
  • $_storage variable has internal access level because it’s intended to be used only by the wrapped type and as a witness to a protocol requirement just like $Storage.
  • Synthesized memberwise initializer has internal access level just like standard memberwise initializers.

Inference of type wrapper attributes

A type wrapper attribute could be applied to a protocol as a mechanism to infer a type wrapper and add extra APIs to the conforming type:

@GenerationTracker
protocol GenerationTracked {
}

Such use implies the following:

  • Types that conform to GenerationTracked at the primary declaration automatically infer @GenerationTracker attribute. If the conformance is stated in an extension, type wrapper inference is disabled and it must be explicitly written at the primary declaration.
  • A type can only conform to one such protocol because type wrapper attributes do not compose.
  • The compiler would add following declarations to the protocol declaration:
    • associatedtype $Storage ; and
    • var $_storage: GenerationTracker<Self.$Storage> { get }

This is how transformed GenerationTracked declaration looks like:

@GenerationTracker
protocol GenerationTracked {
   // synthesized by the compiler
   associatedtype $Storage
   
   // synthesized by the compiler
   var $_storage: GenerationTracker<Self.$Storage> { get }
}

Going back to our original example from the Motivation section this inference mechanism allows us to declare var generation: Int { ... } in a protocol extension instead of having to re-define it for every @GenerationTracker type:

extension GenerationTracked {
  var generation: Int {
    get { $_storage.generation }
  }
}

Now instead of using @GenerationTracker attribute directly, Document could declare a conformance to GenerationTracked protocol:

struct Document : GenerationTracked {
   let title: String
   var content: String? = nil
}

The conformance is going to add @GenerationTracker attribute for Document and allow it to access generation property.

Opting out properties from type wrapper transformation

It could be beneficial to opt-out certain, otherwise supported, properties from the type wrapper transformation. It should be done by applying @TypeWrapperIgnored attribute to each declaration to be ignored.

struct Document : GenerationTracked {
   let title: String
   
   // Property is managed by the GenerationTracker
   var content: String? = nil
   
   // Property is managed by the Document
   @TypeWrapperIgnored var timestamp: UInt64
}

Restrictions on type wrappers

  • A protocol cannot be a type wrapper.

  • Type wrapper cannot be applied to enums, and extensions because they don't have stored properties.

  • A type cannot have more than one type wrapper associated with it (type wrappers do not compose).

  • Type wrappers are not applied to the following:

    1. A computed property.
    2. A let property.
    3. A property that overrides another property (if type wrapper is associated with a class).
    4. A lazy, @NSCopying, @NSManaged, weak, or unowned property.

Source compatibility

This is an additive feature that doesn't impact existing code.

Effect on ABI stability

No ABI impact since this is an additive change.

Effect on API resilience

Adding and removing a type wrapper to a type is a resilient change unless the wrapped type is marked @frozen.

Alternatives considered

Support for let properties

Type wrappers could be allowed to manage let properties as well, but this would mean that the underlying value of the immutable property could change across accesses, which goes against the existing behavior of let declarations. This would also complicate type wrapper types by having to support subscript(storageKeyPath:) overloading based on the key path argument.

Future Directions

Using an actor as at type wrapper

It may be possible to use a type wrapper as an actor:

@typeWrapper actor MyActor { ... }

However, applying this type wrapper to another type needs to reconcile the isolation of the type wrapper. If the type wrapper subscript is nonisolated, then the type wrapper transformation can be applied as usual. However, if the subscript is isolated to the actor, then all stored properties in the wrapped type need to be (implicitly or explicitly) async or isolated to the type wrapper. Determining how actor type wrappers interact with actor isolation is out of the scope of, but not prohibited by, this proposal.

26 Likes

I can’t quickly come up with many use cases of @typeWrapper, and thus I think the given example of generation tracking seems “too weak” compared to the complexity of this feature. Also, I’ve got some questions for two potential use cases I thought of.

  1. The example codes typically “wrap up” a class with a struct, but what about the reverse? Is that possible?
    Under some performance-critical scenes, we may need to use a private class _Storage subtype that fully mimics the layout of a struct, and use computed properties as its interface. By doing so, we can avoid unwanted copying, getting a better performance, while preserving value semantics (by implementing CoW manually). I wonder if a @typeWrapper class is capable of reducing such boilerplate.

  2. How is a type wrapper involved in Codable?
    Tweaking Codable behavior or synthesizing Codable conformance is a typical usage of property wrappers, is that possible for type wrappers? What will the synthesized coding keys be like? Does the type wrapper type shares exactly the same coding container with the wrapped type?

6 Likes

The example codes typically “wrap up” a class with a struct , but what about the reverse? Is that possible?

Yes, it's possible there are no restrictions besides making a protocol and actor a type wrapper.

I haven't read it all, so I won't comment on the pitch in general (except that it looks very well written), but I wonder why we can't just use didSet instead of private storage?

1 Like

We'd like to give implementer of the type wrapper fully control over the underlying storage without duplicating it in anyway.

This is an open question at the moment. There is no synthesis of Equatable or Codable for $Storage type. The main concern is that since the storage is managed by the type wrapper it might not make sense to allow Codable on the wrapped type because the wrapper could have a different underlying serialization mechanism. But supporting Equatable seems reasonable.

Sure, didSet is slightly more involved. But unless there's a reason you can't use it in the first example, I think the comparison would be more fair if the motivating example is written in the simplest possible way, given current constraints.

1 Like

Could you please elaborate on this? The example with computed property is attempting to avoid having to duplicate the underlying storage so I'm not sure what is didSet going to buy us here.

I might be missing something, but couldn't you write it like this:

class Document {
  private var generation: Int = 0
 
  let title: String
  
  var content: String? { didSet { generation += 1 } }
}
2 Likes

Ah I see, I can mention that approach as well!

Very interesting—still mulling over use cases; in the interim, a couple questions on interactions with protocol constraints:

  1. Is the following example well-formed, limiting @OnlyAppliesToHashable to types which conform to Hashable?
@typeWrapper
struct OnlyAppliesToHashable<S: Hashable> { 
    // ... 
}
  1. If the above is well-formed, does a type to which this wrapper is applied implicitly require the wrapper's constraints, or must they be specified explicitly? In other words, does the following imply conformance to Hashable?
@OnlyAppliesToHashable
struct NotExplicitlyHashable {}
  1. Like (2), but for protocols: is an inheritance constraint implied?
@OnlyAppliesToHashable
protocol P {}
  1. Can subscript(storageKeyPath:) specify a constraint on the key path value type? If so, does it imply that a wrapped type can only have stored mutable properties that meet this constraint?
@typeWrapper
struct SomeWrapper<S> {
    // ...
    subscript<V: Comparable>(storageKeyPath path: WritableKeyPath<S, V>) -> V {
        get { // ... }
        set { // ... }
    }
}

S in your example represents $Storage not the wrapped type and there is no synthesis of Hashable, Equatable etc. yet as I mentioned in my previous comment.

Applying type wrapper to a protocol does two things:

  • Injects associatedtype $Storage and var $_storage: OnlyAppliesToHashable<Self.$Storage>
  • Allows it infer @OnlyAppliesToHashable from conformance to NotExplicitlyHashable

Yes, there are no limitations to subscript besides that it has to have storageKeyPath that accepts a key path.

1 Like

I also can’t think of many practical use cases. I guess one useful use case would be to nicely bundle up user-defined properties into a property a framework can manage. For example, SwiftUI’s View wrapped in such a type could manage dynamic properties and auto-inject them without any reflection since it would know where to look for the storage. But other than that, I can’t think of a lot of use cases where transforming multiple properties would be that desirable. Protocols like Codable that want to generate an alternate representations of properties could perhaps be implemented with type wrappers, but in these cases some form of reflection or static conformance is more appropriate. I’m also worried about how this would generalize to sum types, since enums could also have all their cases transformed, similar to how properties are; I don’t see anything inherently different, though property wrappers might be more relevant.

This is a really good proposal for the use case you’re talking about, but I’d really like to see a feature that would cover other use cases, too. For instance:

  • Cases where the type wrapper needs to impose requirements on the wrapped type. (Say, the wrapper needs the wrapped type to provide an id, which it uses to look up the data in an external data store. Or maybe the wrapper itself contains the id and Swift synthesizes a property to access it? There are a few ways we might get at this—explore the space!)

  • Cases where the wrapped type should be a class. (How does inheritance work?)

  • Cases where people are currently using the _enclosingInstance subscript with property wrappers. (Can you design something like ObservableObject using this feature? How would it be different from the ObservableObject we actually have?)

  • Cases involving Equatable, Hashable, and Codable auto-derivation.

  • Cases where the type wrapper should provide public API, and cases where it should not. (Should we have an analogue to projectedValue? Should we have a way to provide per-property projections?)

  • Cases which simplify existing patterns. (Can you use this to automatically COW a type’s contents?)

Remember, SE-0258 had an entire section showing seven wildly different examples of property wrappers either implementing new functionality or reimplementing existing features. This helped ensure that the feature would cover enough use cases to justify its prominence in the language, and uncovered edge cases where a little extra attention to the design produced a dramatically more useful feature. I would hope to see a similarly thorough proposal for type wrappers.

40 Likes

I like the motivation for this proposal, but I don't believe that it's the best solution to the specific problems that it presents, and it's hard for me to think of examples of scenarios that it handles better than dynamicMemberLookup.

Firstly, I think that the dynamicMemberLookup solution presented in the Document example is actually good, and is what I personally would reach for in a production application: a TrackedGenerations<T> class, which can wrap a Document struct. There are significant advantages (imo) to decoupling the way data is stored from behaviors that act on it. For example, if we want to check if two documents are equal, is the generation relevant?

Here's a few more examples of where explicit storage is useful:

  • a type that proxies something with a weak reference so that consumers aren't able to accidentally make a reference cycle
  • a Recording type that stores two instances of types that conform to a protocol and can reapply vars that are set on it to an inactive instance of the protocol when it is switched to be active
  • a type which decorates a Codable struct so that it can implement ObservableObject and be used with SwiftUI

There are some code patterns that require access to storage go through a centralized or externally managed location (e.g. for mocking, proxying, or introducing additional data transformations/effects)

As written today, this proposal will not solve mocking for a typical iOS app. Let's look at the canonical example of a thing an engineer might like to mock, making a network request. None of the API to do this are properties, thus this proposal can't help in this case.

Most elements of "business logic units" of a typical iOS architecture like MVP will communicate through functions, rather than vars, so this is not an isolated issue. The codebase I work in makes heavy use of protocols for mocking, and this would not solve this problem. Ironically, the thing that will (sort of) is a dynamicMemberLookup based proxy, because it prevents engineers from calling anything except for properties on the types being mocked.

Even if all objects we wanted to mock used exclusively properties to communicate, the lack of support for let means that it would still be possible for properties that aren't mocked to slip through the cracks and be used in test code: typeWrappers would never be more than partial mocks.

I also want to +1 @beccadax's comment, especially about _enclosingInstance and auto-derivation.

Lastly: can a type have multiple typeWrappers? And what is the behavior in this case:

@MyWrapper // this wrapper prints "Foo" when the subscript is called
class Foo {

     var a: Int

     var b: Int
   
}

@ADifferentWrapper // this wrapper prints "Bar when the subscript is called
class Bar: Foo {
     override var a: Int
     var c: Int
}
let bar = Bar()
print(bar.a, bar.b, bar.c)
6 Likes

Type wrappers are not composable per this pitch.

1 Like

It's a very thought-out proposal. I'd echo the comments below that this seems to occupy a narrow niche between dynamic member lookup and property wrappers, and therefore one does wonder whether elevating this as another type of wrapper can carry its own weight.


Before I forget, though, I'd like to jot down a few detail-oriented thoughts:

Ah, but then there's a potential clash with a user-defined variable named _storage with a projection-capable property wrapper, is there not?

Given naming conventions observed in the standard library itself and likely by many others, it's almost as likely or even more likely that storage (which name implies internal-only usage) would be given an underscore than not.

This is a very thoughtful accommodation here but perhaps too clever by half: I'd suggest either $Storage and $storage or $_Storage and $_storage. Type wrappers as proposed here impose other limits (more on that below) that are much more limiting than this variable naming clash, so I think consistency here gives predictability.

I'm not sure what purpose the word memberwise: is serving here, and if we ever want to use that word for explicit synthesized memberwise initializers, or bring memberwise conformances formally into the language (it was proposed for @memberwise Differentiable, as I recall), then having the terminology used here may cause issues if there are subtle behavior differences (more on that question later). Instead, it seems that it's perfectly fine for the type wrapper initializer not to have a label here, or to name this init(storage:) instead.

It's worth noting by comparison how the design you propose accepts any name for the sole generic parameter to be used as the storage type (and we don't support labeled generic parameters in Swift yet, of course), so it's hard to see why a label here for init is necessary for clarity when it isn't for the underlying storage type.

As you'll recall, there are subtle differences with respect to initial values and side effects, which the core team has regarded as a bug:

Here, you propose a synthesized "special" initializer, which presumably will behave like Bar.init in the example above that the core team considers buggy? It would be good to spell out the behavior here and whether attaching a type wrapper to a type would change whether a side effect is observable.

Separately from the above issue, it would be important to spell out whether an optional member var foo: Int? is considered to have an implicit nil default value for the purposes of synthesizing the intiializer—i.e., init(foo: Int? = nil) {...} as opposed to init(foo: Int?)—and also mention whether this is consistent with the behavior implemented for SE-0242.

It is unclear whether you're saying that a conformance can be stated in an extension but the stored members of the extended type just wouldn't be wrapped (e.g., I could get all the default implementations of some really useful protocol but none of my stored properties would be wrapped by the protocol's annointed type wrapper), or if you're proposing to make this an error to write.

On the one hand, it seems plausible that there could be useful protocols that happen to use a type wrapper attribute but to which types can conform and do useful things without wrapping any of their properties. In that case, making it an error to state any such conformance in an extension is too severe. On the other hand, it seems plausible that there would be protocols that one couldn't conform to in a semantically sound way without wrapping properties in the way that the protocol wants all conforming types to do; then, allowing conformance without wrapping properties would weaken the guarantees of the protocol.

That said, later on, you discuss a feature to opt out members from the type wrapper (more on that later). This means that users can conform to a protocol that uses a type wrapper attribute while opting all of the members out of type wrapping. This would be isomorphic to allowing a user to declare a protocol conformance in an extension, so I don't see why it should be forbidden if the same thing can be accomplished only in a clunkier way.

This doesn't entirely make sense as a limitation.

It makes sense to allow only one type wrapper attribute as an implementation-level decision, but it seems like a type could perfectly sensibly want to doubly-wrap its storage. To use your example, it seems perfectly sensible that I could want to track generations of my Document and also confine all access to my Document's properties to a single queue. More commentary on whether this is logically impossible or whether it's a limitation of the current implementation is warranted.

Second, even if we accept the one type wrapper rule, this doesn't translate allowing conformance only to a single protocol: infinitely many protocols can specify one and the same type wrapper attribute, and a type could conform to all of them correctly without having to support composition of type wrappers. I see no reason why this would be prohibited.

I understand this point. However, let me ask an unrelated but protocol-related question: can I use a protocol which has a type wrapper attribute as an existential type? What is the behavior of accessing properties of that type-wrapped existential type?

For that matter, in your write-up, the only example of a protocol with a type wrapper attribute has no requirements. Should the use of type wrapper attributes be restricted to protocols with no other requirements, like a marker protocol except with the synthesized associated type and storage member requirement?

We have said here often that protocols exist to enable useful generic algorithms. What useful generic algorithms can be written for protocols that have a type wrapper attribute using its synthesized $_storage property, which might differ from protocol to protocol depending on its other requirements? Or if you anticipate that users might be able to write generic algorithms that make use of $_storage but don't contemplate any other protocol requirements being in the mix, should this be, simply, a single actual protocol named TypeWrapper, just like Actor is a protocol, and then users can refine it?

In what circumstances "could [it] be beneficial" to allow this opt-out—is this hypothetical or do you have a use case in mind? Wouldn't this defeat the purposes of any guarantees of a protocol?

If the desired design is that users must conform to a protocol in the primary declaration to wrap all stored members, there is value (put another way, you're putting power in the protocol author's hands, which we like to do) in saying my protocol definitely requires wrapping all conforming types' stored members with a certain wrapper. This invariant would be broken if users could just opt out some or even all of the stored members of their type.

If a user really wanted to do this, they could have an explicit nested type that conforms to the protocol and an outer type with the properties that aren't managed by the type wrapper. That way, the protocol conformance is actually affixed to a type that contains solely properties that are wrapped.

To extend your example, if I had a Document type that had a property content and not just timestamp but also >20 other metadata properties, it's pretty misleading for a user to see the declaration spelled struct Document: GenerationTracked if I can opt out every property except content from generation tracking. Instead, if opt-outs are not supported, I'd be guided to write a separate type struct Document.Content: GenerationTracked inside my type Document, which itself wouldn't and couldn't be declared as conforming to GenerationTracked; this would be much less misleading and more self-documenting.

Built-in attributes aren't capitalized; this should be @typeWrapperIgnored (although I'd hope we can come up with a better name).

However, more saliently, if we are to support opt-outs it would be great to support multiple, mutually exclusive type wrappers even if we don't support composition, and to have a syntax to label which properties are wrapped by which type wrappers.

To use the example above, let's say I want generation tracking for my document content and, separately, a single queue for accessing all of my metadata fields. If it's thought important not to require users to write separate nested types, then I ought to be allowed to use multiple type wrappers on my Document type as long as the properties being wrapped are mutually exclusive.

12 Likes

I haven't parsed the comments below the main post so excuse me if some parts of my feedback has already be provided.

First of all I'm excited to see a new wrapper mechanics and would love to see where it actually ends up being used.

Here's my raw feedback as I parse the main post:



  • It creates a new struct $Storage that contains the mutable stored properties that are declared in the Document class. The struct would look like this:

It's unclear where $Storage is synthesized and what type of access it has. Is it fileprivate and in the same file or is it possibly nested inside the wrapper type?

Instead I propose to consider the alternative to mimic the property behavior and creating a _Wrapped_Type_Name. I would highly suggest to reserve the $ prefixed namespace for possibly other feature extensions similar to how we got projectedValue with property wrappers.

On top of that _Wrapped_Type_Name could also inherit generic types and the related type constraints from it's associated main type in case it's a generic type.

@typeWrapper
struct MyTypeWrapper { ... }

@MyTypeWrapper
struct Foo<T> where T: Hashable { ...}

// creates `_Foo<T> where T: Hashable` instead of just `$Storage`

EDIT: The proposal explains way too late the synthetization of $Storage in an slightly unrelated paragraph about property wrappers.



Since $_storage is accessible from the wrapped type, it's pretty easy to add a computed property to surface the current generation value of a Document:

extension Document {
  var generation: Int { $_storage.generation }
}

That seems like a missed opportunity on the UX when accessing the synthesized storage. I don't think the wrapper user should need to access the storage via $_, it just feels somewhat wrong to me.

How about $self and $Self instead of $_storage and $Storage?

@GenerationTracker
class Document {
  // synthesized
  struct $Self {
    var content: String?
    var timestamp: UInt64
  }

  var $self: GenerationTracker<$Self>

  var content: String? {
    get { $self[storageKeyPath: \$Self.content] }
    set { $self[storageKeyPath: \$Self.content] = newValue }
  }

  ...
}

extension Document {
  var generation: Int { $self.generation }
}


Just like property wrappers a type wrapper should not require the wrapping type to be passed as its generic type parameter.

Inside a concrete type wrapper type the compiler would permit the access to T.$Self.

@typeWrapper
struct DocumentGenerationTracker {
  init(memberwise storage: Document.$Self) { ... }
  subscript<V>(
    storageKeyPath path: WritableKeyPath<Document.$Self, V>
  ) -> V { ... }
}

@DocumentGenerationTracker
class Document { ... }

It makes the strictness of these rules unnecessary:

  1. The type wrapper type must have:
  • A single generic parameter that represents a type of underlying storage (the $Storage of a particular type a wrapper is applied to).
  • init(memberwise: <#T#>) - to initialize underlying storage property.
  • subscript<V>(storageKeyPath path: WritableKeyPath<#T#>, V>) -> V - to access the underlying storage given a key path.

On top of that this would make the generic type declaration of the type wrapper much more flexible.
In case the wrapping type is generic itself, the compiler could still permit the T.$Self even if it does not exist yet.

@typeWrapper
struct Tagged<T, Tag> where Tag: Hashable {
  let tag: Tag

  var storage: T.$Self // okay within `@typeWrapper` declaration boundaries

  // similar parameter rules like with property wrappers
  init(memberwise storage: T.$Self, tag: Tag) { ... }

  subscript<V>(
    storageKeyPath path: WritableKeyPath<T.$Self, V>
  ) -> V { ... }
}

@Tagged(tag: "something")
struct MyTaggedStruct { ... }


  • Then, it replaces the declared stored properties in the Document with a single stored property of type GenerationTracker<$Storage>, like this:
class Document {
  // synthesized
  private var $_storage: GenerationTracker<$Storage>

  ... see below
}

At the point of this example it's unclear to me how $_storage is initialized or if any further synthetization is taking place in our custom init's on the main type. The "... see below" code comment is misleading and not enough of a hint that a paragraph further below will actually explain it.



class Document {
  // synthesized
  private var $_storage: GenerationTracker<$Storage>

  ... see below
}

That example has it wrong as later in the proposal it's been stated that $_storage is internal.



What about sendability of the storage type? Will it be implicitly inferred, or will it make a legally declared sendable type as no longer being Sendable?



I think @TypeWrapperIgnored should be lower cased as is not a custom type attribute, hence @typeWrapperIgnored. The final name is also debatable.



What about type wrapper declarations inside protocols?

This could be a future feature and does not have to be in the initial proposal, but I think it's important to consider it as we learned a lot from property wrappers and result builders in the past.

protocol P {
  @MyTypeWrapper
  associatedtype Foo // require that the associated type is wrapped with MyTypeWrapper 
}


  • Type wrappers are not applied to the following:
  1. A property that overrides another property (if type wrapper is associated with a class).
  2. A lazy, @NSCopying, @NSManaged, weak, or unowned property.

Shouldn't all stored properties be still captured? This feels artificial without any explanation on why this shouldn't work.



What about sub-classes?

@WrapperA
class A {
  var number: Int
}

@WrapperB
class B: A {
  // what happens here? 
  // is `$_storage` now overrides by `WrapperB` that wars around `B.$Storage` which references `$_storage` from `A`?
}


Overall great proposal and good step forward but I think we have some work to do here still.

3 Likes

Thinking out loud on the naming schemes.

In case of property wrappers $ prefixed always means that something is projected nearby from the wrapper itself.

What if we could apply something similar here as well?

@typeWrapper
struct Wrapper<T> {
  // permit within the type wrapper type to access 
  // special 'projected types' by prefixing the type 
  // with the dollar-sign
  init(projection: $T) { ... }
  
  subscript<V>(
    projectionKeyPath path: WritableKeyPath<$T, V>
  ) -> V { ... }
}
@Wrapper        // ──┐              
                //   │              
class Foo<T>    //   └───── wrapped type ─────┐
                //                            │
struct $Foo<T>  // ◀─ projected storage type ─┘                     
@Wrapper
class Foo<T> {
  // introduction of a new $ prefixed Self: `$Self` that 
  // transforms to `$Foo<T>` which is the projected type of
  // the type wrapper

  // this is either a new swapped stored property, or it should
  // be entirely hidden as such and we would special case a `$` prefixed
  // `self` keyword to access the projected `Self` type
  var $self: Wrapper<$Self>

  var value: T {
    get { $self[projectionKeyPath: \$Self.value] }
    set { $self[projectionKeyPath: \$Self.value] = newValue }
  }
  
  // alternatively this would be the same
  var value: T {
    get { $self[projectionKeyPath: \$Foo<T>.value] }
    set { $self[projectionKeyPath: \$Foo<T>.value] = newValue }
  }
}

struct $Foo<T> {
  var value: T
}

It seems that using $self and $Self in the context of the 'projection' term becomes quite elegant.


I don't want to make things more complex, but we could also consider allowing more control on what exactly the projected type should look like. Not just for storage purposes only. :thinking:


Just based on the functionality name here are some random type wrapper names that come to my mind, although I'm not sure if it's possible to implement those so that they actually do something useful.

@Reflected
@Mirrored
@Tagged
@Synchronized
@Obfuscated
@Unsafe


While @typeWrapper resembles the general purpose of the type which the attribute is applied to, I think we should rather follow the @resultBuilder direction here for the final naming scheme.

I propose to rebrand this to >> @storageWrapper << which is also a 'type wrapper' per definition, but it would have a specific set of use cases as motivated and not burn the general @typeWrapper that could in theory be way more flexible.

Just to throw in one passing comment, I think there's probably enough potential for conceptual confusion about which type is which here, and we shouldn't make it worse by intruding on self/Self with $self/$Self.

7 Likes