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

I see. I don't think this proposal would close off the possibility of bringing closure argument labels back. As for the impact this proposal has, I think it depends on what exactly would be proposed. If the intention is just allow closures to be called with argument labels, then I think this proposal has some impact design-wise and no impact implementation-wise. For example, if we just take the parameter names from the closure body and use those as argument labels, we'd end up in a situation where we use the wrapped-value name for an argument with the backing wrapper type, which would not be ideal (I really dislike this behavior today with generated memberwise initializers).

If the intention is to bring full parameter info to closures, then I think this proposal doesn't add any complexity to the already-existing problem.

1 Like

I think it's worth re-emphasizing one last time that I find it extremely important that the backing storage for wrapped function arguments is potentially exposed publicly in the API under this proposal, while the memberwise initializer is exposed at most internally. My understanding is that the synthesized memberwise initializer is not public precisely because we want to make sure that all public API is explicit and intentional by the library author.

2 Likes

Having done some preliminary work in this area, I agree with this assessment. The issue of argument labels for closures is largely orthogonal to anything proposed here, as far as I can tell.

2 Likes

A agree with the rest of the post, but there are a few important pieces:

I'm sorry. I could not at all parse this sentence, even though it seems to be directly addressing my concern. Could you elaborate more on that?

Sorry if I'm not clear, my "incongruence" means that you cannot exactly mimic the generated initializer with the provided feature. If we want to simplify the mental model, we need to make it possible to match the behaviour of the generated initializer with this feature.

Let's be careful here. The choice of [pass by wrapper] and [pass by wrapped] is within the control of the author of the wrapper (but not of the lib author or the lib user). It is an important distinction since of the three parties, we want to give most of the controls of the call-site to the lib and the wrapper authors. This is one of Swift's guiding principles.


@hborla It does seem we're talking in a circle, and sometimes passing each other. If you'd kindly humour me for a moment, there are a few scenarios I would like your opinions on. Which among them are expected, and which are desirable? They of course can (and should) overlap. It'd also be great if you can elaborate on those that you deem desirable since I don't think they're ever desirable at any level of programming expertise and/or library authorship. So here goes;

  1. We get different function types when implementing memberwise initializer with this feature:

    struct Defaulted {
      @Binding var a: Int
      @State var b: Int
    }
    struct New {
      @Binding var a: Int
      @State var b: Int
    
      init(@Binding a: Int, @State b: Int) {
        self._a = _a
        self._b = _b
      }
    }
    
    let def = Defaulted.init(a:b:) // (Binding<Int>, Int) -> Defaulted
    let new = New.init(a:b:) // (Binding<Int>, State<Int>) -> New
    
  2. If we use @Lowercased with ForEach, the array must contain Lowercased instead of String

    // array must be `[Lowercased]` instead of `[String]`
    ForEach(array) { (@Lowercased a) in
    }
    
  3. Binding works with ForEach example, but won't work with normal function scenario (which are most of the Lowercased examples)

    // OK
    ForEach(array) { (@Binding a) in
    }
    
    func foo(@Binding a: ...) -> Content { ... }
    
    // Somehow OK because `foo` is of type `(Binding) -> ()`
    ForEach(array, content: foo)
    
    // Can't call `foo` because `Binding` doesn't have `init(wrappedValue)`
    foo(...) //error
    
  4. The lib user can use either pass-by-wrapper or pass-by-wrapped. The lib and the wrapper authors do not have any means to control that:

    func foo(@Lowercased a: String) { ... }
    
    // User can pass a `String`
    foo("Test")
    
    // But can also be sneaky and directly pass a `Lowercased`
    let bar = foo
    foo(Lowercased("Test"))
    
  5. The unapplied method reference doesn't match the single-expression closure

    func foo(@Lowercased a: String) { ... }
    
    // `bar1` and `bar2` have different type
    let bar1: (Lowercased) -> () = foo
    let bar2: (String) -> () = { foo($0) }
    

