[Pitch #4] SE-0293: Extend Property Wrappers to Function and Closure Parameters

If we truly believe in this separation between two kinds of wrappers, with the majority being implementation detail, then I wonder if an alternative here is to do away with all of @propertyWrapper(api) entirely--not just the distinction, but to scrap it all. Support only implementation detail wrappers in function parameters as callee-side sugar. Argue that anything that requires caller-style wrapping should be done explicitly.

We can consider the property wrapper version to be "more specific" and ban redeclarations that are "equally" specific in the case where there are multiple wrapped parameters.

Shadowing in Swift is pervasive, and not always with ways to "unshadow," and I don't think the result would be unsatisfactory if we left it at that. However, if we felt that disambiguation here is paramount, given the caller-side wrapping model proposed, we could permit something like explicit coercion to the wrapped or wrapper type (as Int versus as Wrapper) as a way of disambiguating.

1 Like

I'm happy to write up a section in Alternatives Considered discussing this alternative and why I don't think it's the right answer.

1 Like

I think I will also add a section about the impact of formalizing the implementation-detail vs API distinction on the language model for property wrappers, since that has been a big point of concern throughout this thread. I'd like us to wait on discussing that further until I have a chance to articulate my thoughts on it.

1 Like

Thanks, I'd be interested to read it (and your other thoughts). Appreciate all the effort that you're putting into these revisions!

3 Likes

+100. It's an impossible task to satisfy everybody's ideal for how a feature should work, but I have really admired the thoughtfulness with which you've engaged with all the proposed alternatives throughout these threads (whether they were ultimately incorporated into the proposal or not!).

8 Likes

I've made the following change to the proposed design:

  • Overload resolution for property wrapper initializers will always be done at the property wrapper declaration.

I will change the implementation so that instead of initializing the property wrapper at the call-site or in the function body, the compiler will emit a property wrapper generator function (it already knows how to do this - this is how out-of-line initialization of property wrappers works today). So, the type checking of the property wrapper initializer will always be the same between the two models. The only difference is where the generator function is called from. I'll also add a description of this to the proposal - I think it helps with the mental model.

Additionally, I've added the following sections:

The first section is an exploration of the impact of the API versus implementation detail wrapper distinction. All of the other sections are new future directions. I'm also still working on the Alternatives Considered section for @xwu 's suggestion.

4 Likes

This has been on my mind, but we removed

func foo(var a: Int) {}

that de-sugars to

func foo(a: Int) {
  var a = a
}

Now we're trying to add

func foo(@Logger a: Int) {}

That de-sugars to

func foo(a: Int) {
  @Logger var a = a
}

What's the difference here?

1 Like

Sorry, that line of the proposal isn't super explicit about how the desugaring really happens (I will fix this). The intention is for these two models of property wrapper to behave the same way in the function body. The point of the property wrapper isn't to make the parameter a var and mutable inside the function body, but to get the common effect of the property wrapper. Notice in the "further desugared" version in the proposal, there's no setter for the local text:

func insert(text: String) {
  var _text: Logged<String> = Logged(wrappedValue: text)

  var text: String { _text.wrappedValue }
}

I think it should probably also be the case that the backing storage is a let-constant.

1 Like

I do notice that we're only generating the non-mutating part of the wrappers.

Instead, I'm asking about the model at a higher level. Both var arguments and the implementation-detail wrappers are, well, implementation details. So it is sending a conflicting signal about what should be included in the function signature. I am very much confused whether this feature would fit well with the direction of Swift.

Removing var on parameters is an old decision, and I don’t remember the full rationale off-hand. I do remember that people seemed to consistently misunderstand what it meant, and they didn’t understand why changes they made to the variable seemed to disappear. It was too easy to reach for and too similar to inout in everything except its actual semantics.

I’m not sure that sheds much light on this proposal, but it’s what we were thinking at the time.

1 Like

Upon checking SE-0003 again, indeed it seems to be the confusion with inout.

Though I think it might also be a good idea to figure out what constitutes the function signature. Personally, I don't think things that are implementation details belong there, especially when they usually are sugar for another existing features.

Also, the situation here is on the rarer side.
The Implementation-Detail Wrapper (IDW) is a sugar of a future/unofficial local wrapper (no matter how we describe it). So if the local wrapper (and non-mutating wrapper) comes before IDW, it would need to meet a much higher standard.

Leaving implementation-detail wrappers to just local-variable wrapper, and use argument wrapper for API wrapper would be another clean design (of course, that means arguing argument wrapper cannot witness).

1 Like

What do you mean by "future/unofficial local wrapper"?

As for this your comment about what belongs in the function declaration, this perspective might help:

I mean the local variable wrapper, which never goes through official review. So I'm reluctant to talk as if they are in the language.

Yeah, I read that. I think sanctioning two kinds of argument wrappers into the language has its own problems with how subtle they are. Though I'd like to let it sink in first so that I can condense my thought a bit better.

Property wrappers on local variables were accepted as part of SE-0258:

Property wrappers can be applied to properties at global, local, or type scope.

and implemented in Swift 5.4

4 Likes

:thinking: that somehow slipped past me. Ah well. Thanks for letting me know :grin:

1 Like

Thanks for taking the time to write all that up, @hborla. I think it does a great job of motivating the @propertyWrapper(api) distinction. There's a couple points that I'm still confused about, and some I think would benefit from some examples.

Attaching an implementation-detail property wrapper attribute to a parameter is sugar for declaring a local variable that's initialized via wrapped value using the parameter.

Can implementation detail wrappers be used on arguments of functions with higher visibility than the wrapper itself? E.g., a private @Wrapper type on a public function.

Closures

A bit of an aside, but this is only under the API-level property wrappers heading. Are closure parameter wrappers available for implementation detail wrappers, or no?

Conceptually, the API versus implementation-detail distinction should only impact the parts of the language where there is an abstraction layer boundary where the abstraction uses a wrapper attribute.

This discussion of "abstraction layer boundaries" is highly... abstract. :sweat_smile: Are there such boundaries other than function arguments that you were thinking of when writing this?

The proposal authors believe that these two kinds of property wrappers already exist today, and formalizing the distinction is a first step in enhancing programmers' understanding of such a complex feature.

It was a bit difficult for me to tell which examples from the proposal were based on existing wrappers and which were contrived for the proposal itself. In any case, I think it would be useful to call out here a few commonly-used property wrappers on both sides of the aisle to crystallize the distinction in people's minds.

Will this break #function, #line and friends? I thought that was one of the motivating use-cases for API property wrappers.

I kind of have the same reaction as @Lantua here. Regarding your previous response:

Now that we have the language model to discuss which wrappers are API and which are implementation details, it doesn't seem absurd to me to say "you can't use that wrapper on a function argument because it is not an API-level wrapper" with a fix-it to make a shadowing local variable.

I have mild concern that the common syntactic form between API and implementation detail argument wrappers will make it far more difficult to explain the subtly different implications of using each. E.g., less careful readers may think that because the "default" version of property wrappers used on function parameters are just sugar for a local variable, they can add and remove such wrappers without any worries about ABI/API compatibility. Only later might they discover that this is not the case for all property wrappers.

Also, allowing implementation detail wrappers in the function signature makes it impossible to tell from the inspection of a function signature func foo(@Wrapper arg: Int) alone whether this function can witness a protocol requirement func foo(arg: Int), which strikes me as less than ideal.

1 Like

It is an unavoidable characteristic of parameters that they both describe the signature of the function and declare a local variable within it. This feature therefore can be used to both change the signature of the function and change the semantics of a local variable. Ignoring the second case is not decreasing the scope of the feature; it's just forcing people who want to achieve it to use a feature that's intended for a slightly different purpose and therefore has consequences they don't want. Ignoring the first case is a non-starter, because while the second case is probably useful in more situations, the first case is still important, and indeed the main reason why Apple engineers are working on this right now is to solve a first-case problem (with @Binding, which we've been fairly upfront about).

5 Likes

In the interest of increasing the syntactic difference between the API and implementation-detail cases, is it viewed as too subtle to force implementation detail wrappers to be applied to the parameter name, rather than the argument label? E.g.:

func doStuff(@Logged with arg: Int) { ... } // Error: implementation-detail property wrapper cannot be applied to the argument label
func doStuff(with @Logged arg: Int) { ... } // OK
func doStuff(with @Binding arg: Int) { ... } // Error: API-level property wrapper must be applied to the argument label
func doStuff(@Binding with arg: Int) { ... } // OK
1 Like

That would be viable in the grammar, but it seems to me like it would make the signature harder to read, and wouldn't it be undermined as a semantic cue by the fairly common case where the parameter name and argument label are the same?

3 Likes