SE-0242: Synthesize default values for the memberwise initializer

Swift does have a rule, independent of anything discussed here, that var x: Int? is equivalent to var x: Int? = nil. (Compare with let x: Int?, which does not have the same behavior.) I don't think we should treat var x: Int? differently from var x: Int? = nil for the purposes of memberwise initializers.

(People have talked about removing this behavior from var x: Int?, since "mutable stored Optional" is the only kind of declaration that gets an implicit default value. But I think that should be a different discussion and one that's applied consistently to all such declarations, not just the projection of the initial value into the memberwise initializer's default arguments.)

9 Likes

For anyone curious to try this out, the toolchains are now available.
macOS: [Sema] Synthesize default values for memberwise init by Azoy ¡ Pull Request #19743 ¡ apple/swift ¡ GitHub
Linux (Ubuntu 16.04): [Sema] Synthesize default values for memberwise init by Azoy ¡ Pull Request #19743 ¡ apple/swift ¡ GitHub

5 Likes

I do want to touch on what Jordan and the others regarding special types of default values for variables. The intention of this proposal is not to change the mental model when considering default values and the memberwise initializer. That being said, with Jordan's example:

func zeroWithSideEffects() -> Int {
  print("hello!")
  return 0
}

struct IntWrapper {
  var value: Int = zeroWithSideEffects()
}

let x = X()

the implementation only prints hello! once. A lot of what's happening is implementation detail, but in short, the default initializer is still present (because all properties are default initializable) in this case and the type checker favors that initializer over a memberwise one with full default values. Even without considering the default initializer, using the memberwise initializer still only prints hello! once. The case where it prints twice is where the user explicitly writes out an initializer and opts out of both default and memberwise initializers for the struct.

Regarding optionals, the implementation implicitly assigns nil as a default value because of the special default initialization of optionals. I didn't want to special case the memberwise initializer as Jordan points out.

2 Likes

Your example only shows a single property. What happens in the case where there are multiple properties and some do not have default values? The current behavior for a manually written initializer will perform side effects twice, as in the following code:

func zeroWithSideEffects() -> Int {
    print("hello!")
    return 0
}

struct IntWrapper {
    var value: Int = zeroWithSideEffects()
    var other: Int
    
    init(value: Int = zeroWithSideEffects(), other: Int) {
        self.value = value
        self.other = other
    }
}

// prints “hello!” twice
let x = IntWrapper(other: 42)
2 Likes

This still only prints once. The memberwise initializer is lowered a bit differently than say a handwritten initializer. It might be that this could cause a source of confusion because technically identical initializers behaving differently, and I see the argument against this. We might be able to change the behavior of an explicit initializer (source compatibility issues maybe?), but we can also theoretically make the side effect occur twice to be consistent with an explicit initializer (but it seems strange to introduce a double side effect).

1 Like

Grr. I didn't mean to get mixed up with the no-arg initializer in my example, but I have a strong distaste for the compiler doing something you can't write explicitly yourself, particularly if we ever get an "expose the memberwise initializer as public" annotation. I'm against this proposal if it does not evaluate twice if manually writing the equivalent thing does evaluate twice.

6 Likes

I hadn’t thought about the relationship of this proposal to the default initializer deeply enough. It makes sense that it would adopt behavior that matches that initializer. Doing anything different would be a breaking change since it combines the default initializer and the memberwise initializer into one initializer with default arguments. This is a nice simplification of Swift’s initializer model.

I generally agree with your sentiment, however as I mentioned earlier, I think what most people want is for the side effect to only be performed once. IMO, the fact that we can’t suppress the initial default value assignment when a custom initializer wants to initialize a property differently is very subtle and can result in suprising behavior. It also results in minor (but unnecessary) runtime overhead. I think it would be possible to solve these issues if / when we revisit SE-0018 and the initialization model more broadly.

In the meantime, I agree that the difference in behavior between the synthesized initializer and a manually written initializer would be somewhat unfortunate. On the other hand, it is not an entirely new problem - it only expands an existing difference in behavior that already exists for the default initializer:


func zeroWithSideEffects() -> Int {
    print("hello!")
    return 0
}

struct IntWrapper {
    var value: Int = zeroWithSideEffects()
    init(value: Int = zeroWithSideEffects()) {
        self.value = value
    }
}

// prints “hello!” twice
let x = IntWrapper()

The important factors IMO are:

  • this proposal provides significant convenience for a very common use case
  • the difference in behavior between synthesized and manual initializer already exists in a limited form
  • the synthesized initializer will provide semantics people probably desire for their initializers but cannot write manually
  • there is a clear path forward from this proposal to a more complete revisit of the initializer model and SE-0018

I think all of these factors combined significantly outweigh the issue of synthesizing code that cannot be written manually. I think it would be pretty unfortunate to reject this mostly straightforward and broadly supported incremental improvement on the grounds that we can’t manually write initializers with identical semantics to the synthesized initializer. Especially so, given that we do not want to write these initailizers manually and we want the synthesized semantics, not the semantics we can write manually.

6 Likes

I agree with Jordan that this is a negative, and a strong one, not a positive. What you illustrate about the current situation can be written manually by adding init() { }. It would be unprecedented to have an automatically synthesized initializer that is unutterable, particularly since such an initializer can never be exposed publicly. Moreover, since it's unutterable, it's also wildly unteachable: now we're saying that not only can't you see the body anywhere, we can't even show you the declaration.

3 Likes

True. Thanks for correcting me on that.

I agree that it’s a negative as well. But the root cause of the negative is in the existing semantics of default values and how they interact with manually written initializers. ¸I would very much like to see this solved in a more comprehensive way! But I think it would be really unfortunate to reject an incremental step in the right direction unless it was going to get in the way of a more comprehensive solution. I don’t see that being the case here and I think this proposal would deliver significant immediate benefit to many developers.

2 Likes

I strongly agree with @anandabits.

The existing behavior where side effects occur twice is wrong. It is not what people expect or want, and it should be considered a known bug.

It deeply saddens me to see that some people here are so strongly bound to an ideal of “consistency” that they would prefer to make code consistently do the wrong thing, even to the extent that they oppose adding a convenience feature that would do the right thing.

• • •

Luckily, there are two separate ways to view the proposed addition as entirely consistent.

First, we can declare the existing duplicated-side-effects behavior a known bug, which we fully intend to fix in the future. That means the side effects should only occur once, and the proposal is consistent with that goal.

Second, we can declare the proposal as being entirely syntactic sugar. That is:

struct Foo {
  var x: Int = xValue()
  var y: Int = yValue()
}

Actually desugars to:

struct Foo {
  var x: Int
  var y: Int
  
  init (x: Int = xValue(), y: Int = yValue()) {
    self.x = x
    self.y = y
  }
}

With this model, the side effects only occur once, and the equivalent code can in fact be written today.

• • •

No matter how we look at it, the proposed behavior is exactly what people want, exactly what they expect, and exactly what we should implement.

9 Likes
  • What is your evaluation of the proposal?
  • 1
  • Is the problem being addressed significant enough to warrant a change to Swift?
    Yes
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
    Nothing too surprising or out of sorts
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?
    I read the proposal and have considered the feature before

The only reasonable argument against this that I can think up is that we don't know how it will fit with the possible feature that would allow us to control the visibility of synthesized initializers if we still hope to add it.

What is your evaluation of the proposal?

+1, especially with the sugaring that @Nevin pointed out. IMO that's the most sensible way to make this addition.

Is the problem being addressed significant enough to warrant a change to Swift?

Yes

Does this proposal fit well with the feel and direction of Swift?

Yes

If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

N/A

How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

Quick reading

Neither of these views would be consistent or source compatible. The spelling var x = f() cannot be pure sugar because it is evaluated regardless of the initializer that is called. Similarly, the behavior today cannot be described as a bug because this notation is the only way to evaluate and assign the return value of f() to x regardless of the initializer that is called. This has many crucial implications, such as the answer to the question, “When has my value been fully initialized?”

