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

Hello Swift Community!

@filip-sakel and I have been revising the proposal for SE-0293: Extend Property Wrappers to Function and Closure Parameters based on feedback from the first review. Here is a list of changes to the design in this revision:

  • Passing a projected value using the $ calling syntax is supported via init(projectedValue:).
  • The type of the unapplied function reference uses the wrapped-value type by default. Referencing the function using the projected-value type is supported by writing $ in front of the argument label, or $_ if there is no argument label.
  • Closures with property-wrapper parameters have the same semantics as unapplied function references.
  • Additional arguments in the wrapper attribute are supported, and these arguments have the same evaluation semantics as default function arguments.

The latest proposal draft is available here. Please comment with your feedback!

-Holly

18 Likes

Thank you for re-pitching this proposal. It looks like all the suggestions emerged in both the previous pitch and its review have been considered and implemented, along with clarifying the behavior of wrapped @resutBuilder parameters.

In the review of SE-0293, the core team suggested the following approach:

Being able to use property wrappers in patterns seems interesting and more general. Has this alternative direction been explored by the authors?


The proposal adds init(projectedValue:) in order to solve the issue of passing the storage a projected value. This however forces API developers to not use the projected value as a projection but in some way as a public mean to access/instantiate the private storage instance, thus limiting the design space. The proposal states that "one of the motivating use-cases for property-wrapper parameters is the ability to pass a projected value", but in reality it should be "the ability to pass a storage instance".
In the previous pitch thread, the authors observed that having $ attached to a parameter label to specify that it's the storage that needs to be passed is counterintuitive given that $ indicates the projected value. I agree, but I wonder if the ability to assign a storage instance to an already initialized wrapped property should be transparently available to users as a general feature.


From the proposal:

Consider the following example:

let useBinding: (Binding<Binding<Int>>) -> Void = { $value in
  type(of: value)  // returns 'Binding<Int>' or 'Int'?
}

We can have both @Binding var value: Binding<Int> and @Binding @Binding var value: Int and without explicitly knowing which are the property wrappers attached to value, we cannot know its wrapped type.

1 Like

Super excited to see this iteration of the pitch. From a quick read through this irons out most (all?) of the sharp corners I worried about with the previous iteration. Hope to have some time soon to get more in depth, but I have a couple initial questions/reactions:

I'm also curious about the decision to link the projectedValue property and the init(projectedValue:) pass-by-argument feature. As @xAlien95 notes, the underlying motivation for each of these features is relatively disjoint, so it's not obvious to me that it's a win to overload the projectedValue name. Yes, it let's us explain $ syntax as "oh, that's for working with projected values," rather than "oh, that's for projected values or passing a special wrapper argument," but that doesn't do us much good if we then have to explain projected values as "well, it's either for getting a special API from the wrapper, or passing an argument to a wrapper parameter." We've just moved the complexity one level deeper.

I'd love if the proposal could elaborate a bit on why the authors felt that projectedValue and pass-by-init(projectedValue:) should be linked in this way, rather than having a totally type- and spelling-separate init(wrapperArgument:) or something of the sort.

I also think that the "closures" part of the Closures and unapplied function references section could use a bit more explication. IMO there's enough that's unique to closures that it might make sense to split those sections apart into Closures and Unapplied function references sections just to give each a more complete treatment. In particular, it's not entirely clear to me if the following is supported under this proposal:

let wrapperClosure: (Int) -> Void = { @Wrapper<Int> arg in print(arg) }

My best guess is that it would be, but there's not even an example of wrapped-param closures that don't use the $ syntax, so I'm not 100% sure.

Excellent work as always by the authors!

1 Like

Should this work in synthesized memberwise initializers too? So you can do:

struct Foo {
  @Traceable var value: Int
}

let history: History<Int> = ...
var foo1 = Foo(value: 10)
var foo2 = Foo($value: history)
1 Like

Yeah, I think that could work.

