SE-0293 (second review): Extend Property Wrappers to Function and Closure Parameters

Hello Swift community,

The review of "SE-0293: Extend Property Wrappers to Function and Closure Parameters" begins now and runs through February 8, 2021. The proposal is available here .

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to the review manager. When emailing the review manager directly, please keep the proposal link at the top of the message.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available here.

Thank you,

Chris Lattner
Review Manager

13 Likes

How come we did not get to see the call site for

func buy(
  @Asserted(.greaterOrEqual(1)) quantity: Int,
  of product: Product,
) {
 
}

What happens if I try to pass -1? Compilation error?

Also, call site, does it just look like this:

buy(quantity: 3, of: teslaModelS) ?

Call-site syntax and semantics are covered in detail in the dedicated section Call-site semantics.

Yes, but you need the argument label:

buy(quantity: 3, of: teslaModelS)

Assertions written in user or library code aren't evaluated at compile-time. This would result in a runtime assertion failure.

+1! I think the proposal has reached a great spot and rounded off all the sharp corners. There are interesting options for how to expand on this feature in the future, but this feature stands on its own and provides a great resting point to evaluate those future directions.

  • Is the problem being addressed significant enough to warrant a change to Swift?

Somewhat neutral on this point. I haven't personally felt limited by the lack of this feature, but I'm convinced of the hypothetical utility based on the examples provided by the proposal authors. It seems useful overall and there's not really anything else that this syntax could reasonably mean, so IMO the cost in terms of increased complexity/cutting off future evolution is relatively low.

  • Does this proposal fit well with the feel and direction of Swift?

Yes, IMO. This feels like natural way for property wrappers to be applied in the argument position, and the changes in this iteration of the proposal bring the semantics into line with what I would expect from the existing precedents set by property wrappers.

  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

N/A

  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

Extensive participation in both the first and second pitch threads.

1 Like

No that example, the buy methid, is not covered in call site semantics, which I find weird and unclear. Would be great it id could be added.

(Yes ops missed the label quantity: ...)

Hmm would it be possible to get compile time errors? At least for literals..? I understand it is not possible for non-literals.

I don't think it has been for any code. Not that it can't be, but it'll probably be a separate proposal.

3 Likes

Yes you get compile time errors when typing the literal 1337 as a parameter for a function accepting a UInt8. There are a few other more sophisticated examples than that that I can’t think of right now though.

Yes understood that it probably will be a separate protocol, was just curious! That would be a killer feature imo!

You are probably looking for Compile-Time Constant Expressions for Swift (proposal here).

6 Likes

Yes thanks for sharing, saw that earlier! Would be an amazing combo together with Property Wrappers for functions!

I guess it would be possible to combine them later on.

One question I have is:
How does this proposal apply to enum case functions?
e.g:

enum Foo {
  case int(@StoredAsUInt8 Int)
}

It doesn‘t, see recent discussion in the pitch thread. This would require a seperate discussion/pitch/proposal:

That said, it would probably be possible, but it‘s not covered by the current proposal.

3 Likes

Is a wrapped parameter allowed in a method used to satisfy a protocol requirement?

protocol MyProtocol {
    func myFunc(value: Int)
}

class MyClass: MyProtocol {
    func myFunc(@DaryleWrapper1 value: Int) { /*...*/ }
}
enum MyEnum: MyProtocol {
    func myFunc(@ChrisWrapper4 value: Int) { /*...*/ }
}

The wrapper should only affect the internals; it shouldn't be visible when making a call (unless you use a projected value to initialize). This perspective of the user-interaction model being identical in both cases is why we petitioned enum cases to qualify for matching protocol type-level properties or methods. (The same question could apply to classes (and actors): can a derived type's method change/add/remove a parameter's wrapper?) I only saw one line concerning protocols, and it seemed to require the protocol's declaration of the method to specify a wrapper, which all matching types must copy.

This is the approach I took initially when designing this revision. The transformation looked something like:

func test(@Wrapper value: Int) { ... }

// transformed to -->

func test(value: Int) {
  __test(value: Wrapper(wrappedValue: value))
}

func __test(value: Wrapper<Int>) { ... }

Where that special __test backing function is only exposed in the ABI when any of the wrapped parameters have a projected value and init(projectedValue:).

There are a number of sharp corners with this approach, but it really falls apart when additional arguments to the wrapper initializer come into play, including arguments in the wrapper attribute, and other defaulted arguments to init(wrappedValue:) and init(projectedValue:). Any approach which applies the property wrapper in the callee makes these default arguments resilient, which is not consistent with how default arguments work today. Even if we're okay with making default arguments resilient for wrapped parameters when passing a wrapped value, default arguments would be really hard to reason about if sometimes they're evaluated in the caller and sometimes they're evaluated in the callee, because the value of the default argument can be different depending on where it's evaluated.

This tradeoff and others are discussed in the Callee-side property wrapper application section of Alternatives considered.

EDIT: It's worth pointing out that your example is the same as this:

protocol MyProtocol {
  func myFunc(value: Int)
}

class MyClass: MyProtocol {
  func myFunc(value: Int) { 
    @DaryleWrapper1 value = value
    ...
  }
}

enum MyEnum: MyProtocol {
  func myFunc(value: Int) { 
    @ChrisWrapper4 value = value
    ...
  }
}

