Help understanding property wrappers

Reading the Swift Programming Language's Property Wrappers section I had the impression that I intuitively understood how property wrappers should work. My naive intuition was:

If you have a struct with a property and annotate that property with a property wrapper, its type won't change, you'll get the "effects" provided by the property wrapper type and eventually a $-prefixed property that exposes an associated projected value.

However, reading the actual implemented proposal, specifically the Memberwise initializer section, under some circumstances, the memberwise initializer may not provide wrapped type parameters but wrapper type parameters instead:

[...], the memberwise initializer parameter for an instance property with a property wrapper will have the original property type if either of the following is true:

  • The corresponding property has an initial value specified with the = syntax, e.g., @Lazy var i = 17, or
  • The corresponding property has no initial value, but the property wrapper type has an init(wrappedValue:).

Otherwise, the memberwise initializer parameter will have the same type as the wrapper.

Is there a particular reason why this different behavior should apply? From the Swift Programming Language:

A wrapper that needs to expose more information can return an instance of some other data type, or it can return self to expose the instance of the wrapper as its projected value.

With this in mind, the wrapper type should be exposed in the structure only as the projected value if the API author decides so and it should be available in an initializer only if the author explicitly provides that initializer.

Why is the wrapper type exposed in the generated memberwise initializer?

Great question!

You're right that if you annotate a property with a property wrapper, the type of that property doesn't change. However, a property wrapper annotation does make that property a computed property rather than a stored one, and the compiler will synthesize the storage for you. This means that the synthesized storage is what needs to be initialized. The type of the storage that needs to be initialized is the property wrapper itself. Some property wrappers are allowed to be initialized using the wrapped value type, but this is only possible when the property wrapper defines init(wrappedValue:), which is not a requirement for property wrappers.

Because init(wrappedValue:) is not a requirement for property wrappers, not all property wrappers can be initialized using the wrapped value type. So, if the property wrapper does not provide init(wrappedValue:), the generated memberwise initializer for the type containing that property wrapper must take in the property wrapper type directly, because that is the only way the storage can be initialized.

Please let me know if you have any further questions!

2 Likes

Thank you for the quick reply!

May I go a little bit off-topic explaining my mental model in order to understand its flaws and consequently the reasons behind SE-0258? It partly resembles Allow Property Wrappers with Multiple Arguments to Defer Initialization when wrappedValue is not Specified.

Borrowing a simplified example from the SE-0258 proposal:

@propertyWrapper
struct Field<Value: DatabaseValue> {
  let name: String
  private var cachedValue: Value?
  
  init(name: String) { self.name = name }
  
  var wrappedValue: Value { get set ... }
}

struct Person: DatabaseModel {
  @Field(name: "first_name")    var firstName: String
  @Field(name: "last_name")     var lastName:  String
  @Field(name: "date_of_birth") var birthdate: Date
}

Field is a property wrapper that doesn't provide any .init(wrappedValue:) or .init(wrappedValue:...) initializer. Thus properties annotated with @Field cannot be initialized with an initial value in the struct and this implicitly means that their initial value should be provided by the property wrapper type itself (in this case, the property initial value would be the one fetched from the database). In line with @Andrew_Arnopoulos's proposal, I would expect it to be transformed into:

struct Person: DatabaseModel {
  // no default values here
  private var _firstName: Field<String>
  private var _lastName:  Field<String>
  private var _birthdate: Field<Date>

  var firstName: String { get set ... }
  var lastName:  String { get set ... }
  var birthdate: Date   { get set ... }

  init() {
    // default values here instead
    _firstName = Field(name: "first_name")
    _lastName  = Field(name: "last_name")
    _birthdate = Field(name: "date_of_birth")
  }
}

Only an empty memberwise initializer should be generated since it's not possible to provide an initial value to @Field wrapped properties.

Suppose now that also an .init(wrappedValue:name:) initializer is available:

extension Field {
  init(wrappedValue: Value, name: String) {
    self.name = name
    self.wrappedValue = wrappedValue
  }
}

In this case we can either provide an initial value using = or not, leaving its initialization in the generated memberwise initializer:

struct Person: DatabaseModel {
  @Field(name: "height") var height: Double
  @Field(name: "alive")  var alive:  Bool = true

  // generated memberwise initializers:
  // - case 'height' is using Field.init(name:)
  init(alive: Bool = true) {
    _height = Field(name: "height")
    _alive  = Field(wrappedValue: alive, name: "alive")
  }
  // - case 'height' is using Field.init(wrappedValue:name:)
  init(height: Double, alive: Bool = true) {
    _height = Field(wrappedValue: height, name: "height")
    _alive  = Field(wrappedValue: alive, name: "alive")
  }
}

The property alive has an initial value, so .init(wrappedValue:name:) is the only initializer matching its declaration. On the other hand, the property height does not provide an initial value so there are exactly two cases:

  • height is using .init(name:), so a memberwise initializer without an height parameter should be generated;
  • height is using .init(wrappedValue:name:), so a memberwise initializer with an height parameter should be generated.

This has bad scalability implications, since now 2^n initializers would need to be generated, with n being the number of properties having no initial value and both .init(wrappedValue:...) and .init(...) available in their wrapper types.

Is this a reason why we cannot (or shouldn't) allow storage initialization in inits when wrappedValue is not specified?

On the other hand, having those memberwise initializers generated that way, has some advantages:

  • it hides the wrapper types, which is in line with the very first line of the Swift Programming Language Guide section about property wrappers:

    A property wrapper adds a layer of separation between code that manages how a property is stored (the wrapper type and its implementation) and the code that defines a property.

  • it makes your code more safe (kind of), since now you cannot pass an instance of Field with a name different than the one specified in the annotation, i.e. the following wouldn't be possible:
    let person = Person(height: Field(name: "eye_color"))
    

I wonder if reducing the cases to be 1^n, i.e. by forcing one of the "overloads" or by using the "pick the most specific overload" already vastly used in the language, would be beneficial to the whole property wrapper field.

This is almost right. The generated memberwise initializer still has arguments, but they are defaulted. So, the init really looks like this:

init(firstName: Field<String> = Field(name: "first_name"), 
     lastName: Field<String> = Field(name: "last_name"), 
     birthdate: Field<Date> = Field(name: "date_of_birth")) {
    _firstName = firstName
    _lastName  = lastName
    _birthdate = birthdate
  }

This allows the programmer to call the initializer with any of these arguments not specified, and the default argument will be used.

Only one memberwise initializer will ever get generated by the compiler. The compiler chooses which type the memberwise initializer will take depending on whether the property wrapper was initialized via = and whether the wrapper has init(wrappedValue:). Default arguments are used so that the compiler is not generating overloads of the memberwise initializer while still providing the convenience of leaving arguments out of the call.