Does the new Swift 5.5 init(projectedValue:) functionality not work with synthesized memberwise initializers?

Proposal SE-0293, Extend Property Wrappers to Function and Closure Parameters, implements new features in the compiler that allow property wrappers to be initialized using the type of their projectedValues, but it doesn't appear to be working with memberwise initializers.

For example, given this property wrapper...

@propertyWrapper
struct PW {
    
    var wrappedValue: Int
    
    var projectedValue: Bool
    
    
    init(projectedValue: Bool) {
        self.projectedValue = projectedValue
        self.wrappedValue = 0
    }
    
}

Anywhere I use this property wrapper, I can initialize it using its projectedValue initializer using $:

func test(@PW hi: Int) {
    print("Don't mind me!")
}

test($hi: true)

But this doesn't appear to extend to automatically synthesized memberwise initializers:

struct Test {
    
    @PW var sotherbees: Int
    
}

Test($sotherbees: false) // error: cannot use property wrapper projection argument

To add on to this: it seems like property wrappers with init(projectedValue:) initializers don't work at all when used in another type's init(), synthesized or not. The compiler crashes.

cc @hborla

1 Like

I'm sure Holly will be able to fill in more detail or correct me if I'm wrong about anything, but IIRC SE-0293 didn't change the behavior of the synthesized memberwise initializer with regards to wrapped properties at all. That is, if Wrapper provides an init(wrappedValue:) then the memberwise initializer for a type like:

struct S {
  @Wrapper
  var x: Int
}

still has a signature of init(x: Int), not init(@Wrapper x: Int). We'd probably need another proposal to update the interaction of property wrappers with the memberwise initializer, and whenever such a proposal comes around I think there's some other ways we can improve the model too.

As far as:

It looks like this was reported as SR-15033 and fixed by PR #38854.

2 Likes

Part of why I assumed this proposal applied to memberwise initializers is because it explicitly references a case where this would be useful for memberwise initializers under its Motivation:

Currently, property-wrapper attributes on struct properties interact with function parameters through the struct's synthesized member-wise initializer. Because the @Traceable property wrapper supports initialization from a wrapped value via init(wrappedValue:) , the member-wise initializer for TextEditor will take in a String . However, the programmer may want to initialize TextEditor with a string value that already has a history. Today, this behavior can be achieved with overloads, which can greatly impact compile-time performance and impose boilerplate on the programmer. Another approach is to expose the Traceable type through the TextEditor initializer, which is unfortunate since the backing storage is meant to be implementation detail.

Fantastic!

1 Like

Yeah, I can see where the confusion arises—without presuming to speak for the authors, my interpretation of that passage is that it was meant to emphasize that the proposal enables the desired behavior (initialize the @Traceable property with a predefined history) without either of the drawbacks mentioned (declaring a history-accepting overloaded initializer or exposing the backing storage), rather than make a point specifically about the synthesized memberwise initializer.

1 Like

@Jumhyn is right! To add a bit more context, part of the reason why I didn't propagate property wrapper attributes on properties to their corresponding synthesized member-wise initializer parameters as part of SE-0293 is because that would be a source-breaking change. Consider this code:

import SwiftUI

struct MyView {
  @Binding var value: Int
}

The member-wise initializer for MyView takes in an argument of type Binding<Int> with a label of value. It can be called with MyView(value: Binding(...)). If this initializer instead used a wrapped parameter, you'd have to call it with the $ prefix in order to pass a Binding, e.g. MyView($value: Binding(...)).

Yes. One other way I'd like to improve the property wrapper initialization model is spelled out in the future directions of SE-0293:

2 Likes

Thanks for helping to clarify! I'm a bit confused about your example though. To continue to use your example — my understanding was that the value property gets synthesized in the member-wise initializer as init(value: Binding<Int>) because Binding doesn't define an init(wrappedValue:) initializer, so the rule is that the property wrapper's type itself is used as the parameter's type in the initializer. If Binding did define init(wrappedValue:) then the member-wise initializer wold use be MyView(value: Int).

Basically I thought:

  • If a property wrapper defines an init(wrappedValue:) initializer, the member-wise initializer will use the wrappedValue type (i.e. MyView(value: Int))
  • If there's no init(wrappedValue:) initializer, the member-wise initializer will use the property wrapper's type itself (i.e. MyView(value: Binding<Int>))

A quick test suggests that is how it works... But if that's not the case, what are the actual rules?

1 Like

Sorry for the confusion :slightly_smiling_face: You're right that Binding does not have init(wrappedValue:), but it does have init(projectedValue:). I would think that we'd want a wrapped parameter in the member-wise initializer in this case, but others may disagree.

This is super close, it's just missing a few corner cases:

  • If the property wrapper is default initialized, the member-wise initializer uses the property wrapper type, even if the property wrapper also has an init(wrappedValue:) unless the wrapped property is initialized in-line with =.
  • If init(wrappedValue:) takes extra arguments without default values, the member-wise initializer uses the property wrapper type unless the wrapped property is initialized in-line with =.

