[Pitch #5] SE-0293: Extend property wrappers to function and closure parameters

Hello, Swift Community

@filip-sakel have revised the design of SE-0293 again based on feedback from the last pitch discussion. The latest proposal draft is available here.

Here is a list of changes to the design in this revision (compared to the last reviewed version):

  • The distinction between API wrappers and implementation-detail wrappers is formalized, and determined by the compiler based on whether the property wrapper type allows the call-site to pass a different type of argument.
  • Implementation-detail property wrappers on parameters are sugar for a local wrapped variable.
  • API property wrappers on parameters use caller-side application of the property wrapper.
  • Overload resolution for property wrapper initializers will always be done at the property wrapper declaration to minimize the semantic differences between the two property wrapper models.

For those who have been actively following the discussion, here are a few new sections that might be of interest:

11 Likes

Thanks for your continued work on this, @hborla and @filip-sakel! I like how this iteration turned out and appreciate all the hard work that went into it.

I do wonder if you could elaborate on this point a bit:

These two models are designed such that nearly all observable semantics of property wrapper application do not differ based on where the wrapper is applied. The only observable semantic difference that the proposal authors can think of is evaluation order among property wrapper initialization and other arguments that are passed to the API, and the proposal authors believe it is extremely unlikely that this evaluation order will have any impact on the functionality of the code. For evaluation order to have a functional impact, both the property wrapper initializer and another function argument would both need to call into a separate function that has some side effect.

Could you give an example (a toy example would be fine) of what you're describing? It does sound like a bit of an elaborate situation, but it'd be good to visualize it in code.


Going back to the method by which the compiler determines whether a property wrapper type has API significance--I think this is very much acceptable given the point above about consistency of observable semantics for property wrapper application. That provides a nice justification for why the two "different" types of wrappers don't need to look "different" when decorating function parameters. It would be nice if the proposal explicitly regarded this consistency of observable semantics as a fundamental design goal, and therefore any divergences as bugs.

I think the classification of any property wrapper that supports projected-value initialization as having API significance is very sensible and easily justifiable to users: that there is such a projected value initializer is a pretty big declaration of intent, in my view.

I'm not so sure I understand the second point about @autoclosure arguments to init(wrappedValue:). That is, I get why they can't be implementation-detail wrappers, but why not just reject them altogether for use in parameter lists given that they don't meet the requirement that's been stated: "implementation-detail property wrappers on parameters must support initialization via wrapped value"? In other words, is there a use case for property wrappers that take @autoclosure wrapped values being supported as API-significant wrappers?

It would seem to simplify things greatly in terms of learnability to say you can use a wrapper for a function parameter if the wrapper (1) supports initialization via same-type wrapped value as an implementation detail; or (2) supports initialization via a projected value as an API-level matter.

[Edit: We could even, then, formalize the distinction between these two kinds of property wrappers along those lines: we'd have value-projecting property wrappers and (ugh, I've run out of a good term here) non-value-projecting property wrappers.]

4 Likes

I added an example to the proposal. It looks like this:

func hasSideEffect() -> Int {
  struct S {
    static var state = 0
  }

  S.state += 1
  return S.state
}

@propertyWrapper
struct Wrapper {
  var wrappedValue: Int

  init(wrappedValue: Int) {
    self.wrappedValue = wrappedValue + hasSideEffect()
  }
}

func demonstrateEvaluationOrder(@Wrapper arg1: Int, arg2: Int) {
  print(arg1, arg2)
}

demonstrateEvaluationOrder(arg1: 1, arg2: hasSideEffect())

If the property wrapper initializer is evaluated in the caller, the output of this code is 2, 2. If the property wrapper initializer is evaluated in the callee, the output of the code is 3, 1.

Part of the reason for including @autoclosure is because @autoclosure is already propagated from init(wrappedValue:) to the type of a parameter today via the memberwise initializer. I think memberwise initialization is a reasonable use case for this behavior. I could also imagine a property wrapper that lazily evaluates arguments.

4 Likes