If none of these are desirable, then why are we paying for these? What is so desirable about the current behaviour that we can tolerate these (potentially common) surprises?

5 Likes

Yes, I agree with this. In my view (which I think is clear in the motivation but perhaps it needs to be more explicit), this feature should be used only when the API author would have written the backing wrapper type for the parameter. If the API author only wants the programmer to pass the wrapped type, they should not put a property wrapper attribute on the parameter. Adding a wrapper attribute to a parameter provides the caller a convenient way to initialize the backing wrapper by only passing the wrapped value (and the compiler will inject the init(wrappedValue:) call).

The use case is slightly different for closures because parameter information is always implementation detail to the closure body.

I understand, and I apologize! I think this conversation has been tricky because we disagree on the premise (which is totally fine, but it's then hard to discuss the feature as designed).

I'll take some time to write up answers to the rest of your questions in a little bit, but I first wanted to clarify how this feature as designed is intended to be used. I think I caused some damage to how folks are thinking about this feature with the original Motivation from the pitch thread.

2 Likes

I don’t have the time to dive deep and write a proper review for this, but from a quick read of the proposal and a skim of this thread, it seems to me that the main motivation behind the proposal is to enable this:

struct MyView: View {
  @State private var shoppingItems: [Item]

  var body: some View {
    ForEach($shoppingItems) { (@Binding item) in
      TextField(item.name, $item.name)
    }
  }
}

However, the complexity introduced around call site transformations and unapplied references doesn’t actually seem to be strictly related to that motivation. Can’t this be introduced in a way where the only transformation that happens is inside the function/closure, and where everything else is left as is? Essentially, make this less parameter wrappers and more argument wrappers, as @Jumhyn suggested. And if parameter wrappers with call site transformation are an independently important feature, they can be handled as a follow-up proposal with an independent motivation. (Apologies if this direction has already been explored somewhere – I haven’t had the time to read everything in detail.)

Ah, interesting—that's a super useful callout. IMO, this is the inverse (converse?) of the current story for property wrappers. That is, declaring a property as @Wrapper foo: Int is done when you want to expose an API that traffics in Int, but has behavior governed by Wrapper. Clearly projectedValue complicates that story a bit, but the core of the motivation for property wrappers is to replace the pattern of:

private var _fooStorage: Int
public var foo: Int {
  get { getMagic(_fooStorage) }
  set { _fooStorage = setMagic(newValue) }
}

From the perspective you're advancing, the motivation does feel a bit murky to me. In the Memberwise initialization section, issues with the status quo are presented as:

However, this can take flexibility away from the call-site if the property wrapper has other init overloads, because the call-site cannot choose a different initializer. Further, if the property wrapper is explicitly initialized via init(), then the memberwise initializer will choose the backing-wrapper type, even if the wrapper supports init(wrappedValue:). This results in unnecessary boilerplate at call-sites that do want to use init(wrappedValue:).

But these two separate issues pull us in different directions. The proposal acknowledges that some library authors do want to create APIs that traffic in the wrapped type, and presents argument/parameter wrappers as a solution. And, indeed, by most appearances, declaring a function with a wrapped parameter does create an API trafficking in the wrapped type. Yet you're saying here that this feature should not be used to create such APIs. I think this creates a confusing message for library authors that makes the feature liable for misuse.

If we don't want authors to use this feature to create API that traffics in the wrapped type, then IMO the spelling at the function declaration site needs to change. That is, the declaration site should simply have the signature they do now:

func foo(arg: Wrapper<Int>)

and we should focus on effort solely on improving the call site, perhaps with your idea from the pitch thread of an appropriate $ prefix:

foo($arg: 0) // gives the '(Int) -> Void' version of the function via `init(wrappedValue:)`

Then, @Lantua's suggestion from the pitch thread becomes the natural closure analog, as you pointed out above:

    ForEach($shoppingItems) { $item in
      TextField(item.name, $item.name)
    }

The advantages I see for this approach on the function side are numerous:

  • There's no confusion on the part of the library author about what sort values may be passed in: it's clear that any value of Wrapper<Int> may be supplied by clients, not just separate instances initialized via init(wrappedValue:).
  • It is not possible for library authors to misuse this feature to create APIs with traffic in the wrapped type rather than the wrapper type.
  • It's clear to the library author what the true type of the parameter/argument is, and there's no risk of confusion about the compatibility story when adding/removing a 'wrapper'.
  • At the call site, it's clear that there's something 'special' going on compared to a normal function call.

We could also add the ability to do something like:

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

or

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

to enable the function body sugar for library authors. (Although, I'm overall less concerned about requiring library authors to do the dance internally to properly 'wrap' the parameter, just as we require authors to do the var arg = arg dance to get a mutable version of a parameter).

ETA: I happen to think that the feature-as-proposed is also useful, but only if creating APIs which traffic in the wrapped type is intended to be a supported use case. If that's considered an anti-pattern under this proposal, then IMO the proposed solution misses the mark.

4 Likes

I don't think so; let me elaborate. The proposal in the section you're referring to doesn't really talk about API authors. It mainly addresses 'regular' language users –– API consumers, not authors. What Holly is referring to, though, is API authors, who as she says should use the proposed feature only in specific cases ("If the API author only wants the programmer to pass the wrapped type, they should not put a property wrapper attribute on the parameter.").

The way I see it, there are two parts of this proposal: the API-consumer one and the API-author one. The API-consumer part is wrapper application on function parameters and the latter is wrapper application in closure parameters. The reason behind this is simple: authors tend to use closures in their APIs (see ForEach, for instance), whereas consumers don't declare functions with closure parameters. The other main distinction is that when used by consumers, functions don't require very strict checking, since they're used within the same codebase, and often in the same type declaration as well. However, API authors are expected to very carefully select which customization points are available to the user and which are not.

Sorry for all these abstract ideas, but I think they're important to understand the important distinction between use by authors and consumers. All in all, that Holly put it very well, when authors look at the proposal they should think:

  1. You should use them however you like, but we expect cases involving a closure that takes a value with non read-only semantics to be most common.

For API consumers, though, interaction with the proposed feature will probably be very different:

  1. Property wrappers on function parameters will be used more often; probably for passing a property with different semantics (such as Binding) or for applying a custom behavior to the value passed on function call (such as Lowercased) –– with the latter being more similar to current property-wrapper use.

  2. Property wrappers, however, will rarely be used on closure parameters.

1 Like

Could you elaborate on the "complexity" these transformations introduce? Personally, I find them very straightforward. As for unapplied references, I think it's discussed thoroughly throughout the thread; for feel free, though, to ask for any clarifications.

No worries at all, this discussion has been extremely helpful in my understanding of the feature.

If this feature relies on API authors reading all the minutiae of the proposal in order to use it correctly, then that's a flaw, IMO. We should strive for a design which naturally guides authors towards correct usage.

Under this proposal, when I declare a parameter as @Wrapper arg: Int, I've created a parameter which a) is declared to have type Int, and b) can be passed arguments of type Int. That a library author sees this and creates a wrapped-argument API intended to traffic only in Int (and not Wrapper) should not, IMO, be considered a failure of the author to properly understand the feature—it's a failure of the feature design to make misuse difficult.

This seems... a bit too black-and-white to me. I'm mostly on the "API consumer" side of things in my daily work, but I've defined plenty of functions with closure parameters. Also, even an API consumer often wears both hats—defining APIs which are used by other components of the same app/project, even though it's all owned by one team. I don't think it's possible to make the distinction between consumer/author as cleanly as you're attempting to.

Half-serious question: if the function-parameter-wrapper feature isn't meant for use by API authors, why not ban the use of wrapper attributes in public functions? Then there would be no concern of misuse by API authors, and they would be free to provide a 'convenience' entry point which accepts the wrapped parameter type and forwards it appropriately.

2 Likes

You don't need to apologize! Communication is a two-way street. We're accomplices :smiling_imp:.


I'll make use of the scenarios I laid out earlier.

Even with this notion, it's not a clean separation between function vs closure. It's been trivial to refactor closures into functions (scenario 3):

ForEach(...) { (input) in ... }

// refactored to
func itemContent(input: ...) { ... }
ForEach(..., content: itemContent)

It is especially common where refactoring is encouraged, like in a SwiftUI environment. So the consumer side consists of both functions and closures, not just closure. With that dichotomy, we need features that work with functions (author side) or ones that work with both functions and closures (consumer side).


That's why I want to change the problem statement to better reflect regular usage. We can likely agree that the overarching problem is, "property wrappers are common and we want to pass them around". Let's look into existing wrappers.

Looking into SwiftUI and Github, I categorize them into four categories:

  • Shared-storage wrappers use some mechanisms to share the wrapped values between instances, e.g., Binding, UnsafePointer.
  • Utility wrappers adjust the wrapped values to maintain certain invariances, e.g., Lowercased.
  • Entity-bounded wrappers are tied to class/struct they're declared in and are not meant to be passed around as is, e.g., Published, Environment.
  • Proxied wrappers are entity-bound wrappers that vent out proxies when they are passed around, e.g., State. For SwiftUI, the currency proxy is `Binding

It's easy to see that the shared-storage and proxied wrappers, as @Jumhyn put it, traffic in the wrapper type. OTOH, the utility wrappers traffic in the wrapped type (scenario 2) as the caller doesn't need to configure them and they could be regarded as an implementation details at an API level. What dictates the traffic type is the category that these wrappers are in, not where they're used.

From this, I conclude that the decision of whether a parameter traffics in the wrapper type or the wrapped type should depend on the wrapper author (scenario 4). The best way to achieve that is to have the decision depends on the type of the wrapper itself (e.g. Binding traffics in Self, Lowercased traffics in String) regardless of where they are. This is different from the current idea that the wrappers in functions and closures traffic in different types (scenarios 2, 3 and 5).

Wouldn't you agree with this assessment, @hborla, @filip-sakel?

PS

Categories of some of the wrappers (hidden since it's a little long).

* Namespace could technically be a proxy wrapper.
** Some wrappers are not proxied as is but could use Binding. They might as well stay as entity-bound wrappers, with proxy potential.


If you agree, I propose slightly different rules:

  • Wrappers with init(wrappedValue:) are passed-by-wrapped,
  • Wrappers without init(wrappedValue:) are passed-by-wrapper,
  • The ABI always use wrapper, and
  • Unapplied method reference is created with a boilerplate so that bar1 and bar2 below have the same type (scenario 5):
    func foo(@Wrapper a: ...) { ... }
    
    let bar1 = foo
    let bar2 = { foo($0) }
    


If you disagree, I still have some comments left on the old problem statement. In particular,

I that case, I think we should not use init(wrappedValue:) at all even if the wrapper has it. Instead, we could add init(argumentValue:) in the future (though that could easily increase boilerplate).


I don't think we need too much discussion on section Mutability of composed wrappedValue accessor. Property wrapper already has a notion of mutation (you can't mutate property wrapper on struct if the struct is not mutating). We could easily say that the argument wrappers are immutable (and the inout argument wrappers are mutable).


(scenario 1) is problematic for Property-wrapper parameters in synthesized memberwise initializers.


Sorry for a lotta noise. The implication of this feature can be far-reaching and will easily ripple throughout other wrapper-related features. I wanna make sure we get it to the best shape possible.

I'm pretty sure I've said all that I want to say. Imma go hibernate now :bear::zzz:.

6 Likes

I really want to underline this point. I have put in a nontrivial amount of study into the proposed feature as designed, and my impression of it basically boils down to this: To the extent that the feature as proposed happens to align with what @Jumhyn spells out above (and indeed, in the simplest use cases, they do align), the proposed behavior makes sense to me. To the extent that the feature as proposed does not align with the above, the proposed behavior is comprehensible to me only with significant effort. While I can largely see how the motivation discussed in this forum leads to the end result, since the proposed design sometimes runs counter to users' legitimate intuition as @Jumhyn spells out above, it undermines usability even in straightforward scenarios because one can't be confident when the feature behaves intuitively or not.

7 Likes

You're right.

I see the confusion that can be caused to library authors.

You're right it's not as clear-cut as I presented it.

I think that would create too much confusion around this feature. Not to mention that –– at least to my knowledge –– no other feature, except for member-wise initializers, uses access control as a way to prevent use by library authors.

1 Like

I like your categorization, it gives us something more tangible to work with!

Agreed.

Originally, it was our intention to allow this, but seeing the complexity it introduced, we decided to leave it out of the proposal. I do think, though, that some form of differentiation between the above, passed-by-wrapped and these passed-by-wrapper cases is required. This differentiation could be tackled by a future proposal that enables the latter case, with the following potential syntax:

extension Binding {
  
  init(projectedValue: Self) {
    self = projectedValue 
  }

}


func toggleLights(@Binding lightsAreOn: Bool) {
  //                       ^ ~~~~~~~~~
  // The name implies a boolean value, not a binding. 
  lightsAreOn.toggle()
}

toggleLights($lightsAreOn: $livingRoomLightsAreOn)
//           ^~~~~~~~~~~~
// The dollar sign ($) implies use of the projected value,
// and thus 'Binding'. 

I think this would be reasonable.

Do you find @Lantua's rules intuitive? If so, I think they'd be reasonable as a goal for this and future, related proposals.

Overall, I think the rules outlined do make sense. They will not, though, be what this proposal will leave us with. That is, I think that non-init(wrappedValue:) wrappers and their handling of unapplied function references deserve their own discussion. On the other hand, what will change if we chose to work towards this rule is the handling of unapplied references and probably the ABI of functions that utilize the proposed feature. Unapplied references may also have to be left out of this proposal. As for ABI, it will probably need to change.

1 Like

I certainly agree with your agreement with @Lantua’s first point! I also agree with you that @Lantua’s second point requires some syntax to indicate what’s going on at the point of use. I am not sure about the ABI, but in any case it isn’t generally a user-facing issue. I agree that unapplied references could be left out of this proposal for further discussion.

2 Likes

I agree that such a rule would be confusing. The rhetorical point I was making with the non-serious half of the question was that based on what’s been discussed in this thread, there is already an implicit policy of “using function argument wrappers on public functions is probably inappropriate.” The fact that this proposal doesn’t strictly enforce the policy doesn’t make the rule any less confusing, IMO.

This is a good point—it’s weird to have feature availability predicated on access control. The existing pattern we have is to control the exposure of such features (e.g. make the synthesized memberwise init internal, make the synthesized backing storage for wrapped properties private, etc.). So I guess a closer analogue would be to allow wrappers on parameters of public functions, but only expose the Wrapper version of the function across module boundaries.

Like I said, though, I don’t ultimately think this results in a super usable model—it would just make the model proposed here (if I’ve understood everything correctly :sweat_smile:) more explicit in the language rules rather than relying on ‘soft’ enforcement (i.e., endless explanations on StackOverflow/Using Swift to the effect of “you’re using the feature wrong”).

1 Like

The current version of the proposal doesn't give a lot of control to the API author in that regard and, yes, it can be misused by authors more easily.

I guess that would be a more accurate analogy.

1 Like

Let's rename it to pass-by-storage and pass-by-wrapped, so that we don't need to squint our eyes on every line ;)

Praise be. I don't expect the whole shebang from a single proposal anyhow. I just want to make sure that we end up in a relatively good position and direction, and that we don't paint ourselves into a corner.

That may be for the best. We could even add a fix-it for unapplied references:

// Error
let a = foo

// Suggested fix-it
let a = { foo($0, $1, $2, ...) }

That should be what most developers expect if not a little more verbose than necessary. AFAICT, it is unprecedented to have a function that's not a first-class citizen (we can't use unapplied reference), but it looks fixable in the fullness of time. I'd be more worried if we end up in a bad spot by choosing the wrong behaviour.

:thinking: You got me an interesting idea, since we can have the function author select the appropriate argument with that:

func foo(@Wrapper a: Int) // Pass by wrapped
func foo(@Wrapper $a: Int) // Passed by storage

We can even extend it to closures:

{ (@Binding $a) in } // Passed by storage
{ (@Lowercased a) in } // Passed by wrapped

That does give the author full control of traffic type at the call site.


Maybe this proposal can instead be more geared toward pass-by-wrapped, everywhere. So the ForEach example would need to change from Binding to Lowercased:

// pass-by-wrapped, uses `[String]` instead of `[Lowercased]`.
ForEach(...) { (@Lowercased a) in }

That would be easier to grasp with

Binding doesn't work yet, but Lowercased works anywhere.

instead of

Binding works here, does not work there*. The converse is true about Lowercased.

2 Likes

Yes. When you write a wrapper attribute on an instance property, the backing wrapper type stays private (unless the generated memberwise initializer uses the backing wrapper type, in which case the backing wrapper is exposed as internal). When you write a wrapper attribute on a parameter, the backing wrapper type is exposed through the type of the function. It's already been heavily noted that this is a point of concern.

My argument is that the generated memberwise initializer is a source of complexity for property wrapper initialization, and its behavior should change. The intention is to change the memberwise initializer to use this feature, but first we need a way to pass the backing wrapper directly. So, for scenario 1:

This is intentional. I think generated memberwise initializers should always take the backing wrapper type, but still allow the call-site to use the init(wrappedValue:) shorthand initialization.

This isn't the motivating use case. The use case is for ForEach to be able to vend a binding directly to a collection element in the original data source to use in the closure body. The property wrapper attribute in the closure body is so the user can opt into the nice property wrapper syntax instead of having to access .wrappedValue and .projectedValue manually.

If you want to create a new property wrapper from the closure parameter (which will be passed from ForEach), you can use a local property wrapper. e.g.:

// array is [String]
ForEach(array) { a in
  @Lowercased var v = a
}

Per the proposal:

This can be lifted when we allow a special calling syntax to pass the backing wrapper directly. That said, this does prevent a valuable use case that you mentioned, which is factoring a closure into a function to be reused.

Yes they do. If they want users to only pass the wrapped value, they can write:

func foo(a: String) { ... }

Like I mentioned before, the property wrapper attribute is a replacement for:

func foo(a: Lowercased) { ... }

in order to provide the call-site a convenient way to initialize Lowercased, or pass an existing one.

I see the single expression closure case as a way to reference a property wrapper parameter function and make it take in the wrapped value type. This is also the way that you preserve default arguments when you want to reference the unapplied function. I agree that this isn't great, and it would be better if there was a way to reference the function while preserving information that's attached to parameter declarations.

I've been thinking more about the use case where the library author wants to do some kind of argument normalization or assertions on the values that are passed (which is one of the only use cases I can think of where the library author would only want the call-site to pass a wrapped value), and I think this use case might be better served by a function wrapper feature rather than a property wrapper on the parameter. A function wrapper feature wouldn't change the way the function is called at all and it wouldn't change the type of the function, but rather wrap the function itself in some pre- and post-call code that's defined by the wrapper. For example, you might imagine something like:

@Precondition { start <= end }
func scan(start: Int, end: Int) {
  ... 
}

which would assert start <= end before the scan function is called.

Your "call-site only" suggestion is interesting because the spelling at the parameter declaration of @Wrapper arg: Int makes the most sense for users in understanding how to call the function. If the $ syntax is adopted for initializing a backing wrapper for a property-wrapped instance property, this extends naturally to using $ to pass the backing wrapper directly to a property-wrapped parameter. However, I do understand the possibility for API authors to misuse the feature.

1 Like