Examples:

@propertyWrapper
struct WrapperWithDefaultInit<T> {
  init() { fatalError() }

  init(wrappedValue: T) {
    self.wrappedValue = wrappedValue
  }

  let wrappedValue: T
}

@propertyWrapper
struct WrapperWithArgs<T> {
  init(wrappedValue: T, extraArg: Bool) {
    self.wrappedValue = wrappedValue
  }

  let wrappedValue: T
}

@propertyWrapper
struct WrapperWithDefaultArgs<T> {
  init(wrappedValue: T, extraArg: Bool = true) {
    self.wrappedValue = wrappedValue
  }

  let wrappedValue: T
}

struct S {
  @WrapperWithDefaultInit var value1: Int
  @WrapperWithDefaultInit var value2: Int = 10
  @WrapperWithArgs var value3: Int
  @WrapperWithArgs(extraArg: true) var value4: Int = 10
  @WrapperWithDefaultArgs var value5: Int
}

let s = S(value1: WrapperWithDefaultInit(wrappedValue: 1),
          value2: 2,
          value3: WrapperWithArgs(wrappedValue: 3, extraArg: true),
          value4: 4,
          value5: 5)

OK that helps a lot, thanks for those examples.

Final question (hopefully): I'm probably missing something obvious here, but how does introducing support for memberwise initializers that support the $-prefixed syntax for using a property wrapper's init(projectedValue:) break source compatibility in the case of MyView? If the rules you shared hold true, then @Binding would continue to work as it does now, and the only thing that changes is that MyView(value: Binding<Int>) now has a second initializer, MyView($value: Binding<Int>).

I don't think this is obvious! There are a few language features in Swift that seem like they cause the compiler to generate overloads, but they don't. For example, a common misconception is that default arguments add overloads, but that's not true:

func f(arg: Int = 10) { ... }

// There are no overloads of `f`, and calls to `f` are transformed
// to insert default arguments at the call-site.

f() // transformed to f(arg: 10)

Wrapped parameters use the same strategy of transforming the argument at the call-site. If we were to change member-wise initializers to use wrapped parameters, there wouldn't be additional overloads; there would still only be one init that has the property wrapper attached to a parameter, and the argument is transformed at the call-site to allow the caller to initialize the wrapper in various ways. So, calling the initializer with the property name as the label while passing Binding would no longer compile.

Of course, the compiler could add another overload for source compatibility, but I personally think it would be confusing if you're able to call the initializer with two different argument labels. If the type has multiple wrapped properties, there could also be a combinatorial explosion of overloads (e.g. if there are N @Binding properties, there would need to be 2N init overloads), which is really bad for compile-time performance.

3 Likes

How is that the case? Couldn’t the compiler generate only a traditional member-wise initializer and a wrapped-parameter one. This would result in two overloads, since — as you explained upthread — API-level-wrapper and default-value transformations do not generate additional overloads.

I’m probably missing something, but even if the performance issue is related to overload resolution, I think there’s a simple solution for that. I suggest we deprecate the current member-wise initializer in Swift 6, and use the wrapped-parameter one by default. Only in the case of an error, will the compiler try to use the traditional initializer — emitting a deprecation warning if it succeeds.

1 Like

Got it, I think! So the rules for a wrapped parameter are different, and there isn't a rule that creates a variation that accepts the property wrapper itself — correct? Wrapped parameters only accept either the wrappedValue type or projectedValue type (when $-prefixed).

I think I was assuming that for wrapped parameters there was a third rule: if there was no init(wrappedValue:) initializer, the default type they'd accept would be the property wrapper itself, but it sounds like that's not the case.

