[Pitch #3] SE-0293: Extend Property Wrappers to Function and Closure Parameters

The various tradeoffs were covered more in-depth in the previous review thread, but to summarize my personal distaste for exposing the storage-typed version of the function automatically:

  • It breaks with the current precedent that backing storage is private, and does so in a non-obvious way. Library authors may not realize that they are exposing API which traffics in the storage type.
  • It makes the addition/removal of a function-argument-wrapper attribute a potentially source-breaking change.
  • It prohibits the use of initializer arguments in the wrapper attribute. There's no point in declaring a wrapper as @Asserted(.greaterOrEqual(1)) if any client can simply pass an Asserted instance with a completely different validation.
  • It removes API control from both the property wrapper author and the author of the wrapped-argument function.
9 Likes

Right—I'm questioning whether there's really value in that semantic connection. IMO, it would be useful and powerful to allow property wrapper authors to define multiple different types that could be used to initializer a wrapper instance.

(ETA: if we really want to maintain the "projection" metaphor, then IMO it's not unreasonable to talk about the types which can be projected "into" the wrapper, and the type that gets projected "out from" the wrapper).

The difference is that the decision to provide init(projectedValue:) API is entirely within the control of the property wrapper's author. I think it's a reasonable position that the property wrapper author is best-positioned to determine what types may be used to initialize an instance of the wrapper, and I don't believe it's the case that there's a bijection between such types and the types it makes sense to vend as projectedValue. Any type T for which the conversion from T to Wrapper loses information would clearly not be suitable.

Even if you take the position that in most cases, any type which Wrapper could be initialized from would also be suitable to vend as projectedValue, we're simply constrained by the lack of type-based overloading for properties (as well as the general ergonomic difficulties related to overloading on return type), which doesn't apply to init(projectedValue:). Perhaps it would make sense to allow property wrappers to vend multiple different types via projectedValue, and maybe such a feature could be designed down the road. However, I don't think that the lack of such a feature today should constrain the utility of init(projectedValue:).

1 Like

I'm not sure if it's unreachable in a strict sense. Downstream libraries could later define init(wrappedValue:). Given that init resolution is performed at the callee, that does sound like a valid scenario (albeit hardly a useful one).


Wrappers that can be created from projections tend to have reference semantics. Wrappers that are struct-based like Binding are fine since they do handle reference semantics via some other means. It is troublesome for wrappers that use class instances to maintain reference semantics.

I'm thinking of a design similar to this:

@propertyWrapper
public class Storage {
  static func storage(for identifier: Identifier) -> Storage { ... }
  
  let projectedValue: Identifier
  var wrappedValue: ...
}

In this case, it's important that if we retrieve the storage via identifier (its projection), we should get a particular instance of Storage, not a new one.

That said, I've been a little back-and-forth about the necessity of init(porjectedValue:) being static methods. It's not hard to wrap Storage into a class-backed struct:

@propertyWrapper
struct StorageWrapper {
  private var storage: Storage

  init(projectedValue: Identifier) {
    storage = Storage.storage(for: identifier)
  }

  var projectedValue: Identifier { storage.identifier }
  var wrappedValue: ... { storage.wrappedValue }
}

which is very similar to what other wrappers are doing.


One thing I haven't seen discussed: does function with wrapped argument satisfy protocol requirement?

protocol Foo {
  func foo(x: Int)
}

struct Bar: Foo {
  func foo(@Wrapper x: Int) { } // ??
}

It looks quite tricky here, esp. with ABI & API shenanigans.

1 Like

I think this might be an interesting approach for opting into support for passing the backing wrapper directly if that becomes compelling in the future. That's also where preserving reference semantics would be important. Initialization from a projected value is useful as a concept in general, and I'd like to keep this proposal focused on that aspect.

It's intentionally restrictive. I think we've gone a little too far in the direction of "the projected value is for arbitrary API". This is sort of how the feature was proposed and how it's been documented, and I think this has done some damage on how people think about projected value. Now, I'm obviously not the original designer of property wrappers, but I don't think projectedValue was designed for exposing any random API. It's intended to be a projection of the property wrapper - in other words, a view of the property wrapper from the outside. Sure, sometimes you might want to simplify that view down to a Bool, but in the vast majority of cases that I've found of property wrappers in the wild, the projection is some representation of the wrapper type, whether that's the wrapper type itself or a separate type that vends more restricted access to functionality on the wrapper type (e.g., a read-only history of an undo/redo property wrapper). This design is strengthening the tie between $ and "projection of the property wrapper" rather than $ and "a magical property wrapper operator".

EDIT: I do understand your point that perhaps there could be a separate type that's appropriate for "projecting into" the wrapper since not all projection-storage type relationships are bijective wrt initialization, but I want to carefully consider this use case because of my next point:

I want to be really, really careful not to design init(projectedValue:) as a feature that can be misused as a mechanism for (arbitrary) implicit conversions between a type to a property-wrapper type. Your example above with Binding and Box seem to be edging on this (mis)use case.

This should be fine because these two functions can't be called in the same way. When referencing one of these functions (without calling), you'll need a contextual type to disambiguate, e.g. an explicit type annotation. If it turns out we need to spell the first version differently on the compiled-code side, I think mangling can solve that problem.

1 Like

Thank you so much for writing this up! I've incorporated this summary into the proposal.

1 Like

Right. This use case also strengthens the motivation for allowing function parameters with no init(wrappedValue:).

I think of storage(for:) as an easier way for reference types to just return a reference in projected-value "passing" (instead of creating a new instance). I don't think, though, that avoiding wrapping a reference type in a struct deserves discussing here. Don't get me wrong, I would like to discuss this in a dedicated thread as a future direction, but as Holly said:

2 Likes

Good question. I've been thinking about witnesses and overrides and I think the answer is that a function with property-wrapper attributes can only be a witness or override if the original has the same wrapper attributes.

Irrespective of any of the actual functionality of this proposal, I really love this focusing of the documented intent for projectedValue. Thinking of projectedValue as a specialized "view" of the storage makes a lot of sense to me.

I still think it's not always the case that there is only one such specialized view that makes sense for any given wrapper, even though projectedValue boxes us in to only providing one such API. But your justification for the limited scope of the feature in this proposal is totally reasonable—if I feel strongly about opening up that restriction I can always write up my own proposal once this feature ships rather than trying to shoehorn my preferred behavior into y'all's. :slight_smile:

Yeah, this is a great point. Personally I'm not super concerned about this since a) the wrapper author must explicitly provide the various init(projectedValue:) overloads, and b) the conversion/initialization does always get explicitly marked by $, so it doesn't seem to me to fall into the "implicit conversion" danger zone as conventionally understood.

One more thing that comes to mind on this topic is that the proposal reads:

Presence of an init(projectedValue:) that meets the following requirements enables passing a projected value via the $ calling syntax:

  • The first parameter of this initializer must be labeled projectedValue and have the same type as the var projectedValue property.
  • [...]

Is the init(projectedValue:) functionality supported if the wrapper doesn't provide a projectedValue property at all?

1 Like

No - I've clarified this in the proposal:

To enable passing a property-wrapper projection to a function with a wrapped parameter, property wrappers must declare var projectedValue , and implement an init(projectedValue:) that meets the following requirements:

I've also added an Acknowledgements section to credit the thoughtful contributions from the community in the development of this feature.

2 Likes

Awesome—in that case, I have no qualms about this proposal as a resting place to evaluate whether any further expansion of the init(projectedValue:) feature is desirable.

I really like this, and not just because you've mentioned my name there! Proposals so often undergo significant transformation from the pitch phase based on community feedback, and it's great to see people receive recognition for putting effort into these discussions even if they can't devote the time and energy to (co-)authoring a proposal and ushering it through review themselves.

I think it would be awesome if this became part of the template for proposals going forward... :eyes:

5 Likes

I like this version so much better than the previous one. So far, I only read the parts the I found the most concerning from the previous proposal, but I'll read it in full later.

The call site semantics still feels dangerous to me, because the parameter's type (Wrapped or Projected) doesn't match the function's type at the parameter's location (Wrapper).

I wonder if it can be helped by requiring the call site parameters to be annotated with @Wrapper for both wrapped and projected values. E.g.

func foo(@Wrapper value: Wrapped) { /*...*/ }
foo(@Wrapped value: wrapped)
foo(@Wrapped $value: projected)

If I understand correctly, you think it's dangerous that the types used in the transformed function and the call-site are different –– the former uses the backing storage's type, when the latter uses the wrapped or projected values' types.

I don't see how that is dangerous, though. Could you elaborate on that?

I don't see how that would help.

I would say "yes" if we don't drop the following requirement:

With a required init(wrappedValue:) (or init(wrappedValue:params:)) the function type signature is preserved, so it makes sense to satisfy a protocol requirement. Users would also be free to add or remove property wrappers to their function parameters with no effects other than the ability to use the projected value in the function body.

Without that requirement, we should probably need a specific error: the general

Type 'S' does not conform to protocol 'P'
Do you want to add protocol stubs?

would add a separate function with apparently the same type signature of the original, but with all the property wrappers removed. That may be confusing.


Currently, property wrappers cannot be declared in protocols. Are you referring to subclassing only?

FWIW, result builder variables/parameters are allowed to be overridden by non-result builder properties/parameters, but I don't know if it was explicitly intended to work this way.


From the user perspective, the Wrapper type will never be considered. Also, the backing function will be unreachable, so if a function with the same type signature of the backing one exists, it wouldn't conflict.

The function type is always preserved, though. When the initializer becomes available, the caller still need to call it with the type of the wrappedValue. We can also probably do something with the error message.

The problem is that the init calls for argument wrappers are resolved at the caller sites, while the entire protocol requirement is resolved at the callee site (rather, the conformance declaration site). This means that the protocol requirement vs concrete type calls to the same implementation can use different initializers, which is quite a problem, even from the semantic standpoint.

If I understand correctly, you are saying that having a function to meet a protocol requirement regardless of the eventual property wrappers attached to its parameters would be problematic. I apologize if it's not what you meant.
In my opinion, if a protocol requires a func foo(a: String) method, then my function func foo(@Lowercased a: String) { ... } meets that requirement as long as it can be called as foo(a: someString). The same would apply for func foo(@Uppercased a: String) { ... }.
If you need a restriction on the property wrapper that should be attached to a given parameter, that restriction should be specified in the protocol itself.

The issue is that to call your func foo(@Lowercased a: String) requires a call-site transformation, which wouldn't be possible in a context where the only type information is provided by the protocol. Imagine:

protocol P {
  func foo(a: String)
}

struct S: P {
  func foo(@Lowercased a: String) { ... }
}

func callFoo<T: P>(_ t: T) { t.foo("") }

let s = S()
callFoo(s)

Within the body of callFoo, the compiler cannot perform the call-site transformation, since t.foo(Lowercased(wrappedValue: "")) is invalid for arbitrary conformers of P. To support the callFoo(s), expression, we'd have to do something like generate special witnesses for each requirement of P which are already pre-transformed, so that writing S: P would end up generating something like the following under the hood:

extension S: P {
  @_implements(P.foo(a:))
  func _P_foo(a: String) { self.foo(a: Lowercased(wrappedValue: a)) }
}

I'm not even sure that such a scheme would be sound, I'd have to think about the specifics more carefully.

In any case, it's relatively straightforward for a user to define a wrapper type/function if they really need to implement a protocol requirement with the same name but different argument wrapper attributes.

While it might be possible to come up with a sound scheme for allowing requirement implementations to differ in their argument wrapper attributes, there's a lot of design work that would need to be done, and IMO it seems orthogonal enough that it should be addressed in a separate proposal, if at all.

5 Likes

Another question occurred to me while reading the SE-0295 review: are function argument wrappers available in all function-parameter-like positions? I.e., can they be used on enum case associated values and/or subscript index parameters?

The only additional declaration that property wrapper attributes can be applied to in this proposal is a parameter declaration. So, this will apply to subscript parameters, because a subscript declaration contains a parameter list with parameter declarations. However, this will not apply to enum case associated values, because even though they're "parameter-like", grammatically those are tuple types.

2 Likes

One case that I can think of that would break this scheme is @Asserted. In fact, at the beginning of the second revision of this proposal no transformation occurred at the call-site; property-wrapper transformation happened within the function, as I think was proposed in the first pitch. However, that proved to be problematic for wrappers that expect initialization at the call-site. In this case, @Asserted requires call-site initialization for obtaining the file and line literals. All in all, function-local transformations seem to break some wrappers without any action by the author.

Thus, I think that a viable solution for protocols would be to require property-wrapper annotations in protocol requirements. If you think about it makes sense as well, because something like buy(quantity:of:) (with an @Asserted wrapper) would currently have a signature buy(quantity:of:file:line:).

Just thinking out load. This could still be allowed in the future right?

Some bikeshedding:

enum E {
  case foo(@W label: Int)
  // compiler generates some static initializer
  // .foo(label:)
  // .foo($label:)
  // baking storage of the associated value remains W
} 

let e = E.foo(label: 42)
// sugar for
let e = E.foo(_label: W(wrappedValue: 42))

switch e {
// only one of these can be used, not both at the same time
case foo(label: var intValue):
  ...
case foo($label: var projectedValue):
  ...
}

// this is basically sugar over
// case foo(_label: let wrapper):
//   var intValue = wrapper.wrappedValue
//   var projectedValue = wrapper.projectedValue

The baking storage remains private, like in the current proposal. The value will be wrapped and have some effects applied to it. I can only see benefits in terms of usage and consistency.

Thoughts?

4 Likes