The behavior today is exactly what I would expect and continue to want from var x = f(). And if the consequence of that is that an initializer with a default value causes f() to be evaluated twice for lack of a notation otherwise, then so be it.

(Note that, if f() has no side effects, there is of course no need for it to be evaluated twice. But like copy-on-write for value types, that should strictly be an optimization with no user-observable effects.)

There is already a way to spell what you are describing as “what the user wants.” It is to choose not to use the notation that explicitly and exclusively means “evaluate f() no matter what initializer is used.”

What if the sugar is not about default arguments but about not re-initialising already initialised values?

e.g. If we have

struct Foo {
  var a: String
  var b: Int
  var c: Double = .pi
}

The generated initialiser won't be

init(a: String, b: Int, c: Double = .pi) {
  self.a = a
  self.b = b
  self.c = c
}

but

init(a: String, b: Int, c: Double? = nil) {
  self.a = a
  self.b = b
  if let c = c { self.c = c }
}

I assume this solves the double-evaluation of the default value issue?
The only downsides I see is that

  • it effectively "hides" the default value (as it becomes nil), but that could be seen as a positive in some cases as well
  • the signature might be confusing as it might not be clear that passing nil to the value means leaving it at its default value

(I didn't see this option around but I might've missed it)

Only someone who is intimately familiar with the Swift compiler can write such a paragraph.

But we also have to deal with the Principle of least astonishment. Those double evaluations, even though consistent and perfectly rational, according to the current Swift model and implementation, are unlikely to be expected, even by seasoned developers.

I'd like to be even more assertive: there is a high chance that when someone discovers this double evaluation, it is because of some problem.

This is the context of the posts you were replying to: this double evaluation is weird.

It looks like we are building funny interview questions for the post SE-0242 world:

TELL ME, young candidate, are all those three structs API-compatible? ABI-compatible? Would your answer change if the name property were optional? Do you sometimes feel uncomfortable during refactoring? If so, what do you think could be fixed in the Swift initialization process? :eyes:

struct S {
    var name: String
    init(name: String = someComputation()) {
        self.name = name
    }
}

struct S {
    var name = someComputation()
    init(name: String = someComputation()) {
        self.name = name
    }
}

struct S {
    var name = someComputation()
}

(I'm sorry if any reader has tried to answer this interview question, because it is a real chore.)

This is a real trouble, I agree.

To conclude, the double evaluation we are talking about should be no threat for this proposal. We're just having a side discussion.

7 Likes

I'm wondering if the nullary initializer that we synthesize when all properties have a default would still be needed, since with this proposal its equivalent to an invocation of the memberwise init with no arguments.

Also I'm assuming that the definition of 'has default' here always includes Optional types? Today, the following will default-initialize the Optionals to nil:

struct S {
  var x: Int?
  var y: Int?
}
8 Likes

Is the "double evaluation" considered a longstanding limitation of the language initialisation model or a feature? If considered a feature, is there a well defined use-case?

I found the behaviour surprising, since it means that an expensive object could unexpectedly be constructed twice. Think for example a struct that with a default NumberFormatter.

Similarly, the behavior today cannot be described as a bug because this notation is the only way to evaluate and assign the return value of f() to x regardless of the initializer that is called

Could the behaviour be "relaxed" to say that the compiler is allowed to optimise away the initial variable default assignment, if it is reassigned in the initialiser, and there is no visible effect (property access) prior to that?

1 Like

No, that’s only if the default value is nil. Otherwise, you could write something like this only if the value is otherwise fully initialized and the type of the member in question is Equatable.

It’s precisely because it’s rational and perfectly consistent that it can be expected at all. By contrast, not evaluating the value twice is something that can be explained only by someone intimately familiar with the compiler. It’s not expressible in the language by any spelling and requires compiler magic.

2 Likes

I disagree. At present, the proposal does not specify the behavior of this scenario, and the implementation currently behaves in a way that cannot be spelled explicitly. I agree with Jordan that it must be specified, and that the specification must be double evaluation, for the reasons outlined above. Without resolution of this I would find this proposal to be problematic.