EDIT: That could work as a future direction.

I do think that property wrappers in patterns would be useful, but I agree with Chris that this is an orthogonal feature to this proposal. I also don't think this direction should apply to closure parameters, because today there is no precedent in the language for treating closure parameters as patterns. I personally think that designing closures to behave the same way as unapplied function references simplifies the mental model for property-wrapper parameters.

I've clarified the behavior of this syntax with composition in the proposal:

Since property-wrapper projections are not composed, this syntax will only infer one property-wrapper attribute. To use property-wrapper composition, the attributes must always be explicitly written.

Thank you for the suggestion! I've split up these two sections and added a bit more explanation/examples to start, but I'll continue working on clarifications here.

I'm writing up justification for the init(projectedValue:) design decision vs alternatives - will let ya'll know when it's in the proposal draft! EDIT: In the meantime, here's an excerpt from my brainstorming notes:

I have yet to see any concrete evidence that passing the backing wrapper directly is actually important, but this design does not prevent any future enhancement to support this. All of the use cases involve passing either a wrapped value or a projection around (i.e., the parts of the wrapper that are not implementation detail).

1 Like

Thanks for the hard work! I think this has become a pretty solid feature. I agree with most of the proposal, with a few minor points:


If the outermost property wrapper defines a projectedValue property, a local computed property representing the outermost projectedValue will be synthesized and named per the original parameter name prefixed with a dollar sign ( $ ).

This doesn't seem to handle mutating get projectedValue. Though that paragraph is already overlong, and it's quite a minor scenario, so :woman_shrugging:.


Does this also allow unapplied function reference with function arguments, i.e., log(value:) is allowed and is interpreted as log? This is heavily implied by UFR section, but I couldn't find an explicit example in the proposal.


Property wrappers on function parameters must support init(wrappedValue:) .

Rationale : This is an artificial limitation to prevent programmers from writing functions with an argument label that cannot be used to call or reference the function.

Maybe we can drop this requirement. We don't seem to have any problem with unusable function:

func foo(_: Never) { } // ok...

init(projectedValue:) is a new initializer here. Have you considered adding this back to the original property wrapper?

var projected: Binding = ...
@Binding var $value = projected

Aside from consistency's sake, I couldn't find a scenario where this could be useful, so maybe we don't need to add it (even as a future direction). It's out-of-scope for this pitch anyway.


Do you also plan to include use this feature for synthesized memberwise initializer? My impression is that it's left for future direction, but @filip-sakel's reply seems to imply otherwise:

Or maybe I just misinterpreted the discussion.


Property-wrapper parameters with arguments in the wrapper attribute cannot be passed a projected value.

What about an empty argument?

func foo(@Wrapper() a: Int) { }

This could be an interesting way to disable pass-by-projected-value. I think we should also disallow foo($a) here.

1 Like

I second this. I only quickly scanned the proposal and noticed this odd feature extension.

The original PW functionality does not and probably cannot support this feature. Let me try to explain.

What this adds is basically a convenience compiler sugar to opt into calling init(projectedValue:) which cannot be a requirement for a general PW.

It is not possible to design a PW that only has a projected value as a wrapped value is always a minimal requirement for the compiler to know which type a PW will wrap. A wrappedValue cannot be hidden as we won‘t be able to tell which type we would wrap. Therefore a projectedValue is only an extension of some PWs.

Initializing from init(projectedValue:) will still require you to either initialize wrappedValue directly or to implement at least a computed version of it.

So what are we really trying to solve here? To me it seems that we just want to add the ability to use the $ in even more places without adding much functionality to it.

I personally think the correct solution for this problem would be a simple one:

  • split the PW which you‘d otherwise extend with init(projectedValue:) into two separate PWs
  • provide multiple necessary overloads on functions where the proposal uses $ for parameter labels

To be clear, I'm in favor of adding init(projectedValue:) for function argument wrapper, but I'm not sure if we want to add init(projectedValue:) to original property wrapper.

