SE-0293: Extend Property Wrappers to Function and Closure Parameters

So far, we'd probably already agree that in this area, we (eventually) want

  • Supports for multiple kinds of passing
  • Out-of-the-box (no additional functions required) pass-by-storage
  • Minimal surprises for Unapplied Function Reference (UFR)
  • Similarity between function declaration and closure declaration

I believe there are a few possible alterations that could put us in a relatively comfortable spot.


Remove UFR and add fix-it.

It's unlikely we'll have any serious problems. However, unannotated passings for closure and function are still of a different kind (pass-by-storage vs -wrapped). I already discussed it at length, so I won't go further.


Removed automatic init(wrappedValue:) and allow every wrapper to use the feature.

I think this is the best one I could come up with. It put us in line of everything is pass-by-storage so there is a little-to-no surprise. It would also allow for some interesting expansion into init(wrappedValue:) later on (see PS below).


If we need to add pass-by-wrapped urgently, we could

Add argument prefix $ to the function call.

So you'd need to do foo($a: a) instead of just foo(a: a). This should add enough room for expansion should we add pass-by-storage to functions and pass-by-wrapped to closure later. At the very least, it allows for { (@Wrapper $a) } and foo(a: a). Though we then have to commit to using the $ prefix to signify pass-by-wrapped, which may not be what we want.


PS

When playing around with the idea of explicit annotation, I think that it's best to be at the argument label. There's a lot of "intuitive" extensions there.

Say normal functions accept both pass-by-storage and pass-by-wrapped:

// Pass-by-storage or pass-by-wrapped
func foo(@Wrapper a: Int) { ... }

