SE-0258: Property Wrappers (third review)

For a property foo that has a property wrapper, the type metadata will contain (only) the _foo synthesized storage property, which has the wrapper's type. That's what will show up in reflection.

  • Doug
2 Likes

I would even say that the wrapper/storage type should not show up in a protocol - only the projection type.

The proposal will run through tomorrow, @Douglas_Gregor will you have time to review the rest of the feedback from this thread?

I‘m especially interested what you think about the feedback I posted above regarding retroactive extensions of projectedValue, then the drop of the strict access level requirement on inits and projectedValue, and finally a clarification for the correct projection of composed property wrappers.

With the current design I think the mentioned rules are artifacts from older iterations that can be lifted now.

The $ variable uses projectedValue of the outermost wrapper. That means we'll be directly projecting from the synthesized storage property.

The implementation already does this; I'll clarify the proposal.

Doug

Why are we doing that? The wrapped type comes from the inner wrapper, why is it different for the projection? The compiler should be able to traverse and check the types the same way through multiple calls to wrappedValue and check if the inner most wrapper has a projectedValue.

Is there a logical reason I misunderstand here? (I just want to understand the rationale for this behavior.)

There's a philosophy behind these restrictions: a property wrapper type should behave the same way in all contexts, and one should be able to look at the primary definition of the property wrapper type to understand how it's going to behave when used as a property wrapper.

Loosening the access restrictions, or allowing projectedValue et al to be provided via extensions, chip away at our ability to reason about what a given property wrapper type will do when it is applied to a property. I don't think we should make it any harder to reason about property wrappers just now---as it is, we need to figure out how to better document them and get people used to them.

Doug

2 Likes

The API on the wrapper type itself is accessible via _foo within the scope where the property was defined (because _foo is private).

Doug

@Douglas_Gregor I have one question regarding typealias: Do you think it would be possible to allow property wrapper lookup to include typealiases in the given scope?

This could allow for property wrappers to capture the Self type, which would be very powerful:

@propertyWrapper
struct ModelField<ModelType, Value>
    where ModelType: Model
{
    var wrappedValue: Value
    init(initialValue: Value) {
        self.wrappedValue = initialValue
    }
}

protocol Model { }

extension Model {
    typealias Field<Value> = ModelField<Self, Value>
}

final class Planet: Model {
    @Field var name: String

    init(name: String) {
        self.name = name
    }
}

This currently gives an error: Unknown attribute 'Field', even though Field is known at this scope. For example, I can do this:

final class Planet: Model {
    var name: Field<String>
    init(name: String) {
        self.name = .init(initialValue: name)
    }
}
1 Like

To add on the projection issue from above. Take SwiftUI for example. If I wrap a property with State I‘ll get a Binding as the $ projection. Both types come from State.

Now if I had a custom wrapper which would always project a String and if I compose it with State, I will loose the Binding as the projection but my wrapped type remains untouched, it will just be guarded by an additional behavior of MyWrapper.

@State var number: Int
// var number: Int { ... }
// var $number: Binding<Int> { ... }

@MyWrapper @State var number: Int
// var number: Int { ... }
// var $number: String { ... }

This seems contraintuitive.

Okay I would say "it‘s okay if these limitations stay in the initial implementation", but I think we should consider lifting them in the future as with the current design the additional behavior that they would give us will still remain predictable. I strongly think that these are artifacts from previous iterations of the proposal and are unnecessary just as it was unnecessary to require a fixed generic Value like in the very first pitch.

It's partly that I've been thinking of this in terms of the init(initialProjectValue:) suggestion (see below), which biases slightly toward directly initializing the storage:

That said, we could implement a rule stating that at most one of the wrapper types can have a projectedValue, and that's the one that will be used for the $ variable. If we do get an init(initialProjectedValue:), it would likely work only on the outermost wrapper type.

Doug

Yes, I consider the current behavior to be a bug.

