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

The linked section provides the following justification:

This transformation does not apply to closures, because closures are not called with argument labels.

which seems like a bit of a non-sequitur to me. After all, functions aren't necessarily called with argument labels either, and presumably the transformation will still be applied to unlabeled arguments. Obviously, once you form a reference to a closure, the parameter wrapper decays to the wrapped type, but what happens with a direct call? E.g.:

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

Yeah, you're right. The way I worded that justification isn't really what I meant to say. I'm not sure what the right formal terminology is, but the distinction that I meant to outline is calling a function directly using its name (which gets the transformation) vs calling via an expression/value with a function type (no transformation). Closures always fall under the second case. The reason behind this distinction is that the wrapper attribute isn't part of the function type, but rather the parameter declaration, so you need to be matching the argument with the parameter declaration directly in order to do the transformation. Does that make sense?

1 Like

I understand the argument, but the final design is, in my view, confusing for similar reasons to @Jumhyn's concerns regarding unapplied references.

Broadly, it seems that we have very different mental models of what the property wrapper is being applied to. We agree that it's not being applied to the parameter type. Nonetheless, it is being applied to the parameter; your view seems to be that it's somehow attached to the label. This seems much less intuitive, and also (as @Jumhyn points out) hard to justify when it applies to unlabeled parameters.

Or, perhaps to look at it a different way, I understand the value of this proposal, but I'm unconvinced that it should introduce two "ways" of calling a function, and I'm not sure most users will understand why that distinction exists. It seems a much greater conceptual expansion (in breadth and depth) to Swift than is necessary to get the obviously nice behavior that's motivating the proposal. And if it's necessary, I think it merits its own review separate from this proposal.

5 Likes

On the distinction between direct passing and via initializer, it's important (and potentially useful), but it'd be prudent to give the choice of that distinction to the function author, not the function user.

If we instead treat them as two different annotations:

func x1(@passByStorage @Wrapper a: Int)
func x2(@passViaInitializer @Wrapper a: Int)

x1(a: Wrapper(...))
x2(a: 0)

it gives the author the ability to choose between the two. We can then assign appropriate defaults:

struct WithInit { /* has init(wrappedValue) */ }
struct NoInit { /* no init(wrappedValue) */ }
func bar<T>(_: T.Type, _: (T) -> ())

func foo1(@WithInit a: Int) // @passViaInitializer
func foo2(@NoInit b: Int) // @passByStorage

// Type check, (generally unambiguous)
bar(Int.self) { (@WithInit a) in } // @passViaInitializer
bar(WithInit.self) { (@WithInit a) in } // @passByStorage

Then the unapplied method reference could follow the annotation:

let a = foo1 // (Int) -> ()
let b = foo2 // (NoInit) -> ()

let c = { (@WithInit a) in } // Depends on the "default" passing
let d = { (@NoInit a) in } // (NoInit) -> ()

It'd follow the progressive disclosure ideology. It'd also fit well with the member-wise initializer.

struct A {
  @WithInit var a: Int
  @NoInit var b: Int
}

// Generates
extension A {
  init(@WithInit a: Int, @NoInit b: Int) { ... }
}

In the current behaviour, A.init simply becomes (WithInit, NoInit) -> (), not (Int, NoInit) -> ().


We definitely could use more motivations. The proposal itself mentioned Binding, State, and Published (in passing), but none of these wrappers are well-suited for this proposal; State and Published are entity-bounded wrappers and Binding doesn't have init(wrappedValue). This shows that not every wrapper would be appropriate for this syntax. So it'd be helpful to at least find a class of wrappers where this feature would be useful upon. The proposal extensively uses LowerCased, but, well, a single wrapper doesn't say much about its usability esp. if the reader is skeptical of the proposed form.

My view is that the wrapper attribute is attached to the parameter declaration. The subtlety is that unapplied function references lose any attributes that were on any parameter declarations. FWIW, this isn't a new subtlety (for example, this behavior can be observed with other declaration attributes on parameters, like result builders). I do agree that it's not intuitive that a function may need to be called differently when not called directly using its name.

1 Like