You could pass-by-storage or -wrapped using the argument label annotation (I'll use prefix $, but it should be viable with any kind of call-site annotation)

foo(a: wrapper) // pass-by-storage
foo($a: 0) // pass-by-wrapped

now here's an interesting part, you can force the function to accept only pass-by-wrapped by embedding the annotation into the declaration

func bar(@Wrapper $a: Int) { ... } // Only pass-by-wrapped allowed

You can even distinguish between UFR with pass-by-storage and pass-by-wrapped:

let a = foo(a:) // (Wrapper) -> () pass-by-storage
let b = foo($a:) // (Int) -> () pass-by-wrapped
let c = foo // same as foo(a:)
1 Like

This is my desired outcome, as I mentioned above. The downside I see is that the current usage of $ for property wrappers is a syntactic transformation which gets applied to the "wrapped" property and spits out the projectedValue (which is in many cases the storage). If we use $ in this position, it would be a syntactic transformation applied to the storage which allows you to pass the wrapped value. I'm not sure whether this distinction would be confusing for users...

The thing I like about making pass-by-wrapped explicit at the call site is that the need for declaration-site wrapper annotations just... disappears. We'd have a function foo(a:) which is passed arguments of type Wrapper, and that can just be declared as func foo(a: Wrapper), without any special syntax at the declaration site. The special f($a: 0) call syntax would be enabled by virtue of the fact that Wrapper is an @propertyWrapper type with an init(wrappedValue:), not because there's any attribute applied to the parameter itself.

If we accept the proposal authors' assertion that using argument wrappers to define APIs which would normally traffic in the wrapped type, then I'm not sure that adding a way to force pass-by-wrapped call syntax is a desirable goal. This should probably just be defined as func bar(a: Int) with Wrapper initialized internally.

I'm not sure we want to go that far. Ah well, we can debate on that when the time comes.

Not that this also apply to pass-by-wrapper (though you need two lines for declaration and initialization). If the point is convenience, I don't see any fundamental reason not to support it.

1 Like

I don't agree with this one; I think that giving control the API authors is very important and this out-of-the-box pass-by-storage feature would violate this principle. Furthermore, it is often not desirable to expose the backing storage to public API. All in all, I think the previously discussed, opt-in method, which would require an init(passedAsArgument:)-style initializer would be better in this regard.

I think this is reasonable; it also gives us time to more thoroughly consider how to utilize unapplied references to, again, give more control to API authors.

A good future direction (the way I see it)

Syntax For functions For unapplied references
init(...) a(_wrapper: storageValue) let b = a(_wrapper:)
init(wrappedValue:) a(wrapper: wrappedValue) let b = a(wrapper:)
init(passedAsArgument:) a($wrapper: argumentValue) let b = a($wrapper:)
init(wrappedValue:) &
init(passedAsArgument:)
a(wrapper: wrappedValue);
a($wrapper: argumentValue)
let b = a(wrapper:);
let b = a($wrapper)
1 Like

I agree with this; not to mention that often when applying such a wrapper a user may want to use as a utility wrapper (to just apply a behavior to the property) and making the calling of such a function require more steps is undesirable.

I see how this approach gives us simpler rules on paper, however I think that in the real-world it wouldn't really help the user understand the feature. In fact, it may even confuse the user, who would have to learn quite different rules for property wrappers on function arguments and on struct properties – as you describe above.

1 Like

I think this is a little dangerous. This gives control to the wrapper author more than the function author. I think it is better if we add possible passing via init declaration, restrict allowed passing via function declaration, and ultimately choose it at call-site.

Passing-Kind Required Init Declaration
storage n/a (automatic)
wrapped init(wrappedValue:)
projected? init(projectedValue:)
Function Declaration Allowed Passing-Kind
func foo(@Wrapper _a: Int) storage & wrapped & projected
func foo(@Wrapper a: Int) wrapped
func foo(@Wrapper $a: Int) projected
Passing-Kind Call-Site UFR Closure
wrapped foo(a: ...) foo(a:) { (@Wrapper a) in }
storage foo(_a: ...) foo(_a:) { (@Wrapper _a) in }
projected foo($a: ...) foo($a:) { (@Wrapper $a) in }

This would give most control back to the wrapper author, and about an equal amount to the function author. The function author can now do more than "Use the wrapper syntax, and accept whatever the wrapper author has in mind".

There could also be some synthesis for init(projectedValue:) if the projectedValue has the same type as Self, and no init(projectedValue:) is defined.

We just talked past each other, sorry for the confusion.

I agree that function authors should have control over the accepted passing kinds. What I meant by out-of-the-box is that the wrapper author doesn't need to do anything to enable the function author to use pass-by-storage. That may be clearer with the diagram I provided above. I think about this much separation is needed for intuitive usage (oddly enough). What do you think?

+1

2 Likes

I'm confused now. I thought that one of the conclusions of the discussion above is that argument wrappers are only appropriate for APIs which would normally traffic in the storage type, but this sentence seems to contradict that sentiment. Could you clarify?

I actually agree that this model is a bit confusing, but I think it's far less confusing than the alternative where we say "you can declare an argument of type Int which accepts values of type Int, but you'd better not use this feature to create APIs that are intended to traffic in Int."

However, if I've misunderstood the previous discussion, or if the authors have softened on the stance that wrappers should only be applied to arguments that normally would have accepted the storage type, then I'm on board with everything that has been discussed above: pass-by-wrapped should use the bare argument name, the $ syntax can enable some sort of static func makeFromArgument(...) -> Self feature, etc.

IMO, this direction fits with the current philosophy around property wrappers. We don't, for instance, give property wrapper clients control over how the projectedValue is exposed—we let the property wrapper author say, "If you expose a property wrapped with this wrapper as API, you must let this additional API 'piggyback' on your property."

Similarly, I don't think it's unreasonable to allow property wrapper authors to say "If you allow this wrapper to cross API boundaries and accept the wrapped value as input, you must also allow your API to accept this additional input type."

In both situations, the response to a library author who doesn't want to expose the projectedValue or init(passedAsArgument:) is the same: "Okay, then don't use a wrapper attribute. Write your API out explicitly if you want fine-grained control!"

This is getting a bit speculative for a review thread, but since the author has brought it up... :slightly_smiling_face:

If we want to maintain parity with existing property wrapper functionality, then the underscored version should probably only be exposed privately. If a property wrapper type has no init(wrappedValue:) and no init(passedAsArgument:), I would be comfortable saying "okay, that's just not suitable as an argument wrapper attribute, then."

Also, if adding the underscored version of a function is a desirable future direction, we should consider the fact that if it isn't shipped with the original version of this feature, it may become source breaking to introduce later (since users are free to define their own functions with underscored argument names).

I'll agree with @Jumhyn here:

Not to mention that if a function author wants fine-grained control over the behavior of a given property wrapper, writing a custom wrapper is quite simple: they simply have to store this provided wrapper in their new wrapper type, and simply expose the restricted functionality they want for their new wrapper.

Don't worry about it, if someone's caused a lot of confusion it's me.

Talking about confusion, I should have been more specific :upside_down_face:. What I meant by "API authors" is wrapper authors. In my opinion, wrapper authors should have more control than function authors. The way I see it, the former provides the wrapper and the functionality it provides; thus, I think it's important that they be the ones who control the contexts where it is available to encourage and enforce correct use (hence my stance on the init(passedAsArgument) feature).

I understand this, but in some cases the wrapper author may deem that to encourage correct usage, the backing storage should remain private to users' types and functions. For example, when State is used, albeit initialization through the backing storage being correct, it's probably better to just expose the wrapped type, so as to retain simplicity in the API.

I think my reply to the previous point answers this; let me know if you need any clarification.

My stance is that the function part of the proposed feature is targeted towards users who had to pass the backing storage of a wrapper and then manually "property-wrap" it, and users who were looking for an easy way to apply some behavior on a property and now can. I hope to have cleared things up a little.

Despite the points made in the discussion above, I think that there are cases where a wrapper author would prefer that their wrapper's backing storage not be exposed publicly (from users' declaration). That is, the author of Wrapper may prefer that its backing storage remain private in users' function and local-property declarations; and my point is that we should respect that.

