Pitch #2: Extend Property Wrappers to Function and Closure Parameters

That's where I have problem with this design, the API authors have little control on the type of argument it can support. You can't force Clamp(to: 0...3) since the callers will just provide a separate Clamp with different parameter.

This proposal does not support additional arguments in wrapper attributes on parameters for this exact reason. I mentioned up-thread that there are a few ideas for how to support this in the future in a way that ensures that callers can't change those arguments, for example:

Yes, that's right. The function type of foo is (Wrapper<Int>) -> Void. I'll make this explicit in the proposal, thanks!

3 Likes

Great!

Even without considering property wrappers with additional initializer arguments, this does expose one potential gotcha that I can think of. For property wrappers with reference semantics, a function author may incorrectly assume that with a setup like:

@propertyWrapper
class Box<T> { ... }

func foo(@Box arg1: Int, @Box arg2: Int) { ... }

the transformation rules ensure that foo will be called with fresh Box instances for each argument. However, if a too-clever client does something like:

let box = Box(wrappedValue: 0)
let bar = foo
bar(box, box)

Then suddenly the author of foo may have to worry about exclusivity violations that they thought weren't possible.

Do you have thoughts on the tradeoffs of implementing a transformation for function references like I wrote above, where the problematic line in this example would become:

let bar = { (arg1: Int, arg2: Int) in foo(arg1: Box(wrappedValue: arg1), arg2: Box(wrappedValue: arg2)) }

?

Hi Holly, thanks for your attentive reply…

What I'm really asking about, though, is not the mutability rules, but the first sentence of item 4 that I quoted:

  1. A local computed property representing wrappedValue will be synthesized by the compiler and named per the original (non-prefixed) parameter name.

Does that mean we get a local computed property for all the wrappedValues in the nested stack, or just the inner, or just the outer one, or…? I think it would be helpful to write out an example with two levels of wrapper and show the synthesized code that results.

Thanks for your clarification on the rest. I'm going to go back and read the rest of the proposal in that light. I've been opening PRs to propose improvements/corrections to the writing. Shall I continue along that course or would you prefer I post those here?

Regards,
Dave

Does that mean we get a local computed property for all the wrappedValue s in the nested stack, or just the inner, or just the outer one, or…? .

Ah, I understand the confusion now. A local computed property is only synthesized for the innermost wrappedValue.

I think it would be helpful to write out an example with two levels of wrapper and show the synthesized code that results

Will do.

I've been opening PRs to propose improvements/corrections to the writing. Shall I continue along that course or would you prefer I post those here?

PRs for improvements/corrections in the writing are great. I see Filip has already merged some of them. Thank you for the help!

The more I think about it, the more this feature parallels the var declaration in the function argument. They both don't affect the call site (though this one might alter the ABI), and the not-yet-existed boilerplate are relatively short:

func foo(var a: Int) { ... }
func bar(@Wrapper a: Int) { ... }

vs

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

It looks like a stopgap solution that would be removed once we get local property wrapper (if we get local property wrapper), except now that the bar for removing feature is much higher compared to back then.

3 Likes

A property wrapper on a parameter does affect the call-site, unless it's a closure parameter. In the closure parameter case, it's just enabling syntactic sugar in the closure body.

It looks like a stopgap solution that would fade away once (if) we get local property wrapper

Local property wrappers have already been implemented: https://github.com/apple/swift/pull/34109

Local property wrappers don't help this feature as designed. The function type using the backing wrapper instead of the wrapped value type is essential for the closure use cases:

AFAICT, whether or not the parameter is marked with a wrapper doesn't change how the caller looks:

func foo(@Wrapper a: String) { ... }
func bar(a: String) { ... }

foo(a: "1234")
bar(a: "1234")

The difference would lie only at the ABI, which is still malleable. Is that assessment inaccurate?