The current restriction is a good starting point, but not an ideal end version of this feature –– as I see it. This proposal, as pointed out in the 4th pitch discussion, was too complex and instructions for authors to properly determine the API significance for their wrappers wasn’t discussed thoroughly. This would likely lead to this feature being widely misused. A well-crafted proposal, though, focused solely on enabling explicit marking of API significance while also guiding authors to correct usage would greatly benefit libraries –– and users to a lesser extent.

I'll update the future direction of explicitly marking API significance to elaborate on this.

I think what @xwu pointed out is a design goal. Users shouldn't have to think about where exactly the wrapper is initialized to figure out what their code is doing. Sure, it might be nice for #file, #line, etc. to work in the future -- and that's possible -- but beyond that, I don't think semantic differences are desirable. The simpler the mental model for the programmer, the better.

There are a lot of interesting future directions for this feature, but as long as the current design isn't cutting off any of those directions, I think it's important to try to focus the discussion here on what's being proposed now (and more specifically, the updates in this revision).

1 Like

The proposal is a bit tricky. What is the reason to restrict mutability for mutating setters in case of implementation detail property wrappers? I feel like the proposal tries to keep consistency between api and impl. detail PWs but that‘s the tricky part here.

I could write the desugared line of code by hand and create a local PW which would result in a mutable variable. So why isn‘t it the same for impl. detail PWs already?

func insert(@Logged text: String) { ... }

// The above code is sugar for:

func insert(text: String) {
  @Logged var text = text
}

// which the compiler further desugars to generate:

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

  var text: String { 
    get { _text.wrappedValue }
    // missing part
    set { _text.wrappedValue = newValue }
  }
}

That said, if we already using local property wrappers, then I don‘t see the point for api property wrappers having the same restriction.

Instead of this:

func copy(_text: Traceable<String>) {
  var text: String {
    get { _text.wrappedValue }
  }

  var $text: History<String> {
    get { _text.projectedValue }
  }

  ...
}

We could generalize the above idea and go a step further:

func copy(_text: Traceable<String>) {

  var _text: Traceable<String> = _text
  var text: String {
    get { _text.wrappedValue }
    // synthesize a setter if PW has a setter on wrappedValue
  }

  var $text: History<String> {
    get { _text.projectedValue }
   // synthesize a setter if PW has a setter on projectedValue
  }

  ...
}

This is simple to remember, teachable, less restricting and consistent across across both api and impl. detail wrappers.

I will try to provide a more extensive review of the current proposal today or tomorrow. I have to sort my thoughts first. :slight_smile: The proposal made a long way up until now and is great but I think we can improve and fine tune it even more to have a greater flexibility for everyone.

Because conceptually, parameters are immutable, and it can be surprising when it appears that you're able to set a parameter without the change being reflected in the argument that was passed when the caller resumes.

It is true for normal parameters, but we're no longer talking about normal parameters. If we extend the synthesized code for api property wrappers, we would align the rules and simplify the design of the synthetic code. If we say that a wrapper on a parameter always desugars to a local property wrapper which shadows the parameter, there is clearly an opportunity to allow mutation.

In fact, you (the proposal authors) already propose to synthesise setters if the PW's wrappedValue / projectedValue has a nonmutating setter. Sure this indicates that there will be an effect that the user can trigger. However for super simple PWs there will be no effect except that you will mutate the value in the backing storage.

As mentioned by someone in the previous thread, this isn't a basic feature anymore and I don't see the requirement to artificially restrict it. Api or implementation detail property wrapper, both should just produce a local property wrapper variable. That is far more flexible. If you recall in the early stages of the original PW proposal we had PWs that were forced to have a generic type parameter that represented the type of wrappedValue. That restriction was totally unnecessary and would have caused more issues than the design we have today.

Here is a quick but simple example:

@propertyWrapper
struct Clamping<Value> where Value: Comparable {
  var value: Value
  let range: ClosedRange<Value>

  init(wrappedValue: Value, range: ClosedRange<Value>) {
    precondition(range.contains(wrappedValue))
    self.value = wrappedValue
    self.range = range
  }

  var wrappedValue: Value {
    get {
      return value
    }
    set {
      if newValue < range.lowerBound {
        value = range.lowerBound
      } else if newValue > range.upperBound {
        value = range.upperBound
      } else {
        value = newValue
      }
    }
  }
}