I guess the disconnect is around what it means to "lose" a wrapper attribute from a parameter declaration. I appreciate that the underlying ABI suggests that the 'bare' function retains the wrapper type, but from a user perspective I think that "losing" an attribute translates to eliding it from the source code, so losing the attribute from func foo(@Wrapper arg: Int) gives us func foo(arg: Int).

The closest analog I can think of that exists today is what happens with default arguments, which does give "two ways of calling a function":

func foo(arg1: Int, arg2: Int = 0) {}

foo(arg1: 1) // ok!
let bar = foo
bar(1) // error :(
bar(1, 2)

However, I'd consider this a drawback of the behavior of default arguments, and would love it if there were some way to get at the 'defaulted' version of a function, like let bar = foo(arg1:) to cause arg2 to be defaulted.

1 Like

Assuming this proposal goes through, we will now have 2 distinct types of custom attributes (result builders and property wrappers) that can be validly applied to function parameters.

I'm not exactly sure what point I'm trying to make about this (and I definitely don't think it should block this proposal), but all I'm saying is it seems totally possible that a third custom attribute type could actually make sense as the last attribute (e.g. @Wrapper @Builder @SomethingNew). Or it could possibly only make sense between a wrapper and a builder. Or it could only make sense as the first attribute. Or it could make sense in any order.

Obviously you cannot be expected to take all future possibilities into consideration in the present, but I guess all I'm saying is, "the result builder attribute is always last" may not hold true forever.

What would happen to this distinction if argument labels were returned to closures, as was discussed (dare I say, promised?) in the aftermath of SE-0111? Or is that avenue now closed forever?

1 Like

About closure vs function, I don't think it's at all about labels. In fact, if I want to use Lowercased, I'd pretty much expect the closure to take in String. If I see this:

ForEach(...) { (@Lowercased string) in
  ...
}

I'd expect that it operates on an array of String instead of Lowercased.

The opposite happens with Binding. If I'm using it, I'd pretty much prefer that it's passed as Binding<X> regardless of where it actually is declared.

// Should be called with `foo(someBinding)`
func foo(@Binding x: Int)

// Expecting an array of `Binding`
ForEach(...) { (@Binding value) in
}

The type of argument passing seems to depend more on the [type of the wrapper] than the [function vs closure].

It is still a huge pain for me losing labels in closures (argument labels for functions seems to naturally extend to closures and makes the code a lot more readable) and something embarrassingly I do not think I can do much to bring back on my own :/.

I think this is an orthogonal discussion, and I don't know enough about what was discussed after SE-0111, but the property wrapper transformation at the call-site would only be possible if closures somehow preserved parameter declaration attributes.

@hborla Would it be possible to use a pre-transformed ABI, and generate post-transformed boilerplate when being captured by a variable? So we'd have (Wrapper)->() ABI, and can still treat it as (Int)->() on the API level. That should also address problems mentioned in Caller-Side Property Wrapper Application section.

I do think it's possible to transform a closure or a function reference that has a property wrapper parameter so that it can be called with the wrapped type. @Jumhyn suggested such a transformation in the pitch thread:

However, the motivating use case for closures as laid out in the proposal is opting into property wrapper syntax for closures that accept property wrapper types.

EDIT: Remember that this is a general problem with parameter declaration attributes and other information like default arguments. I think it's worth solving the problem holistically, rather than specifically for property wrapper parameters.

1 Like

I read that. I disagree that it should apply universally to all closures. I believe we'll arrive at a better ground if the decision lies in the type of the wrapper. So,

struct WithInit {} // has init(wrappedValue:)
struct NoInit {} // no init

func foo1(@WithInit a: Int) // (Int) -> ()
func foo2(@NoInit a: Int) // (NoInit) -> ()

let bar1 = foo1 // (Int) -> ()
let bar2 = foo2 // (NoInt) -> ()

let baz1 = { (@WithInit a) in } // (Int) -> ()
let baz2 = { (@NoInit a) in } // (NoInit) -> ()

It leads to a lot less surprise if since now the function, Unapplied Method Reference (UMR), and the closure syntax would have the same API (and ABI to since they're all using wrapper on the ABI level).

1 Like

Spitball: perhaps the 'expert-level' feature here is to differentiate between 'parameter wrappers' and 'argument wrappers': the former would act like closures do in this proposal (i.e., the sugar is totally internal to the function body, and exposes the wrapper type), whereas the latter would behave like functions in this proposal (i.e., the sugar is applied externally, and the parameter type is exposed).

Then, a function author could write something like:

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

to get the closure-type behavior for functions. That is, internal to this function you would still have the synthesized $arg and _arg members, but it would be called as foo(Wrapper(0)) and the foo reference would have type (Wrapper) -> Void.

Conversely, whenever we get argument labels for closures (I still plan to work on this when I have a chance!), you would be able to do something like @xwu suggests to get the 'argument wrapper' behavior for closures:

1 Like

I'm not sure that is ideal. It does give the API user a lot of power, and remove the same amount of control from the API author. They can no longer decide what kind of wrapper is used for the argument.

All the more so that I want to urge us to be careful not to introduce a design that is incongruent with the existing features, and the current design is incongruent with the generated memberwise initializer with property wrapper.

I brought it up to see what the impact would be to adding labels back if we adopted this proposal. How much work does this proposal add to that scenario?

1 Like

The original author's wrapper would still get used (indeed, since the underlying ABI takes the wrapper type, it would have to be used). I picture that in a situation like this:

func foo(@Lowercasing arg: String) { ... }
let baz(@Uppercasing arg:) = foo
baz(arg: "hello")

the 'fully-synthesized' version would look like:

func foo(arg _arg: Lowercasing) {
  var arg: String { /* accessors to _arg.wrappedValue */ }
}

let baz(arg:): (Uppercasing) -> Void = { _arg in
  var arg: String { /* accessors to _arg.wrappedValue */ }
  foo(Lowercasing(wrappedValue: arg))
}

baz(arg: Uppercasing(wrappedValue: "hello"))

Of course, AFAICT, nothing in this proposal (except the unapplied method reference issue) precludes this as a future direction, so I don't want to spend too much time on it in this review thread. But I think it would help unify the closure and function concepts under a holistic rule.

I see. I went on a different route instead.

The current design is consistent with existing features. Property wrapper attributes are declaration attributes. Wrapper attributes are applied to a declaration in order to prompt the compiler to synthesize supporting declarations (e.g. the backing storage). The wrapper attribute does not affect the type of the declaration it's applied to, only the synthesized declarations. The attribute is not preserved through the type system. You can initialize the backing storage indirectly using the original declaration, or you can initialize the backing storage directly using the synthesized declaration. This is all consistent with the design of property wrapper parameters. The only inconsistency is that the backing storage in a type context is private and with parameter, the backing wrapper type is exposed through the function type, but this inconsistency already exists today with generated memberwise initializers.

The design is also consistent with the semantics (and limitations) of declaration attributes today on parameters, and other parameter declaration info like default arguments. Any parameter declaration info is not preserved through closures or unapplied function references, and it only impacts the call-site when calling a function directly through the function name. To give you a quick example of this limitation:

func test(@ViewBuilder closure: () -> Text) {}

test {
  let name = "Holly"
  Text(name)
}

let fn = test
fn {
  let name = "Holly"
  Text(name) // error: missing return in a closure expected to return 'Text'
}

The one major inconsistency I can think of with existing language features is that a property-wrapped parameter declaration itself is actually a local variable declaration, and it can be mutated if the wrapper has a nonmutating setter. The backing storage is what's immutable here. This was brought up in the pitch thread, and some folks argued that this is useful behavior when using a wrapper that's intended to have reference semantics, like Binding or UnsafeMutablePointer.

The design is intentionally "incongruent" with generated memberwise initializers for types with wrapped properties, because memberwise initializers today have a lot of complexity when property wrappers are involved. As stated in the proposal, this design aims to simplify the mental model of property wrapper initialization. Initializing a property wrapper through a generated memberwise initializer is the only place where the programmer cannot choose how to initialize the backing wrapper (and only in some cases is this true), and sometimes the convenient init(wrappedValue:) synthesis is not available even if the wrapper supports it. I understand the argument that this design exposes the backing wrapper while folks may have already internalized that the backing wrapper is private, but this rule is already broken for many cases today with generated memberwise initializers. To fully replace today's generated memberwise initializers, we need a way to call the function and pass a backing wrapper directly, so this proposal is a step toward that.

3 Likes