Yea, I've been trying to understand this scenario. It's far removed from what PW has been able to do. We probably want an initialization syntax that uses backing storage at some point. That the syntax works only in this specific scenario got me worried that it is too contrived to be useful for PW declared elsewhere, outside of closure parameter.

Further, now that we can also infer the wrapper type, maybe the usual syntax is even too verbose? Instead of this:

typealias A = (Reference<Int>) -> Void

let a: A = { @Reference reference in }

how about this?

let a: A = { @reference in }
let b: A = { $reference in }

If we're feeling fancy, we can even include nested wrapper:

typealias X = (Reference<Reference<Int>>) -> Void

let x: X = { @@reference in
  // @Reference @Reference var reference
}
3 Likes

I was just about to edit my reply and say it depends on how you define "affects the call-site" :slightly_smiling_face: you're right that the call-site will look the same in both of those cases, but the semantics are different. Even if bar creates a local property wrapper using a, the semantics of property wrapper initialization can be different if init(wrappedValue:) is overloaded.

Further, now that we can also infer the wrapper type, maybe the usual syntax is even too verbose?

Yeah, I agree that writing out the full backing wrapper attribute seems like specifying unnecessary type information, because type inference can indeed infer the backing wrapper type in many cases (unless the wrapper attribute is needed to infer a generic parameter). I think a syntactic sugar extension to this proposal is definitely possible, and I like the syntax you've proposed! I like the $ syntax more, but the @ syntax would certainly make the composition behavior more explicit as you've pointed out.

Could you elaborate more on this? All overloads require that the wrappedValue parameter has the same type as the wrappedValue member, so the type that the caller needs to supply are still the same. To the caller, the function signature is as if the argument type is that of the wrapped value. There are some difference on the callee side as the local wrapper could be treated as shadowing the argument, but that's a distinction that hardly matters. So even if we use a custom initializer, wouldn't it still be the same from the perspective of the caller and the callee?

// These two are both `foo(x:)` of type `(Int) -> Int` when called

func foo1(@Reference(param: "1") x: Int) {
  // x, _x, and $x is supplied by the caller
}
func foo2(x: Int) {
  // x is supplied by the caller
  @Reference(param: "1") var x = x
  // x, _x, and $x are created, shadowing x
}

Granted, foo1 is actually propagated as (Reference) -> () instead of (Int) -> (), but that doesn't seem to be a useful distinction beyond leaving room for other wrapper-at-function-argument features.

You can write overloads of init(wrappedValue:) that all take the wrappedValue type but differ in generic constraints. For example:

protocol P { ... }

@propertyWrapper
struct Wrapper<Value> {
  var wrappedValue: Value

  init(wrappedValue: Value) { ... }

  init(wrappedValue: Value) where Value: P { ... }
}

Now consider the following generic function that takes a property wrapper parameter:

func generic<T>(@Wrapper arg: T) { ... }

If the wrapper initialization happens in the body of generic using arg which has type T, overload resolution will always pick the init(wrappedValue:) with no generic constraints. However, if the wrapper initialization is done at the call-site, and you pass an argument with a static type conforming to P, e.g.:

struct S: P { ... }

generic(arg: S())

// transformed --> generic(arg: Wrapper(wrappedValue: S()))

Then overload resolution can choose the init(wrappedValue:) with the generic constraint on it.

Does this make sense? Please let me know if you have any questions.

Ok, I see the difference now, but is that a feature? I'm not sure that allowing different initializer when calling into the same function leads to a consistent design. At least, I don't think it's congruent with any other Swift's function calling resolution*. It feels like an unintentional side effect, more so that there's no motivational example.

* I got curious and checked this on member-wise initializer, which is the only function with property wrapper in the argument.

The initializer seems to always be choosing the generic one.
protocol P { }

@propertyWrapper
struct Wrapper<Value> {
    var wrappedValue: Value
    let projectedValue: String
    
    init(wrappedValue: Value) {
        projectedValue = "Generic"
        self.wrappedValue = wrappedValue
    }
}