For function argument, it'd be extremely useful for those I called shared storage wrapper that I found in my last survey:

but it doesn't do much for the original PW. size-wise, they don't differ too much from custom init:

$value = projectedValue
_value = .init(projectedValue: projectedValue)

though it may allow for inlined init(projectedValue:)

@Wrapper var $value = projectedValue

I think it's fine to have the initializer be optional, just like init(wrappedValue:). If you define it, the type must match the projectedValue, and you can then use var $value = ....

The first one probably wouldn't work since we'd just be shifting the need for init(projectedValue:) into one of the two PWs.

The second one would work for original PW, but I don't see how that could improve function argument wrapper.

1 Like

I understand that there could be ways to extend the general PW functionality with that, but I would to be careful not only for that but even for the current proposal.

How would you handle this case?

@propertyWrapper
struct W<A, B> {
  var wrappedValue: A?
  var projectedValue: B?
  init(wrappedValue: A) { self.wrappedValue = wrappedValue }
  init(projectedValue: B) { self.projectedValue = projectedValue }
}

func foo<A, B>(@W<A, B> bar: A, b: B.Type) { ... }

// compiler infers B as Int, but how would you tell it what A is?
foo($bar: 42, b: Int.self)

Regarding the $_ prefix for unlabeled parameters. I think it should be safe to drop the underscore as you could theoretically write baz($: 1, $: 2) and let the compiler figure out the parameters via the parameter order rules. Since the $ is reserved for identifiers, I don‘t see any issues or need to add an explicit underscore.

1 Like

You're right - the compiler can only synthesize a local projection property if the getter is nonmutating. I've clarified this in the text.

Yes, this is supported. I've added the log(value:) example to the section to demonstrate this.

That's a good point. I don't feel too strongly about this restriction. I was mildly concerned that adding a wrapper attribute to an existing parameter would be a source breaking change, but for libraries (with evolution) this is a moot point anyway because that would break ABI.

Yes, in the form of definite initialization - I intend to implement property wrapper initialization from a projected value via DI sometime in the future. I've added this as a future direction to the proposal.

Yeah, it's in future directions at the moment, but there's no reason not do to this out of the gate. In the previous design, this was a source breaking change for code that curried or referenced a synthesized memberwise init, but this is no longer the case because we've changed the type of the init in this design. I can add this to the proposal proper if we want this functionality to work out of the box - it's straightforward to implement.

Oh, that's kinda neat! This Just Works in the implementation :slightly_smiling_face:

Side note: I'm still implementing the transformation for function expressions, but I plan to have a toolchain for folks to try out sometime in the coming week.

Thank you for pointing out all of these missing details!

4 Likes

Remember that, while var wrappedValue is a requirement for property wrappers, init(wrappedValue:) is not. Initialization from a projected value would be very useful for out-of-line initialization of property wrappers that don't support init(wrappedValue:), but have a projected value that can be used for initialization.

Probably with an error message. I don't think this will be a big issue because in practice, most projection types include the wrapped-value type in some way.

I chose $_ to be consistent with the Swift grammar today. $ alone is not an identifier, but $_ is. This is also consistent with the way you call or reference a function that omits argument labels while writing those labels explicitly, for which you use _:.

5 Likes

I think that the real goal here is to achieve deferred initialization form a storage instance, not from an eventually available projected value. Projected values, technically, are in a surjective relation with their wrapped variables. A boolean projected value that returns true if its wrapped variable satisfies a given condition, isn't suitable to initialize that variable. The source of truth is the storage instance.

Let us consider State and Binding:

@State var x: Int
@State var y: Int
@State var z: Int

[$x, $y, $z].forEach { $element in
	type(of: element)  // returns 'Int'
	type(of: $element) // returns 'Binding<Int>'
}

Are we passing a State 's projected value or a Binding 's storage? The element's storage type is Binding, not State. Here, we are initializing a Binding from a storage instance. We are not initializing a State from its projected value.