I have clarified my stance above; I'm not sure what @hborla thinks of this.

This is interesting... I don't see a reason not to.

It will be source-breaking, however I see the amount of people affected by this to be insignificant. This case is similar to the SE-296 async-await proposal: there are people who may do this, but the number of people who actually will do this will be very small. People may resort to prefixing a function's name with an underscore, however doing this to argument labels –– at least in my experience –– indicates that a declaration is private, rather than indicating that it is the underlying implementation (which I assume people would follow the behavior you're describing). I hope you follow me, let me know if you need any clarifications.

2 Likes

Careful though, crossing the module boundary, wrapper author got in cahoot with the user :smiling_imp:. Nothing stops the user from declaring their own init(projectedValue;). It's probably benign for overloads of the same passing-kind, but it could be significant for a new passing kind, e.g., users adding pass-by-projected/storage to wrapped that wants to hide it.


I think now I see the direction the authors are going for. Gotta contemplate some more :thinking::thinking::thinking:.

I see the potential for abuse. I think, though, that a function author wanting to create a special API is far more likely to create this special wrapper to constraint the original's functionality. On the contrary, a user who wants to use the backing storage besides the wrapper author's wishes not to, will at least face some friction, and may reconsider the efficacy or usefulness of their new wrapper.

I don't know if @hborla has changed her mind on this; the future direction I proposed above is based on the original version of this proposal and what we discussed with Holly at the beginning.

Got it—that gels better with my original understanding of the feature. Thank you for clearing that up!

+1. I'm totally on board with placing this control in the hands of the property wrapper author (provided that the first iteration without the init(passedAsArgument:) feature doesn't expose the backing storage at all). I think that fits best with the existing model for property wrappers!

Yeah, I agree that any breakage is likely to be very rare. If you're not concerned about it, then I'm down to punt on that for a future proposal!

I think this ultimately gets an "oh well" from me. 'Intermediary' library authors are already permitted to define all sorts of potentially-confusing API on types they don't own using extensions, so defining their own init(projectedValue:) overload isn't really anything new in that regard.

The bigger concern on this front, for me, is potential inconsistency with existing projectedValue functionality. Is it possible to enable $property syntax by defining an out-of-module projectedValue property? I thought the answer was "no."

1 Like

If we go for the direction that function author must accept all that the wrapper author provides, the closure syntax will be problematic. We don't want to pass around bare storages (we want to pass them as projected value instead), but that's exactly what the closure syntax does. That leads to disparity between the functions and closures, even with a full-feature property wrapper:

// We don't have function equivalent to this
{ (@Wrapper a) in }

It's the same issue I raised earlier (albeit in a different context):

This concerns me a lot since this makes it hard/impossible to refactor closures <-> functions. More so if we drop the UFR since there's no longer connection between the two forms.

+0.9. We do need this functionality and the need will grow with the new concurrency features. The proposal, as it stands, needs some revisions to address the review discussions.

This proposal has been returned for revision. Thank you to everyone who participated in this review thread!

Chris Lattner
Review Manager