Do we have enough information from outside the module to determine whether a particular initializer is a synthesized memberwise initializer or not? If so, (never mind, the synthesized init isn't public :slightly_smiling_face:) I'd say we should go even further in this hypo and simply disallow calling the "traditional" memberwise initializer from source in Swift 6 mode—if users want that then they can specify the language mode appropriately.

For ABI stability it seems like resilient libraries would have to keep the traditional initializer around in perpetuity (since API-level wrappers change the type of the parameter), so there are also code size implications to changing the synthesized init behavior. Maybe they wouldn't be that bad in practice, though.

Edit: the fact that the synthesized init is non-public saves a lot of headaches!

2 Likes

Yep, that's right!

Right, there is no fallback to the property wrapper type for wrapped parameters.

1 Like

I thought the suggestion was that is how the model should work. If we're talking about a migration mechanism, another overload sounds reasonable. That said...

I agree with this. If this is something folks are interested in pursuing, it feels like this is the point where someone should write up a pitch? :slightly_smiling_face:

2 Likes

Since ABI stability is a non-issue for the synthesized init, I think we could achieve most of this functionality in a source-compatible way:

If the synthesized init would use the wrapped type for initializing a wrapped property and the wrapper supports init(projectedValue:), then the memberwise init gets the wrapper applied as an argument wrapper for that member.

This would exclude wrappers like Binding making their way into the synthesized init (since under the current rules they're exposed as the storage type).

Of course, all the edge cases around how property wrappers interact with the synthesized initializer are already pretty complex, so I'm not eager to add a further carve-out for this case—perhaps its better to leave the current synthesized initializer as-is and go all in on the "new" initializer.

This may be what you're already imagining, but I would love if we could include wrappers initialized with their own argument list as well so that all the current complexity could be consolidated under a rule like:

A property declared as @Wrapper(arg1: foo1, ..., argN: fooN) var x: Int = 3 appears in the memberwise initializer signature as init(..., @Wrapper(arg1: foo1, ..., argN: fooN) x: Int = 3, ...) (where the = 3 and argument list on the wrapper attribute are both optional).

along with an error if this substitution would result in an uncallable initializer—the user would either have to modify the property/wrapper initialization, or define an explicit initializer in those cases.

Once I close the loop on my previous proposal, I'd love to take a crack at writing up this pitch (but if anyone else is motivated in the meantime, don't let that stop you from getting to it first! :slightly_smiling_face:). This is a source of poorly-documented complexity that has always bugged me about property wrappers and I think with SE-0293 we're finally at the point where we can clean this up nicely.

4 Likes

I think that would create a dialect, which is unaligned with Swift's principles. I'd argue for the opposite:

  1. a Swift 5 flag to opt into deprecation mode; and
  2. a Swift 6 deprecation,

This is akin to Concurrency's handling of erroneous code (EDIT) in Swift 5 mode: it is permitted with an option to be warned about it.

To further ease migration, we could also soft-deprecate traditional inits in Swift 5. That is, we wouldn't suggest them in auto-completion, while keeping them valid and warning-free.


I think keeping around both inits is preferable given Swift's source-compatibility constraints.


I'd love to write a pitch for this! When you are ready, or if anyone else is interested, feel free to DM me.

EDIT: Along with a new init for wrapped properties, we could completely overhaul wrapper-related initialization with projection-based wrapped-property initialization.

1 Like

I'm not sure how this would be different than any other source-breaking change that we've adopted across a major language version bump. The rule would just be "in Swift version <6, we use the 'legacy' memberwise init synthesis algorithm, in Swift version >=6 we use the 'new' one."

Perhaps I'm out-of-date on the latest Concurrency plans, but I thought this characterization only applied to code in the Swift 5 language version:

That is, in Swift 6 we will start emitting errors for code that would compile fine (with an optional warning) in the Swift 5 language mode.

Yeah, this would be great. We'd need to make sure there's always a way for users to disambiguate between the two inits in cases where they might be ambiguous, though :thinking:.

Will do! Personally, I think these should be two separate proposals, with the memberwise init proposal also encompassing any other 'fixes' we want to make to the synthesized init (potentially unrelated to property wrappers, even if that's the main motivation).

1 Like

Sorry for the confusion; I misread, arriving at the conclusion that you suggested a Swift-6 ban on traditional inits and a compiler flag to opt-out of the ban.

So, if I understand correctly, the traditional init would still be available but deprecated.

Right, I meant to specify "in Swift 5 mode."

Good point. We will definitely need to think about the edge cases of soft deprecation since its unprecedented in Swift — as far as I’m aware.

1 Like

Ah, got it! Yeah, I wasn't suggesting that there would be an "opt-out" flag, but...

I was suggesting that Swift 6 remove the "traditional" init entirely. The way I see it, the initial introduction of property wrappers introduced a nontrivial amount of complexity into the synthesized memberwise init due to its inability to support property wrappers in parameters.

I see this project as an opportunity to scale back on the complexity of the memberwise init with respect to property wrappers. If we don't do the hard break at the Swift 6 boundary then we'll be stuck (at least until Swift 7) in a state of affairs where we have to retain all the existing complexity of the traditional init in addition to "there's actually two memberwise initializers if you use property wrappers but one of them is deprecated."

As I consider it further, I think the best thing to do pre-Swift-6 would be to start warning on uses of the old init (a "hard" deprecation, I suppose). The way I see it, in cases where the traditional and "new" init would differ (potentially only for types with wrapped properties, unless there are other things we want to fix about the memberwise init?) we should:

  • Generate both initializers.
  • Unconditionally prefer the new init in otherwise ambiguous cases. Hopefully in cases where either init works, we won't have behavior differences...?
  • Warn about surviving uses of the traditional init. I think this would basically be limited to cases where the traditional init exposes the backing storage type, and all the wrapped properties have an appropriate init(projectedValue:) we could suggest using the $ syntax as an alternative.

This is perhaps more aggressive than you were imagining, and I tend to have a fairly high tolerance for source breaking changes, but IMO the current situation where we expose the (private!) storage type for direct initialization via an internal init is a strong motivator for a source break—if the wrapper author wants to expose that functionality, they can add an init(projectedValue:), and if the API author wants to provide that functionality without support from the wrapper then they're free to define their own custom init.

Terms of Service

Privacy Policy

Cookie Policy