When myFunc is called in a generic context, the wrapper attribute on a parameter in conforming types isn't providing anything to the client - there's no call-site transformation and there's no documentation benefit either since the wrapper attribute is not in the protocol requirement. I question the usefulness of the property wrapper attribute on a parameter in this case instead of simply using a local wrapped variable. I have yet to see a compelling use case of property wrappers that are truly meant to be implementation detail that isn't satisfied by local property wrappers.

2 Likes

I agree. It would also be coherent with the rules already in place for wrapped properties: if a protocol requires a property var foo: T, then any wrapped property @Wrapper var foo: T satisfies that requirement (it's the generated computed property var foo: T { get set } that satisfies the requirement).

Not quite, but it still applies in this case.

It's not the interaction model being identical but the type signature being identical. A function func foo(x: Int = 0) can be called as foo() but wouldn't satisfy a protocol requirement since its type is (Int) -> Void instead of () -> Void. The same applies for @autoclosure parameters: they can be instantiated as values, but they do not satisfy protocol requirements since func foo(x: Int) has a different type signature than func foo(x: @autoclosure () -> Int).
In this case both func foo(x: Int) and func foo(@Wrapper x: Int) share the same type signature.

That being said, since the proposed rules are more restricting, it can be seen as a possible future direction. Until then (if the direction appears to be viable), users can use local wrapped variables as suggested.

While I agree that the situation with protocol isn't ideal. I think improving it takes a lot more design consideration than only generating thunk. Especially that the init overload resolution is performed at the call site. As is, it seems to neither discounts any direction nor diminishes the current proposal's utility. Unless, of course, someone comes up with a better and more holistic story.

First of all: I really like the overall proposal.

There is on thing that I find suboptimal: While the proposed solution for closures may be helpful in some situation, I think there is a much more ergonomic solution. Let me explain:

The proposal suggests that an API author would create a function that takes a closure like this:

func useValue(_ handler: (Binding<String>) -> Void) { ... }

Then let‘s suppose there are two users of this API. User A wants to access the value as well as the binding. It would look like this:

useValue { (@Binding value) in
    print(value)
    doSomething(binding: $value)
}

One thing bothers me in this example: Every time, user A wants to use useValue(), she has to include @Binding.

Then let‘s turn to user B. He just wants to use the plain value. However, he does not know that property wrappers can be used in closures. So he does this:

useValue { binding in
    handleValue(binding.wrappedValue)
}

This does not look good to me. And even if user B would know property wrappers, he would have to include the @Binding:

useValue { (@Binding value) in
    handleValue(value)
}

My solution to this problem would be the following:

The API could be written like this:

func useValue(_ handler: (@Binding String) -> Void) {
    let binding: Binding<String> = getBinding()
    handler(binding)
}

User A could then write this:

useValue { value in
    print(value)
    doSomething(binding: $value)
}

No @Binding is needed.

User B, who doesn‘t know of property wrappers would look at the API, probably google @Binding and find out about the concept. Then, he could just write:

useValue { value in
    handleValue(value)
}

Or even simpler:

useValue(handleValue)

Here is another example:

let songTitles: [String] = ...
songTitles.forEach { title in
    print("\($title.index): \(title)")
}

This would eliminate the need to use enumerated().

And one last example:

ForEach($entries) { entry in
    HStack {
        Text("\(entry):")
        TextField("Entry", text: $entry)
    }
}

What do you think?

Thank you for the review!

This suggestion has come up a few times (and I should probably add this to Alternatives considered). I personally am against allowing property wrapper custom attributes as type attributes, because I think it would confuse the property-wrapper declaration model. For example, if you can write @Binding String, then why can't you write var value: @Binding String? Or would that work under this model and now there are two ways to declare property wrappers?

I also think it's useful to have some indication (via syntax) at the parameter declaration that you have a property wrapper. I agree that sometimes the wrapper attribute doesn't need to be written out explicitly. There's a special bit of sugar in the proposal for the case where a property wrapper's projected value is the same type as the wrapper itself:

For closures that take in a projected value, the property-wrapper attribute is not necessary if the backing property wrapper and the projected value have the same type, such as the Binding property wrapper from SwiftUI. If Binding implemented init(projectedValue:) , it could be used as a property-wrapper attribute on closure parameters without explicitly writing the attribute

So, if Binding were to implement init(projectedValue:) your last example could look like this:

ForEach($entries) { $entry in
    HStack {
        Text("\(entry):")
        TextField("Entry", text: $entry)
    }
}

I like this because you don't have to write @Binding, but you still have an indication that you're working with a property wrapper in the closure body.

3 Likes

Thank you for the detailed response. Could this particular concern be alleviated by writing it as follows?

(@Binding _: String) -> Void

or

(@Binding _ value: String) -> Void

This wasn‘t clear to me. Thank you for mentioning it. I think this is better than having to write @Binding every time. However, I think, the solution I mentioned would create many opportunities to improve existing functions (such as my forEach() example) by retaining the function signature (as seen by the user) but allowing users to access additional information (such as an index).

1 Like

I think what you're suggesting is allowing property wrapper attributes only in parameter type positions to force the argument function to have the same wrapper attribute on that parameter, and to allow inference of the wrapper attribute on closure parameters. I still think a syntactic indication that you have a property wrapper is valuable, but I also don't think there's anything in this proposal preventing such a feature from being proposed in the future.

FWIW, there are a few more downsides that I outlined in the first review:

That sounds correct. However, I fear that by the time such a feature will be added, many APIs such as

func doSomething(handler: (Binding<String>) -> Void)

will have been added. It would then be a breaking change to change it to:

func doSomething(handler: (@Binding _: String) -> Void)