@propertyWrapper
struct W {
  @Clamping
  var wrappedValue: Double

  var projectedValue: Int {
    get { Int(wrappedValue) }
    set { wrappedValue = Double(newValue) }
  }

  init(wrappedValue: Double) {
    self._wrappedValue = Clamping(wrappedValue: wrappedValue , range: 0 ... 10)
  }

  init(projectedValue: Int) {
    self.init(wrappedValue: Double(projectedValue))
  }
}

// func foo(@W value: Double)
func foo(value _value: W) {
  // the key alignment between api and implementation detail PWs.
  var _value: W = _value

  // syntheized computed property
  var value: Double {
    get { _value.wrappedValue }
    set { _value.wrappedValue = newValue }
  }

  // syntheized computed property
  var $value: Int {
    get { _value.projectedValue }
    set { _value.projectedValue = newValue }
  }

  print(value, $value)

  value = 5.5

  print(value, $value)

  $value = 4

  print(value, $value)
}

foo(value: 20.5)
// foo(value: W(wrappedValue: 20.5))

// prints:
// 10.0 10
// 5.5 5
// 4.0 4

foo($value: 2)
// foo(value: W(projectedValue: 2))

// prints:
// 2.0 2
// 5.5 5
// 4.0 4

There is no reason to artificially restrict code that is "just sugar" for the most part.


As for the implementation detail PW from the proposal:

func insert(@Logged text: String) { ... }

The above code is sugar for:

func insert(text: String) {
  @Logged var text = text
}

which the compiler further desugars to generate:

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

  var text: String { _text.wrappedValue }
}

That is incorrect. In Swift 5.4 the following code

func insert(text: String) {
  @Logged var text = text
}

would already desugar to this:

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

  var text: String { 
    get { _text.wrappedValue }
    set { _text.wrappedValue = newValue }
  }
}

As I wrote this example I realized that one example in the proposal seems to be incomplete.

