Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to me as the review manager. When contacting the review manager directly, please put "SE-0546" in the subject line.
What goes into a review?
The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:
What is your evaluation of the proposal?
Is the problem being addressed significant enough to warrant a change to Swift?
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?
How much effort did you put into your review? A glance, a quick reading, or an in-depth study?
More information about the Swift evolution process is available at:
As described, the same-file memberwise initializer must have the (exact?) same argument labels and argument types to suppress the compiler-provided init. Do the following features change that suppression?
Default values for any of the parameters (hopefully not, since that's one of the things the compiler-provided memberwise init can do)
Failable return
throws and async
A where-clause on the extension or the init itself, for a generic struct
Availability attribute(s)
Isolation
Marking as convenience, which isn't really necessary for structs but I believe is still legal?I may be misremembering though
I'm happy to say no, the suppression happens purely based on argument labels and argument type exact matching, but it feels like these things should be written down that affect how the init might be used (most of which cannot normally be overloaded on).
Generally all the rules that would prevent an extension init due to "Invalid redeclaration of synthesized memberwise 'init'" are allowed by this proposal, but there are exceptions to call out:
While init? and init throws will suppress the memberwise initializer, init async is a legal overload and does not
A where clause adds a constraint that is legal today and is still legal with this proposal, and does not suppress the memberwise initializer
convenience is not legal for structs today
Default values do not affect suppression in that an extension with init(count: Int = 0) will suppress the memberwise initializer on struct S { var count: Int = 0 }, but you bring up a good detail about initializer synthesis, and that's the fact that "memberwise" initializers and default empty "init()" initializers are distinct concepts, and the current implementation does not suppress default init()s.
That leads to the following behavior:
struct S {
var c = 1
}
extension S {
init(c: Int = 0) {
self.c = c
}
}
S().c // 1
So it seems like maybe an additional rule should apply: if a memberwise initializer has defaults that could suppress the default empty init(), then the default empty init() should further be suppressed. This should be source compatible given the limitations we have today. Thanks for poking things!
I'd be happy to push the implementation that direction if folks agree.
I would imagine the same thing that happens if you declare this type with a non-extension custom memberwise initializer. That happens to be a "type does not conform to protocol" error, because apparently an initializer with all defaulted parameters can't be used to witness a default initializer.
It seems a simpler rule would be that a user can write an explicit zero-argument init() to suppress the default init() for the rare scenario where this comes up. Based on some cursory twiddling in the REPL, currently the diagnostics for that are...interesting and unintuitive. It would make sense to me that default empty init() could stand to be given parallel treatment to memberwise initializers.
By contrast, I worry that it would be surprising to have the presence or absence of default arguments in one overload suppress another invisible overload at a distance. I think the rules are subtle enough that users who aren't thinking explicitly about these default initializers would not expect writing = 42 somewhere to delete another overload.
My overall evaluation of the proposal here is that I think we'll ultimately want to give users flexibility like this, and the rules proposed are sensible enough that I think it's the shape we'll ultimately want. I continue to worry about the order in which this feature is introduced relative to other features that we'll want: in other words, that the 'resting place' that we'll reach with only this feature is less than ideal for end users.
As I understand it, a major motivation for the proposal is for macros that generate types for which users may want to customize the access level of the memberwise initializer (but also may not, in that they may also want to keep the default memberwise initializer). Both now and with the currently proposed feature, users will continue to need to write out the full implementation of that initializer by hand. With this proposal, users will also bear the responsibility of ensuring that the declaration of the initializer is an exact match (with respect to parameter order, etc.).
Most memberwise initializers have an obvious desired behavior to humans and are boilerplate-y to implement, and (afaict) no one is taking the position that having to write out the boilerplate is a desirable thing here. Rather, macros cannot generate memberwise initializer implementations in full generality and the Swift language also lacks the expressivity to say, "give me the default memberwise initializer the compiler already knows how to generate but with a different access modifier."
So we are still leaving it as the user's problem, by default and not by design, to align their extension initializer exactly to a macro-generated type, a back-and-forth dialogue with code that is only technically in the same file from the perspective of the compiler but most definitely not actually written in the same file nor under the same authorial control.
That said, two things make this concern less than an absolute dealbreaker: (1) It's a valid point that users are already writing similar implementations to work with macro-generated types, just with static members named something like create() to work around the limitation at issue; so, modulo the init declaration exact-matching issue, the proposed feature isn't making their experience clearly worse; (2) if the user's initializer implementation fails to align perfectly with the generated code, oftentimes the failure will be loud rather than silent—I believe the only scenario in which we could run into silence corruption here would be where a library author thinks they've suppressed the default memberwise initializer but didn't in fact, calling the default initializer within their own module but inadvertently vending a somewhat slightly-off public API to end users that they didn't adequately test or dogfood. These are probably not likely to be commonly encountered issues; moreover, this proposal wouldn't stand in the way of further language features that address them and may even tend to apply pressure to get us there faster.
So I think ultimately accepting this feature now is fine even if not the ideal order of things.
Yeah after thinking about it more I agree that init() should not be suppressed by a memberwise initializer with defaults, in the same way that a memberwise initializer with defaults does not witness an init() protocol requirement.
I wonder if generated memberwise initialisers access control should match that of the struct itself. Eg if the struct is public then it’s init would be public too.
That would solve some of the problems here. And the dance around making a struct public and having to write out the boilerplate init would be removed entirely for many situations.
This was intentionally not done because it would mean that if you added a new stored property of the struct in the future, existing clients would be incompatible since they don’t pass it to the initializer. Instead, you are required to write out the initializer and keep it working even if you add more properties later.
Whilst I understand and agree that handling breaking changes requires care, I feel like maybe the priorities might have been wrong.
Handling and managing breaking changes should be the responsibility of the developer, not the language. A simple unit test should protect against accidental shipping a breaking in a generated memberwise init.
The result of the status quo is a lot of boilerplate manually written because of protecting people from themselves.
The problem is that it is so easy to ship the initializer without noticing, and then you’re stuck with it forever. And if you don’t want to make it public, you’re back in the same boat: now you need to manually re-declare it as internal.
FWIW Swift takes the same stance with Sendable. An internal type can be inferred to be Sendable without explicitly conforming, but the moment you make the type public that inference stops. That doesn't cause the boilerplate problem like initializers do, but Swift does this for the same reason: it's not correct to publicly ship API that the user did not write. It would force the user to maintain a Sendable conformance that they may not have intended, and same for the initializer.