Doug

Awesome, that's great news! Thanks Doug :slight_smile:

Should this then be
var wrappedValue: Self

Instead of
var wrappedValue: Value

I’m a little confused. My apologies if I am missing something.

For SwiftUI specifically, you would need the @State to be first, so the framework can find it... in which case we'd get an error because Int is not the same as String.

Doug

Hmm I‘m not sure I follow that idea. @Lantua can you provide an example what you had in mind with such behavior or pin point me to a post I might have forgot about? I currently don‘t understand why we would want to initialize the projected value instead of the wrapped value.

If we're still talking about, e.g.,

@Lazy var foo: Int = 17

Then wrappedValue is about getting to the Int (the Value generic parameter) and you don't need a projectedValue.

Doug

Well switching the sides shouldn‘t raise any errors in that example as the most inner wrappedValue will be Int but the projection will be of type Binding<MyWrapped<Int>> then.

In the previous example I would need to write _number.wrappedValue.projectedValue to get a Binding<Int> value.

In case of the swapped order I now need to write $number[\.wrappedValue] or just $number.wrappedValue due to @dynamicMemberLookup. To get String projection I’m required to write again _number.wrappedValue.projectedValue.


It‘s still is unclear to my why init(initialProjectedValue:) could or should ever be a thing and why the projection is build to potentially allow that init, which is not even decided yet. The behavior of the projection must be set in stone now, it would be too late to change it later if we decide not to allow init(initialProjectedValue:) in the future.

This makes sense, but it does raise a composition-related observation. I created an Inspectable property wrapper that has reference semantics and conforms to BindableObject.

The specific use case in the linked example does not require it to be used with ObjectBinding because it performs a manual frame-based animation driven by a RenderClock (backed by CADisplayLink). However, in a more typical app context it would need to be composed with ObjectBinding in order to invalidate the view when the value changes.

Unfortunately, I'm not sure the current proposal offers a way to compose these wrappers in a way that is as clean as might be desired. Users would need to compose @ObjectBinding @Inspectable in that order and use _foo.value to access the Inspectable instance when injecting it into the view hierarchy. Having to use _foo.value isn't terrible, but it probably wouldn't be obvious to most users either (at least until they learned the pattern).

I have thought about tweaking Inspectable to project a Binding, having users use _foo when injecting the Inspectable instance into the preference value instead. If you add this rule then it would not be composable with ObjectBinding at all. In that world, supporting a Binding projection and the ability to compose inside an ObjectBinding would require two separate Inspectable property wrappers. I think it would get confusing for users pretty quickly to know which wrapper to use in a specific context.

I don't have any concrete suggestions to offer, but I think this is an interesting real-world example of composition. One possibility would be to bring back $$, but this time with consistent meaning. $$foo would refer to the projection provided by the wrapper just inside the outer wrapper. $$$foo would refer to the projection provided by the third layer, etc. I haven't thought through whether this is a good idea or not, but it would be one way to make all of the projections available.

2 Likes

From proposal.

@propertyWrapper
enum Lazy<Value> {
  case uninitialized(() -> Value)
  case initialized(Value)

  init(initialValue: @autoclosure @escaping () -> Value) {
    self = .uninitialized(initialValue)
  }

  var wrappedValue: Value {
    mutating get {
      switch self {
      case .uninitialized(let initializer):
        let value = initializer()
        self = .initialized(value)
        return value
      case .initialized(let value):
        return value
      }
    }
    set {
      self = .initialized(newValue)
    }
  }
}

later in proposal.

extension Lazy {
  /// Reset the state back to "uninitialized" with a new,
  /// possibly-different initial value to be computed on the next access.
  mutating func reset(_ newValue:  @autoclosure @escaping () -> Value) {
    self = .uninitialized(newValue)
  }
}

_foo.reset(42)

Specifically I am confused about the above reset .reset and how does it get attached to an Int. Maybe its a typo?