extension Wrapper where Value: P {
    init(wrappedValue: Value) {
        projectedValue = "P"
        self.wrappedValue = wrappedValue
    }
}

struct X<Value> {
    @Wrapper var x: Value
}

extension Int: P { }

X(x: 1).$x // Generic
X(x: "T").$x // Generic
1 Like

I think the transformation model of wrapping arguments into calls to methods provided by the declaration attribute type, where the argument types can influence overload resolution, is very similar to buildExpression in result builders.

The general model of "applying" the declaration attribute to a value for that declaration pre-type checking is very similar between result builders and property wrapper parameters as proposed here.

I think memberwise initializers are a motivating example for this overload resolution behavior.

Anyway, it's totally fine to disagree with me :slightly_smiling_face: I'm going to write up a section about this in Alternatives Considered so that all of the possibilities we've discussed in this thread are clearly laid out in the proposal.

I don't think the model used in result builder, which is a context-heavy feature, would apply well to argument wrapper. The result builder reasons that it'd be obvious that a transformation has occurred (largely due to unused results and the lack of return) and so require minimal ceremony at call site. This couldn't be applied to argument wrapper scenario. It could even be confusing when debugging if different initializers are used when calling the same (generic) function.

If we're really basing a function call feature from a distant feature like result builder, which is generally a codeblock-wide application, IMO it would help a lot if we have some overarching story to tell about code transformation or function calling. I honestly can't tell if this would improve or worsen consistency, and I'm personally leaning on the latter.

Just to confirm, you mean that the current behaviour is lacking, and this pitch will expand its flexibility to be more desirable, right? The current behaviour doesn't match what this pitch is proposing so that's the only interpretation I can extract from the sentence.

Totally, I'm just trying to dig into the design rationale since it's a little bit... sparse on the current write up. Sorry if it's a little bit pushing. I think we're reaching a point where we can see both sides' reasoning enough to agree to disagree.

I think including the part about argument wrapper taking different init for the same function call would be great. It seems to be the motivation for the pitch's current form. If it's really a feature, maybe we should add some motivation. If it's a bug, we should figure out a way to mitigate it.

2 Likes

Today, it's perfectly valid to do something like this:

@propertyWrapper
struct Wrapper<T> {
  var wrappedValue: T

  init(wrappedValue: T, secretSauce: String = "") { ... }
}

struct S {
  @Wrapper
  var x: Int = 3
}

That is, if the init(wrappedValue:) supports a syntactic form which admits no additional arguments, then it's permissible to initialize it with just the bare attribute name. Does/should this carry forward to wrapped arguments? I.e., is it valid to have:

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

?

If so, I remain concerned that it's possible to form references to functions-with-wrapped-arguments that reflect the type of the wrappers. It seems like an end-run around the restriction you've laid out above, since I could write:

let bar = foo(arg:)
bar(Wrapper(wrappedValue: 3, secretSauce: "hunter2")

and potentially break the assumption of foo's author that _arg is always initialized with the default secretSauce argument.

Perhaps it simply shouldn't be possible at this stage to form a reference to such functions, at least until we've ironed out more details of the argument-wrappers-with-initializer-arguments? In other words, whenever a function with wrapped arguments is used, it must be as an immediate call. It would still be possible for clients to form references with the wrapped types "manually," i.e.,

let bar = { foo(arg: $0) }

Otherwise, I think Wrapper as written in this post should be illegal in argument-wrapper position.

The proposal talks about a "suitable" init(wrappedValue:) initializer, but doesn't go into detail about what that means. I'd appreciate a more thorough definition of this terminology!

I don't think this is an assumption that the function author can make. Even for property wrappers today, if somebody writes a reference semantics property wrapper and then uses it inside some type, there are cases where the generated memberwise init for that type will take in the backing wrapper type (even if an init(wrappedValue:) does exist), and the caller can either create a new backing wrapper or pass an existing one.

Putting a wrapper attribute on a parameter doesn't mean that the call-site will always create a new wrapper, but it allows the call-site to create a new wrapper via the wrapped value easily. I think the only assumptions the author can make are whatever assumptions are promised by the property wrapper type.

I also don't think we'd want the type of a function with wrapped parameters to be different than a closure with wrapped parameters like you suggest here:

Consistency is important, and this would also prevent someone from using a function with a wrapped parameter as an argument instead of a closure, which would be really unexpected IMO. This is also probably an argument for allowing wrapper attributes on parameters even if the wrapper doesn't have an init(wrappedValue:), but I'm still wary about allowing people to write a function with an argument label that you can't actually use to call the function.

Yeah, this is still allowed under the current proposal. I don't think it's unexpected for the default argument to change - default arguments are a convenience, and letting the caller change them is absolutely expected.

Good point - I'll add a definition for this. Thank you!

1 Like

This is an interesting characterization to me because function arguments are already extremely context-heavy. Type inference, leading dot syntax, autoclosures, conversion to optional, etc, are all entirely dependent on the context at the parameter declaration. In many cases of calling a function, there is a code transformation happening at the call-site, not in the function body. I guess I don't see a drastic divergence between property wrappers applied to arguments and all of these other context-heavy features that exist today.

I do understand that the transformation happening on a wrapped argument isn't obvious because there's nothing distinct about the call-site. However, I think the entire mental model of property wrappers is built around the code transformation that happens. I would be open to exploring different syntax for the call-site to make the transformation more obvious, but I think that would diverge more from the inference-heavy model that a lot of language features use today.

Yes, I think that the current behavior of memberwise initializers is lacking when property wrappers are involved, and this pitch can improve that behavior.

2 Likes

Sure, but a) not all types offer synthesized memberwise inits, b) the memberwise init will not expose the backing wrapper if an appropriate init(wrappedValue:) exists, c) in cases where the memberwise init does expose the backing wrapper, it's only exposed as internal and not necessarily at the visibility level of the struct/property itself, and d) if this behavior is undesirable then the struct author can define their own initializer which does not expose the backing wrapper, and they don't have to worry about the memberwise init.

Conversely, with function argument wrappers, a-b) the backing wrapper exposed for every wrapped function argument, c) the backing wrapper is exposed at the visibility of the function itself, and d) there is no way for a function author to override either of these behaviors if they find them undesirable.

I started writing out a longer response, but then I scrolled back up and realized I was basically reiterating all of my arguments about embedding wrappers in the ABI, and instead reframing them in terms of API. Unfortunately, I was convinced on the ABI front on the basis that it was consistent with how property wrappers already behave. Unless you can help me reframe things mentally, I'm not sure that the same arguments apply (for the reasons I sketched out just above).

Agree that consistency is important, and that this would be... somewhat troubling. But to me, this isn't a question of "inconsistency vs. no inconsistency," it's a question of "inconsistency between functions and closures, or inconsistency between property wrappers and function argument wrappers."

In particular, I think it's an incredibly important difference that functions with wrapped arguments are called with the wrapped type, while closures are called with the wrapper type. E.g.,

let foo = { (@Wrapper arg: Int) -> String in ... }
func bar(@Wrapper _ arg: Int) -> String { ... }

foo(Wrapper(wrappedValue: 0))
bar(0)

func transform(_ arg: Int, by transform: (Int) -> String) -> String { transform(arg) }

transform(0, by: bar) // error: cannot convert '(Wrapper<Int>) -> String' to '(Int) -> String'

// I have to do this, why isn't it the same?
transform(0, by: { bar($0) })

In other words, I think that clients of functions with wrapped arguments will think of those functions as having the type of the wrapped values, not the backing wrapper. This doesn't apply to closures, so at some level the inconsistency is already there.

