Pitch #2: Extend Property Wrappers to Function and Closure Parameters

I've been thinking about this, and especially the case when the property wrapper takes arguments which seems to be excluded here. Since logically speaking this annotation doesn't create a new property wrapper, it is more about telling Swift that "we want to use this argument as e.g. a @Binding", wouldn't the natural thing be to accept e.g. @Clamped but without the ability or necessity to supply arguments?

I mean, the Clamped<Int> was initialised elsewhere, and that is where the range was supplied. In the closure we simply say we want property wrapper syntax.

Hm, this is a good point. It does seem to me that at that point, expressing the argument as a wrapper is purely a convenience to the API author, with the API consumer suffering. You'd have an API declared as:

func foo(@Wrapper someArg: Int)

but which is unable to be called as foo(someInt) since the transformation will generate an invalid Wrapper(wrappedValue: someInt) call.

IMO it would be much clearer for API consumers if that were declared instead as:

func foo(someArg: Wrapper<Int>)

(This is especially the case if the someArg label were chosen specifically for an Int-type argument, with a name that was poorly suited for a Wrapper-type argument).

It feels somewhat analogous to me to the justification for the removal of var in argument declarations, (a la func foo(var someArg: Int)). Just like it's trivial for function authors to write var someArg = someArg at the top of their implementation in exchange for a clearer interface for readers, it's easy for function authors to do the dance to create a local wrapped variable in exchange for a clearer interface.

On the topic of passing a wrapper value directly to a wrapped argument, I'm curious whether you've thought much about what that would look like at the call site. Were you thinking you could just be able to call the function as:

foo(someArg: Wrapper<Int>.makeWrapper())

or would you need to do something like:

foo(_someArg: Wrapper<Int>.makeWrapper())

If the latter, would that be realized in the transformation by generating multiple foo overloads, or by some other mechanism?

It seems a bit strange to me that wrappers on properties synthesize private storage by default, but wrappers on function arguments would implicitly appear at the level of visibility of the function.

1 Like

I don't think I understand this part:

When there are multiple property-wrapper custom attributes on a single parameter (I assume that's legal from the language introducing the rules above), presumably the wrappers nest and there is a wrappedValue at each level. Can you explain how this item plays out in that scenario?

Edit: Also, I'm completely lost here. I hope you can help me.

OK as far as it goes, but I can't imagine what alternative you may have in mind…

application of a property wrapper type is only available within a closure expression.

Again, as opposed to where? Here's a declaration using a closure expression that I presume would be legal based on your example. It uses the @Percentage property wrapper on its parameter:

let inverted = { (@Percentage _ x: Int) -> Percentage in
  .init(percent: 100 - x.percent)
}

It seems obvious to me that you can't use the wrapper outside the closure expression, except to declare a new (wrapped) property.

That is, the signature of a function that contains a closure cannot include the property wrapper attibute. Instead the application of the attrbute will be up to the caller of the function, which supplies the closure argument.

After spending a long time trying to figure this out… I suspect maybe by “signature of a function that contains a closure” you don't mean “the signature of a function whose body contains a closure” but “the signature of a function that has a parameter of function type” and by “closure argument” you don't mean an “argument to a closure,” but a function parameter of function type. If so, this whole thing should be rephrased to be less ambiguous. A closure is a literal expression, not a type. Function arguments may or may not be closures but function parameters are not closures.

And even if I assume my suspected intent as detailed above, I'm still not sure what this all is trying to say. Please help, thanks!

Whether the proposal as written would change that depends on how you define “the parameter.” Is it the wrapper or the wrappedValue?

In part because I am working on interoperability with C++, I would very much like to keep this part of the proposal as is, where (IIUC) the wrapper itself is always immutable but with a nonmutating setter, the wrappedValue can be mutated. The ergonomics for uses of an UnsafeMutableCxxReference will be poor if, when used as a function parameter, it needs to be copied into a local before it can be used for mutation.

I should also add that the fact that property wrappers can only be applied to var properties seems somewhat at odds with the idea of making the outer wrapper immutable anywhere.

1 Like

I apologize up front if it was mentioned before or I missed it in the actual proposal, I haven't read the comments due to a timing constraint yet, but I will catch up later.

Is there a great reason why we cannot just shadow the parameter in the scope with a synthesized local variable?

@propertyWrapper
struct Wrapper {
  var wrappedValue: Int
}

func foo(/* @Wrapper */ parameter: Int) {
  // initialization of the wrapper
  var _parameter = Wrapper(wrappedValue: parameter)
  // shadow the parameter in the current scope
  var parameter: Int {
    get {
      _parameter.wrappedValue
    }
    set {
      _parameter.wrappedValue = newValue
    }
  }

  // ...
}

If we had property wrappers for local variables, the above function would look like this:

func foo(parameter: Int) {
  @Wrapper
  var parameter = parameter
}

The only thing that the current proposal would change, is that it would shift the wrapper closer to the actual parameter.

// after this proposal
func foo(@Wrapper parameter: Int) {
  // synthesized like above
}

One other thing. If the property wrapper is placed in the spot as proposed, we should still allow the composition of multiple property wrappers AND result builders (aka. function builders):

func bar<V: View>(
  @PW1
  @PW2
  @ViewBuilder
  parameter: V
)

Somewhereee, here.

2 Likes

When there are multiple property-wrapper custom attributes on a single parameter (I assume that's legal from the language introducing the rules above), presumably the wrappers nest and there is a wrappedValue at each level. Can you explain how this item plays out in that scenario?

Thank you for pointing this out - this does need clarification and I don't think the property wrapper mutability rules mixed with composition are specified in the original property wrapper proposal.

The computation for mutability of a wrapped parameter's accessors will be the same as it is today for wrapped properties. The computation starts with the mutability of the outermost wrapper's wrappedValue accessor, and then iterates over the chain of composed property wrappers, "composing" the mutability of each wrappedValue accessor along the way using the following rules (which are the same for getters and setters):

  • If the next wrappedValue accessor is nonmutating, then the mutability of the composed accessor is the same as the previous composed getter.
  • If the next wrappedValue accessor is mutating, then the composed accessor is mutating if the previous composed getter or setter is mutating (since both are needed to perform a writeback cycle).

If any of the property wrappers do not define a wrappedValue setter, then the wrapped property/parameter does not have a setter.

As for the confusion about property wrappers on closure parameters, your suspected intent is correct. The property wrapper attribute is allowed on a closure parameter as opposed to writing the property wrapper attribute in the contextual type of that closure. A common point of confusion for people reading early drafts of the proposal (especially those who aren't compiler engineers or don't have a clear mental model of the distinction between type attributes and declaration attributes) was why the proposed design doesn't support writing wrapper attributes in contextual types for closures so that the attribute can be inferred on closure parameters, but I agree that this attempted early clarification causes cause more confusion than it clears up. I will change the section to only discuss the proposed design and leave the rest to Alternatives Considered.

In part because I am working on interoperability with C++, I would very much like to keep this part of the proposal as is, where (IIUC) the wrapper itself is always immutable but with a nonmutating setter, the wrappedValue can be mutated. The ergonomics for uses of an UnsafeMutableCxxReference will be poor if, when used as a function parameter, it needs to be copied into a local before it can be used for mutation.

I realized the other day that not synthesizing nonmutating setters, would ruin the ergonomics of some of the use cases for this feature, including Binding and UnsafeMutablePointer. I mentioned this to Doug offline and he agreed that this part of the proposal should stay as is.

4 Likes

I apologize that it took me forever to articulate my thoughts on all of your great points and questions!

You'd have an API declared as: func foo(@Wrapper someArg: Int) but which is unable to be called as foo(someInt) since the transformation will generate an invalid Wrapper(wrappedValue: someInt) call.

I agree that it's not a good idea for a function to be able to have a label that you can't actually call the function with. This is why the proposal requires wrappers on function parameters to have a suitable init(wrappedValue:) and the rest is left as a future direction. Closures don't have this limitation in the proposal because closure parameter declarations don't affect the call-site of the closure.

IMO it would be much clearer for API consumers if that were declared instead as: func foo(someArg: Wrapper<Int>)

I think there's a difference between an API author choosing to write a wrapped parameter vs a parameter whose type is also a property wrapper. If using the wrapper attribute offers no convenience to the caller, then there's no reason to use it.

On the topic of passing a wrapper value directly to a wrapped argument, I'm curious whether you've thought much about what that would look like at the call site.

I am very much in favor of making the call-site look syntactically distinct when passing the backing wrapper directly. I very much dislike that today, memberwise initializers always use the name of the wrapped property, even if the parameter is the type of the backing wrapper.

The idea of using $ for the backing wrapper is starting to grow on me. I've often feel like the backing wrapper and projected value are conflated in existing APIs, but I've been looking at existing property wrappers and I'm noticing a pattern: If a property wrapper defines a projectedValue that isn't just self/has a different type, it often means that the backing wrapper is truly meant to be an implementation detail, and you should pass around its projection instead. State is an example of such a property wrapper.

I actually like that the author of the property wrapper gets to choose whether the backing wrapper is meant to be implementation detail. Conceptually, I view the projected value as a representation of the backing wrapper that's meant to be used as part of the API (which could just be self, or it could be some other type). This helps me reconcile your next point, which I've also been struggling with:

It seems a bit strange to me that wrappers on properties synthesize private storage by default, but wrappers on function arguments would implicitly appear at the level of visibility of the function.

So, I guess I'm suggesting that maybe we should only support passing the backing wrapper directly when it's support by the wrapper through projectedValue, and the syntax would use fn($someArg: Wrapper<Int>.makeWrapper()).

The init(projectedValue:) idea in Future Directions was intended to be a mechanism for property wrappers to opt into support for this, but I understand that this doesn't really work for wrappers with reference semantics. I'm going to keep thinking about this, and I'm open to ideas if anybody has them :slightly_smiling_face:

would that be realized in the transformation by generating multiple foo overloads, or by some other mechanism?

Not through overloads, but by modifying name lookup rules/argument-to-parameter matching in the constraint system.

2 Likes

No worries! Compiler engineers are allowed to take weekends (and IMO 1-day turnaround is not "forever", FWIW). :slightly_smiling_face:

This makes sense to me, and triggers a tangential question that should probably be addressed in the proposal (if I didn't miss it!): what happens when I form a reference to a function with wrapped arguments, e.g.,

func foo(@Wrapper arg: Int) { ... }

let bar = foo

Based on everything else I'm assuming that bar would just have type (Wrapper<Int>) -> Void, but it's probably worth addressing that use case explicitly.

Agreed!

Yeah I second this. Different things should look different! In fact, since the transformation gets injected pre-type-checking, I think it would have to be syntactically distinct since otherwise we couldn't a priori know whether a call like:

foo(arg: someFactory())

should be transformed to

foo(arg: Wrapper(wrappedValue: someFactory()))

or

foo(arg: Wrapper(projectedValue: someFactory()))

Better to just force the caller to specify up front.

So, it strikes me that the suggested init(projectedValue:) here is playing a notably different role than projectedValue currently plays in property wrappers. Currently, projectedValue is about vending API which has some important relation to the property itself, which may or may not be the backing storage itself.

OTOH, init(projectedValue:) is about creating an instance of the backing storage. In the case where projectedValue does simply vend self, there's a clear connection, but that breaks down when projectedValue vends some other type. It seems like a big assumption that the type of projectedValue will always be appropriate for initializing an instance of the wrapper. What does an author do if they want init(projectedValue:) to accept a different type than their projectedValue property? What if they don't want to offer projectedValue at all?

IMO, we should simply divorce projectedValue from this proposed future direction and introduce a separate mechanism. It could be hard to come up with a name that is distinct enough from init(wrappedValue:), but I'm picturing some magic initializer like init(initializingFrom:) which would enable the foo($arg: Wrapper<Int>.makeWrapper()) syntax.

As far as wrappers with reference semantics go, maybe this magic initializingFrom mechanism should take the form of a static func initializingFrom(_:) -> Self method?

Yeah that makes sense if we're using the $ syntax to signify the "special" call.

Overall, I'm convinced of the justification to bake these wrappers into the ABI/API of the function. It's already the case for property wrappers (although not quite so visibly so in the type of the declaration), so I doubt it will be that big of a footgun for responsible API authors who are paying attention to their compatibility.

1 Like

Can I ask a stupid question? Aren't we dealing with two different use cases here. Sometimes the closure argument etc would already be e.g. Binding<Double> or Clamped<Int> like in

ForEach($shoppingItems) { @Binding shoppingItem in ... }

and then the notation only says "give me property wrapper syntax please". In the other case the argument is originally of the plain type (Double or Int in the example) and the new syntax would initialise a new Binding<Double> from the Double?

In the first case, it seems we can allow property wrapper initialisers with arguments, but we'd have to exclude them at the point of use since they have already been set. In the latter case, we could allow them since they could just be supplied (since we are creating a new wrapper).

Not a stupid question—I had the same reaction at first, but IMO they're more closely tied than it might initially seem. The perceived difference, IMO, is more due to the existing differences between closures and function declarations. Particularly, if my assumption is correct that in

func foo(@Wrapper arg: Int) { ... }

let bar = foo

bar will have type (Wrapper<Int>) -> Void, then the connection between closure parameter wrappers and function parameter wrappers becomes more clear. When you form a reference from a closure with wrapped parameters or a function with wrapped parameters, you essentially desugar the wrappers and turn it into a normal function which accepts the wrapper type.

The difference is that with closures, you almost always immediately form a reference to the function, rather than call it directly. Perhaps there's an argument that:

{ (@Wrapper arg: Int) in ... }(0)

should get the transformation behavior and become:

{ (_arg: Wrapper<Int>) in ... }(Wrapper(wrappedValue: 0))

but I imagine that realistic use cases for that are unlikely to be found (regardless of whatever the immediate-call behavior is, it should probably get documented in the proposal!). Furthermore, in a world with fully-fledged compound name syntax for closures, you could imagine that writing:

let foo: (@Wrapper arg: Int) = {
   ...
}

would let you call that closure as:

foo(arg: 0)

without any issues.

1 Like

I start to think that most of the use cases can also be satisfied with property wrapper in local declaration:

func foo1(@Wrapper x: Int) { ... }

vs

func foo2(x: Int) {
  @Wrapper var x = x
}

func bar1(x: Wrapper<Int>) {
  @Wrapper var y: Int
  _y = x
}

It does seem more explicit and flexible compared to putting these declaration right in the argument (like what we used to do with var).

3 Likes

Right and as far as I can tell even if we introduced that syntax, it could just auto generate two function overloads with one calling the other which would expose the property wrapper as parameter.

func foo(@Wrapper param: Int)

// transforms to
func foo(param: Int) {
  @Wrapper
  var param = param

  foo(param: _param)
}

func foo(param: Wrapper) {
  var _param = param
  var param: Int {
    get { _param.wrappedValue }
    set { _param.wrappedValue = newValue }
  }

  ...
}

If you need more flexibility, then opt out of this and use local property wrappers manually.

——

The only issue I see is that the current proposed solution would also cover all permutations of the overloads no?

func bar(@Wrapper a: Int, @Wrapper b: Int)

This would have 4 versions:

  • (Int, Int)
  • (Wrapper, Int)
  • (Int, Wrapper)
  • (Wrapper, Wrapper)
1 Like

AFAICT, it should have only have one (Wrapper, Wrapper), and all the transformation happens as call site (Int -> Wrapper as needed).

That's where I have problem with this design, the API authors have little control on the type of argument it can support. You can't force Clamp(to: 0...3) since the callers will just provide a separate Clamp with different parameter.

2 Likes

This is a decent point, but AFAICT this proposal doesn't (currently) provide a way for clients of a function with a wrapped argument to pass in their own Wrapper<T> (although it seems like you'd be able to by forming a reference to the function). Perhaps that's an argument that forming a reference to the function should work more like this:

func foo(@Wrapper arg: Int) { ... }

let bar = foo

gets transformed to:

func foo(arg _arg: Wrapper<Int>) { ... }

let bar = { (arg: Int) in foo(arg: Wrapper(wrappedValue: arg)) }

I'd definitely appreciate a discussion from the authors about how wrappers with arguments fit into the future directions (e.g. projectedValue initialization).

1 Like

That's where I have problem with this design, the API authors have little control on the type of argument it can support. You can't force Clamp(to: 0...3) since the callers will just provide a separate Clamp with different parameter.

This proposal does not support additional arguments in wrapper attributes on parameters for this exact reason. I mentioned up-thread that there are a few ideas for how to support this in the future in a way that ensures that callers can't change those arguments, for example:

Yes, that's right. The function type of foo is (Wrapper<Int>) -> Void. I'll make this explicit in the proposal, thanks!

3 Likes

Great!

Even without considering property wrappers with additional initializer arguments, this does expose one potential gotcha that I can think of. For property wrappers with reference semantics, a function author may incorrectly assume that with a setup like:

@propertyWrapper
class Box<T> { ... }

func foo(@Box arg1: Int, @Box arg2: Int) { ... }

the transformation rules ensure that foo will be called with fresh Box instances for each argument. However, if a too-clever client does something like:

let box = Box(wrappedValue: 0)
let bar = foo
bar(box, box)

Then suddenly the author of foo may have to worry about exclusivity violations that they thought weren't possible.

Do you have thoughts on the tradeoffs of implementing a transformation for function references like I wrote above, where the problematic line in this example would become:

let bar = { (arg1: Int, arg2: Int) in foo(arg1: Box(wrappedValue: arg1), arg2: Box(wrappedValue: arg2)) }

?

Hi Holly, thanks for your attentive reply…

What I'm really asking about, though, is not the mutability rules, but the first sentence of item 4 that I quoted:

  1. A local computed property representing wrappedValue will be synthesized by the compiler and named per the original (non-prefixed) parameter name.

Does that mean we get a local computed property for all the wrappedValues in the nested stack, or just the inner, or just the outer one, or…? I think it would be helpful to write out an example with two levels of wrapper and show the synthesized code that results.

Thanks for your clarification on the rest. I'm going to go back and read the rest of the proposal in that light. I've been opening PRs to propose improvements/corrections to the writing. Shall I continue along that course or would you prefer I post those here?

Regards,
Dave

Does that mean we get a local computed property for all the wrappedValue s in the nested stack, or just the inner, or just the outer one, or…? .

Ah, I understand the confusion now. A local computed property is only synthesized for the innermost wrappedValue.

I think it would be helpful to write out an example with two levels of wrapper and show the synthesized code that results

Will do.

I've been opening PRs to propose improvements/corrections to the writing. Shall I continue along that course or would you prefer I post those here?

PRs for improvements/corrections in the writing are great. I see Filip has already merged some of them. Thank you for the help!