// The compiler will synthesize computed text and $text variables in the body of copy(text:):
func copy(_text: Traceable<String>) {

// shouldn't this be?
func copy(text _text: Traceable<String>) {
1 Like

Yes, it's feasible, but again, I believe it's unexpected when it appears that you're able to modify a parameter and that change isn't reflected when the caller resumes. This was also a point brought up in the first pitch of this feature:

The only reason we chose to synthesize nonmutating setters is that this is typically what's used for property wrappers that provide an abstracted reference to a value, and the ergonomics of using such property wrappers as parameters would be poor otherwise.

I understand how local property wrappers work. What's in the proposal is deliberately different. I can make it more clear in the proposal that it's deliberately different.

You're right - thanks for pointing this out, it was an inadvertent change I made as I updated the proposal for this revision. It's fixed now.

I do not doubt your understanding of the feature :wink: but the proposal claims that the transformation is achieved in two steps, or at least one gets the impression if you say that the implementation detail PW will compose a local property wrapper first before the final desugaring. In that sense it's not correct because the final transformation still should have the setter.

I get the point, but I personally do not buy the argument for synthesized code that is only sugar for the user (not speaking about the parameter handling from the compiler, which is one of the major motivations for this proposal in the first place). We're not really outruling the immutability of parameters, we would just copy them into a backing storage which happens to be function local instead being an immutable parameter.

Let me use the implementation PW example from the proposal again.

If this

func insert(@Logged text: String) { ... }

transforms to

func insert(text: String) {
  @Logged var text = text
}

and finally to

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

  var text: String { _text.wrappedValue }
}

Then the result is artificially restricted and goes against the expected result for local property wrappers in Swift 5.4.

Not only that, if this restriction persists, why should I as a user use the property in such manner? Instead I would do the local property wrapper by hand and get the expected result.

func insert(text: String) {
  @Logged var text = text
}

That said, implementation detail property wrappers on parameters start to lose the motivation for their existence. The only benefit is that I don't have to do the shadowing by hand, but then it ends up in removing the setter for me. That is simply not what I would expect from this feature.

1 Like

I only had time to argue on that small adjustment. In fact, I think if we did it that way we can remove a few more restriction from the current proposal. There are a few other restrictions that I think we can provide a solution to, but I need a bit more time to parse the entire proposal and sort my arguments and ideas. :slight_smile:

I'm pretty happy with this revision and don't have a lot to say (shocker!). IMO many of the minutiae at this point (such as the mutability decisions) are debatable but will ultimately come down to minor tradeoffs between different priorities, and I could really be happy with a principled stand in any direction.

I'll sit with this to see if anything comes to mind over the next few days, but this feels like a well-balanced proposal for a very complex feature. Kudos to the authors for their thoughtful engagement through several revisions!

3 Likes

I'm having some difficulties understanding the Overload resolution of backing property wrapper initializer subsection. Until the third pitch, overload resolution affected which init(wrappedValue:) was going to be used. Now it doesn't anymore, is that correct?

These quotes seem in contradiction to me:

There is a difference between the parameter type and the argument type. For example:

func generic<T>(value: T) {}

generic(value: 10)

The type of the parameter is generic type T, and the type of the argument is Int. In this revision, the type of the parameter will impact init(wrappedValue:) overload resolution, rather than the type of the argument.

5 Likes

This was the one section of the revised pitch that I found confusing. Thanks for clarifying here.

Do I understand correctly that generic Itself could be overloaded to propagate the wrapper’s overloading to caller code?

func generic(value: Void) {}

generic(value: ()) // now calls LateInitialized’s “Value == Void” constrained initializer

If so, it would be great to extend the example with that. That is, after the current example add something like “Author’s can select between overloaded initializers by providing similar overloads in their declarations.”

—-
Stepping back, thanks for all the iteration on this! I’m thrilled with where this is ending up.

Yep, that's right! I'll add an example of this to the proposal.

I'm still not sold that supporting argument wrapper for Impl wrappers is a good idea, especially when local var wrapper has just been rolled out. Nonetheless, that's a disagreement I can (reluctantly) work with.


Have we considered other categorization heuristic? Given that we know exactly which init is possible at function decl (only the corresponding init(wrapppedValue:) and init(projectedValue:)), we could choose between Impl wrapper and API wrapper at func decl site. That would double the type checking required (not exponentiating it thankfully), but depending on how one spin it, it could be either a footgun or a feature.


I'm glad that we now infer the init from the decl instead of call site. I've been thinking that the old behaviour is weird and unintuitive all this time.


Property wrapper attributes can only be used on parameters in overridden functions or protocol witnesses if the original has the same property wrapper attributes.

This should be restricted to only API wrapper.


How does it work with implementation-detail wrapper w/ #line in the arguments? Do they just use the line of function decl? Maybe we should treat them as API wrapper, but Assert is not making this easy!


That's largely the reason we remove var in SE-0003. It gets confused w/ inout semantic. And well, supporting wrapped inout is also in the future direction.

Regardless, I think it's more inline with wrapping non-mutating let local variables, which doesn't exist (I'm sure this time!).

3 Likes

Just to clarify, for init(wrappedValue:) and init(projectedValue:), you're suggesting to 1) type check in the context of the defining module of the property wrapper to determine whether or not the wrapper can be API, and then 2) type check in the context of the defining module of the function to determine the actual initializer that will get called? I thought about that briefly. One issue that I thought of is if overload resolution chooses an @autoclosure init(wrappedValue:) in the module defining the wrapper but overload resolution chooses a regular init(wrappedValue:) in the module defining the function.

EDIT: This also sounds like a really complicated type checking rule to think through as a user.

Agreed. I'll clarify in the proposal.

Yeah, the magic literals like #file and #line will use the context of the function declaration. It's certainly possible to look for magic literals in default arguments to the wrapper initializers, but promoting a wrapper to API based on default arguments feels like it could be unexpected...

§Restrictions on API-level property-wrapper parameters

Non-instance methods cannot use property wrappers that require the enclosing self subscript.

Is this referring to the static subscript(instanceSelf:wrapped:storage:), which was a future direction of SE-0258?

Terms of Service

Privacy Policy

Cookie Policy