Since it's prohibited to use a wrapper with initializer arguments on a function argument, I would argue it is unexpected. I think it's a very natural leap from "huh, I can't use a wrapper with init arguments here" to "the wrapper passed in is always created via init(wrappedValue:)." In fact, given the explanation of the transformation, I think it would take a very careful reader to determine that that was not in fact the case. This feels like an inevitable sharp corner that will always have to be mentioned whenever anyone reaches for this feature.

With your idea of the future foo($arg: 0) syntax in mind, IMO a natural corollary is that you would be able to reference the unapplied version of that function as foo($arg:), and in that world it seems perfectly reasonable that the foo(arg:) form would give you the 'unwrapped' (Int) -> Void version of the function.

I believe that the question of unapplied references to functions with wrapped parameters may be better left unanswered as part of this proposal, and deferred until we can see a little more usage of this feature in the wild. That would allow us to examine things at least a bit more empirically when considering the question of whether users expect unapplied references to take on the wrapped type, or the wrapper type.

On an unrelated note: it's been a couple weeks since I read through this thread, so I forget if it was already mentioned, but I think the Motivation section could definitely be bolstered. For one, the solution-as-proposed doesn't actually solve the problem posed with Percentage—the comments complain, "Unfortunately, we can't use '@Clamped(to: 0 ... 100)' [on the function argument]," but with the proposed solution, we still can't use @Clamped(to: 0 ... 100)!

Additionally, I think that the overloads of adding(_:) should be updated (per @Lantua's post) to what I think is a fairer representation of the status quo:

  mutating func adding(_ offset: Int) {
    adding(Clamped(wrappedValue: offset, to: 0...100))
  }

  mutating func adding(_ offset: Clamped<Int>) {
    @Clamped(to: 0...100) var offset: Int = offset.wrappedValue
    // ... or, '_offset = offset'
    percent += offset
  }
2 Likes

We re-worked the Motivation section and I'm hoping that it will help clear up the mental model by reframing the purpose of property-wrapper parameters in functions. Please give it a read and let me know if it helps.

I'm still thinking about whether or not to ban unapplied references to functions with property-wrapper parameters. I do like your idea to make the unapplied reference have different semantics based on which argument label you provide.

2 Likes

Yeah, I think the revision definitely makes a better case for this feature. I think the problem posed in the Memberwise initialization section could also stand to be addressed directly as part of Proposed solution—right now it's raised as the first item which supposedly motivates this proposal, but AFAICT it doesn't get mentioned again until Future directions (nor is it immediately apparent to how the proposed solution addresses memberwise initializers).

If this proposal is supposed to provide a solution to that specific issue (presumably via a manually written memberwise initializer using wrapped parameters?), it should be explicit (or, if y'all don't think that this proposal provides a satisfactory solution to that problem, then that section of the motivation should just be removed and included in a future proposal which attacks the synthesized memberwise initializer directly).

Yeah, I've been mulling this over as well. I'm not 100% in either direction between giving unapplied references the wrapped/wrapper type (although I still lean towards the wrapped type), and the issue seems separable from the core of this proposal, so I'd rather be conservative about introducing functionality that might turn into a sharp edge and which couldn't be fixed in a source-compatible manner.

I'm still most convinced by the argument that the ability to call a function as foo(arg) implies that the unapplied reference should be able to be called in the same way (i.e., let bar = foo; bar(arg)). However, that mental model is already broken by default arguments since they don't get "carried through" unapplied references, so maybe having another transformation that gets applied on immediate calls only is okay...?

I wonder if it would be too much flexibility to default to one of the wrapped/wrapper type, but allow the unapplied reference to be freely converted to the other based on context. I.e.,

func foo(@Wrapper arg: Int) {}

let bar = foo as (Int) -> Void
let baz = foo as (Wrapper<Int>) -> Void

would both be okay. That could even be extended to let a client choose the wrapped/wrapper type on a per-argument basis. I'm just spitballing, so not at all confident that this results in a more usable model in practice. :sweat_smile:

1 Like
Terms of Service

Privacy Policy

Cookie Policy