Default property value assignment is skipped when calling synthesized memberwise initializer of struct

I found this inconsistency in property initialization of structs, depending whether the synthesized memberwise initializer or custom initializer is used:

func generateDefaultName() -> String {
    print("Generating default name")
    return "Anonymous"
}

struct User1 {
    var name = generateDefaultName()
}

struct User2 {
    var name = generateDefaultName()
    
    init(name: String) {
        self.name = name
    }
}

  let user1 = User1(name: "User1") // User1.name's default value is not assigned, `generateDefaultName()` is not called. `user1.name` is directly set to "User1".
  let user2 = User2(name: "User2") // User2.name's default value is assigned with the result of calling `generateDefaultName()`. `user1.name` is set to the default value first and then "User2". This is the expected behavior to my understanding.

Is this a bug?

2 Likes

The core team actually understands this behavior to be the bug:

See also:

5 Likes

Interesting, thanks for sharing!

Do you know why core team thinks that the default value should be skipped in custom initializer that sets its value? Also it seems there is no consensus: SE-0242: Synthesize default values for the memberwise initializer - #102 by Slava_Pestov

If this bug is fixed, then the following code which is valid today will fail to compile:

struct User2 {
    var name = generateDefaultName()
    var somethingElse: String
    
    init(name: String) {
        self.somethingElse = self.name // ***
        self.name = name
    }
}

besides the fix would break exiting apps that depend upon this behaviour (because of Hyrum's law). Safest would be to not fix and live with it.

How do other languages cope with this?

To be clear, I personally think synthesized memberwise initializer skipping default property value assignment is a bug, not that custom initializer doing default property value assignment is a bug.

Doesn't matter which way you fix it – it will break some valid existing code.

I checked a couple: Kotlin & Java also call side effect before overriding the value.

People who are relying on their initial value setter not being called due to using a synthesized initializer rather than an explicit one were asking for trouble

Thanks for digging down. Consider these use cases:

struct S {
    var other: String
    var name: String = sideEffect()
    
    // Obviously sideEffect is called once:
    init(other: Sting, name: String) {
        _ = self.name // Obviously allowable here
        self.other = other
    }
    // Obviously sideEffect is called twice:
    init(other: String, name: String = sideEffect()) {
        _ = self.name // Obviously allowable here
        self.other = other
    }
    // here is where we are not sure:
    init(other: Sting, name: String) {
        _ = self.name // should this be allowable here?
        self.other = other
        self.name = name
    }
    // here is where we are not sure:
    init(other: String, name: String = sideEffect()) {
        _ = self.name // should this be allowable here?
        self.other = other
        self.name = name
    }
}

In init 1 & init 2 it's obvious what should happen.

I believe for simplicity of reasoning init 3 and init 4 should behave the same, otherwise the logic is too convoluted. In other words this:

if there's explicit initialiser that matches the signature of what
autosynthesized initialiser would have been, that does assign 
the property in question to the particular named parameter and 
doesn't use that property beforehand – then follow the 
autosynthesized initialiser rule, otherwise do what we currently do.

is much more complicated than this:

member wise initialiser rule has to change to call the side effect.

That is if we do want to align the two.

The crucial here if we should allow "_ = self.name" before name is being set in the initialiser – I think we should – but then the rule whether sideEffect is called or not becomes complicated (e.g. the mere presence of "_ = self.name" makes a spooky behaviour change).

Semantically, this:

struct User1 {
  var name = generateDefaultName()
}

Is shorthand for this:

struct User1 {
  var name: String
  init(name: String = generateDefaultName()) {
    self.name = name
  }
}

This is obviously a correct formulation, and it never needs calculate the default if the caller supplies a name itself. This can be important if generateDefaultValue() is expensive.

The fact that this:

struct User2 {
    var name = generateDefaultName()
    
    init(name: String) {
        self.name = name
    }
}

Isn't sugar for this:

struct User2 {
    var name: String    
    init(name: String = generateDefaultName()) {
        self.name = name
    }
}

Or for this:

struct User2 {
    var name: String    
    init(name: String) {
        self.name = name
    }
    init() {
        name =  = generateDefaultName()
    }
}

Is an obvious deficiency in the code that synthesizes the default initializer.

The best way to solve this compatibly for people who were relying on the erroneous behavior, is to take advantage of the fact that we already do liveness tracking for individual fields in an initializer.

The compiler should, instead of erroring if you access a member in an init before that member has been initialized, first check if it has a default value. If it does, assign that default value before the access, otherwise emit the usual error.

1 Like

Upon reflection I take this back. The algorithm could be simplified to this:

  1. if self used within the initialiser (e.g. print(self)) - check if there are any stored properties not yet initialised - for those see if they have initial values - initialise those properties with their initial values - check if there are still uninitialised fields - if so bail with error, otherwise proceed.
  2. if self.property is used within initialiser (e.g. print(self.property)) perform the above step with just that one property.
  3. at the end of initialiser pretend self being used somehow and perform step 1.

With this approach the initial values will be executed as late as possible and only if needed. It would be a breaking change with the current behaviour, but it seems like it will do more good than bad going forward. +1 to consider the current behaviour a bug worth fixing.

1 Like