2 Likes

I think it's best to add this [pass-by-projected value in memberwise-initializer] together with [update memberwise-init], whether now or later. Otherwise, having both old and new synthesis would be quite fractured and confusing.


What if we use static methods for projected value Wrapper.instance(fromProjectedValue:)? There's a good chance that we'd want to retrieve an already-existing instance. Especially that self-projection, like Binding, can simply use self.


I'm still ambivalent about projection vs storage. On the one hand, directly passing storage is a sure way to specify the same instance used. OTOH, we don't want to pass around storage for many types. Passing around things like State, Published, Environment, Lowercased is ill-advised at best. Somethings that we want to pass around are already mostly self-project, like Binding, UnsafePointer.

It also raises the question, if the projection, which is surjective to the storage (not wrapped value), is insufficient to identify the storage, is it appropriate for the storage to be passed around? If we have a Wrapper with Value wrapped value, and Bool projected value, then the only way to access the storage is via _value, which is already private.

1 Like

I also thought this was the goal all the way through the first review. However, a big point of criticism from reviewers was that the backing storage type should be private. The core team also thought that the backing storage type should be an artifact of the function implementation, and not exposed to function callers through the type system.

After the review, I thought long and hard about all of the use cases, and I realized that those people were right, because all of the use cases I had presented were actually passing a projection and not the storage type. Keeping the storage type private is consistent with how property wrappers work today. Unless a property wrapper projects its storage type via projectedValue, the storage type itself is meant to be private, implementation detail that cannot be accessed by API clients.

I agree, and in this case I would advise that such a property wrapper not implement init(projectedValue:). Similarly, not all property wrappers can be initialized from their wrapped value, in which case they don't implement init(wrappedValue:).

I actually think State and Binding are good examples of why we don't want passing the backing storage to be a goal. The State type is intended to be private, implementation detail, and you should never pass an instance of State to another view. Instead, you pass it's projection, which is Binding. Passing around Binding is totally fine because binding publicly projects itself via projectedValue. The public part of the API is projectedValue, not the storage type, and that's deliberate.

This example is initializing a Binding from its projected value, which is also Binding.

5 Likes

From the proposal:

A projected value can provide a setter different from what you get using init(projectedValue:). If a user adds an initial value, e.g. @Traceable var dataSource = "none", then in the initializer $dataSource = history would use the projected value's setter instead of init(projectedValue:), resulting in potential differing behaviors. Is this acceptable?


I was going to write it too. If the API provider's goal is to let users to access the storage type or bits of it, the only way is to expose them via the projected value. I agree with you and I'm now mostly fine with the proposal, thank you!

There's still the case in which the user happens to already have storage instances and its goal is to rely on the property wrapper just to "unwrap" the wrapped value and the projected value. In that case we can just force the user to define a wrapper variable in the function/for/case/while body and use the storage instance to explicitly initialize it. After all it's the same we do when we need to have a variable parameter inside a function body: we just redeclare it to be a var.

@propertyWrapper struct Wrapper<Wrapped> { ... }

for storage in storageInstances {
  @Wrapper var element: Wrapped
  _element = storage
  ...
}
2 Likes

This is already the case for init(wrappedValue:), isn't it?

1 Like

Yes, that's how DI works for initialization from a wrapped value. An assignment to a property with an attached property wrapper will get re-written to init(wrappedValue:) before all of self is initialized. Otherwise, assignment is re-written to the wrappedValue setter. The same rules would apply for init(projectedValue:) if we are to support it via DI.

I don't see your point here; could you elaborate?

Should there be a warning, though? Adding statements in the bodies of unusable functions results in a warning ("Will never be executed"). Likewise, there could be a similar warning –– and overall behavior, by allowing the function not to return a value –– for functions with wrapped parameters whose wrapper types lack any of the appropriate initializers –– init(wrappedValue:), init(projectedValue:).