Same-file memberwise initializer extensions

Same-file memberwise initializer extensions

Summary of changes

Allows a struct's memberwise initializer to be defined in a same-file extension.

Motivation

When a struct's memberwise initializer is meant to be public (or package), it is only possible to define it in its base declaration:

// ✅
public struct Post {
  public var title: String
  public init(title: String) { self.title = title }
}

Defining it as an extension is currently a compiler error:

public struct Post {
  public var title: String
}
extension Post {
  // 🛑 invalid redeclaration of synthesized memberwise 'init'...
  public init(title: String) { self.title = title }
}

This organizational limitation was brought up on the forums many years before Swift had macros in the language, but now that structs can be generated by macros, there is no way to publicize their memberwise initializers:

@Draftable public struct Post {
  public var title: String

  // Generated:
  public struct Draft {
    public var title: String?
  }
}

extension Post.Draft {
  public init(title: String?) { self.title = title }  // 🛑
}

Proposed solution

Allow memberwise initializers to be defined in same-file extensions of the struct:

public struct Post {
  public var title: String
}
extension Post {
  // ✅
  public init(title: String) { self.title = title }
}

Detailed design

A handwritten initializer that is indistinguishable from the synthesized initializer (i.e. it has the same argument names and types defined in the same order) will now suppress the default, synthesized initializer when defined in a same-file extension.

Source compatibility

The change is additive, and only allows code that previously failed to compile to compile.

ABI compatibility

This change requires no changes to ABI.

Implications on adoption

This feature can be freely adopted and un-adopted in source code with no deployment constraints and without affecting source or ABI compatibility.

Future directions

Future directions for memberwise initializers has been brought up before on the forums, including a pitch for explicit synthesis, as well as other shorthands. This pitch shouldn't have any effect on further evolution of memberwise initializer functionality.

Alternatives considered

An alternative would be to allow these extensions to be defined anywhere in the module, but as mentioned in the original forum thread, this would make it more difficult for humans to read and reason about the code. Limiting to the same file minimizes this burden, and there are similar precedents are set for syntesizing Codable and Hashable conformances in same-file extensions.

Another alternative is to leave things as is, and folks can work around the problem with static func create APIs, or rearranging property order just to allow for a differently-ordered version of a memberwise initializer to be publicized. Each of these alternatives is not without its limitations: a static function introduces a naming/design burden on the code, and deliberately reordering properties to allow an initializer to be publicized is fragile.

Acknowledgments

Thanks Diana Ma ("Taylor Swift") for first suggested this functionality on the Swift forums.

16 Likes

Sorry, can you explain what this means? I had thought that the pitch was to make it possible to define such initializers in the same file, not to forbid them?

It means that handwritten initializers that suppress the synthesized initializer when defined directly in the type can now also be written in an extension. Would "suppress" be better than "elide"? Or do you have a suggested wording?

The rule today is "if you write any init in the main body of a struct, the implicit init is suppressed". I understand the proposed change to be "additionally, if you write an init that looks sufficiently like the implicit one, the implicit one is suppressed, even if that init is outside the main body (but still in the same file)". This is not a source-compatible change unless "sufficiently like the implicit one" does an exact match on both labels and types. Is that actually the condition we want? Or do we want something looser that's technically not source-compatible, but unlikely to be a problem in practice? (For instance, using stricter types, or additional arguments with defaults.) Or do we want an explicit attribute on the init or the extension (or the struct?) that says "hey, I'm taking over init duties even though I'm not in the original body"?

1 Like

The current implementation attempts to do that, so I believe source compatibility is achieved here.

Definitely open to discussing more alternatives! I think stricter types or additional arguments with defaults is already allowed by the current model (the internal synthesized initializer would be synthesized again). Something like an explicit attribute is mentioned in "future directions" with a nod to the Explicit Memberwise Initializers proposal.

1 Like

I'd consider a radical approach (like this or similar) that would eliminate the very need of writing those inits manually in the majority of cases.

1 Like

Thanks! I added your link under "future directions," where further evolution is discussed. I believe this pitch could still land without blocking such shorthands from landing in the future, though.

Ah, sorry, never mind—it is a misreading arising from the fact that “will now be allowed” reads like “will not be allowed.”

If we're going to go fiddle with this stuff, I'd prefer and/or also want a way to explicitly create the default memberwise initializer with varying access levels, without having to type out the body.

straw-person:

public struct TwoThings {
    public var integer: Int
    public var string: String?

    default public init
}
4 Likes

This is mentioned as a future direction, and nothing about this proposal should prevent such evolution. You say "and/or", which sounds like you are open to this pitch as long as a more ergonomic syntax is still possible in the future, but if you want to emphasize the or, can you articulate any particular reason why this pitch should not land in the meantime?

I think this pitch is fine as-is, except that it feels like a very oddly targeted snipe at a piece of the larger problem around default memberwise initializers, that I haven't personally encountered.

I don't think I've ever come across a situation where I got the default memberwise initializer because I have no others in the primary declaration, but desperately wanted to spell it in an extension? How do you arrive here? Why would you not just write it in the main block? Is it motivated by macros?

By far the most common problem I encounter is that if I define any initializer in the primary declaration, it wipes out the default initializer, which leads me to define "unnecessary" extensions to put my custom initializers, just to retain access to the default initializer. I could spell out the default initializer, but I'd rather not have to in the first place.

5 Likes

Yup, macros are mentioned at the end of the motivation:

And so even if we got something like your straw person syntax, we'd still want to support it in same-file extensions:

extension TwoThings {
  default public init
}
3 Likes

If macros are the primary motivation, since @jrose reminds us that any init in the main declaration will suppress the implicit init, is it a feasible workaround with today's rules to have the macro generate source as follows:

@Draftable public struct Post {
  public var title: String

  // Generated:
  public struct Draft {
    public var title: String?
    private init(__suppress_default_init: Never) { fatalError() }
  }
}

extension Post.Draft {
  public init(title: String?) { self.title = title }  // ✅
}

It is, of course, not exactly pretty. But it doesn't have to be looked at, leaves almost no footprint, and even if observed is pretty self-explanatory...

1 Like

That would effectively make Draft non-constructible unless the initializer was reimplemented from scratch. And this technique would have to be known by every macro author, and they would probably want it to be configurable (e.g. @Draftable(suppressInit: true)), and so even every macro user needs to be aware of this technique. What we are proposing allows the synthesized initializer to still be usable.

2 Likes

Sorry, perhaps then I'm misunderstanding. As I understand the pitch, the point is that it's for those cases where you'd want to reimplement the initializer (from scratch, or with some hypothetical public default init feature) outside of the primary declaration, no?

If not desired—in this example, for instance, if it's important that the memberwise initializer be available without users reimplementing it—both humans and macros alike would be able to write it into the primary declaration (or rely on the implicit default), and it renders this pitch a moot point?

Sorry, again perhaps I'm not understanding this part either. The proposal says (as it would have to be, unless I'm missing something) that the synthesized initializer would be suppressed. Two initializers with indistinguishable signatures cannot both be usable...no?

2 Likes

Yes, the point of the macro is to allow users to reimplement memberwise-like initializers outside primary declarations where it is otherwise synthesized by the compiler.

Can you explain more as to what renders this pitch a moot point, though? I’m not sure I understand.

The low-friction expectation is that a user could apply a macro that generates a type and:

  1. Immediately get access to its memberwise initializer for use in the current module
  2. Have the ability to redefine this initializer more publicly if they need to exercise it outside the module

If the macro emits a __suppress_default_init initializer by default, the user must skip 1 and go straight to 2. The point of macros is to eliminate boilerplate, and here we have a macro that now requires boilerplate from its users to use it.

If the macro is configurable by suppressInit then that’s something the macro needs to communicate to its users, via documentation, etc. And the user needs to find this configuration, etc.

Either way, this is work that every single macro that generates types would need to employ, while this pitch tries to provide a general solution using same-file semantics similar to other synthesis.

As for “macros […] would be able to write it into the primary declaration,” this would require a public default init-like feature to be feasible. It’s not possible for a macro to always implement a valid memberwise initializer right now, and the limited subset that is possible is a lot of logic for each and every macro to individually get right. And even if it were possible for the macro to generate this code, it would still be moving configuration to a macro argument when it could be written as simple Swift for the user. The equivalent of:

@MyMacro(generateNestedEquatable: true)
struct MyType { /*… */ }

// vs.

@MyMacro
struct MyType { /*… */ }

extension MyType.Nested: Equatable {}

I think @mbrandonw simply meant that this memberwise shape of initializer would be usable because the handwritten initializer would suppress the synthesized one.

Got it—this makes sense and clearly solves a particular pain point. I do find myself agreeing with other commenters, though, that the feature is a lot more compelling with public default init. Indeed, for macros, it'd be not just a nice-to-have but actually rather a big usability issue not to have. Here's my thinking:

Taking as given that it's not really possible for macros to reliably synthesize a memberwise initializer, never mind the whole customizability angle, devising new rules that end up leaving it to the user to write one "from scratch" is, to my mind, the third-best thing.

My hunch (data-free, to be fair) is that most macros will have some sort of usually-correct, if not always-correct, access level for a memberwise initializer. For instance, one may surmise that a @Draftable in some library is used in certain ways such that it's blazingly obvious whether a memberwise initializer ought to be API or ought not to be.

Yet, as you point out, even if it were always the case for some macro that a public memberwise initializer would be appropriately emitted in the primary declaration, it could not reliably be achieved. For me at least, the optimal solution here is to make that possible, which calls for a public default init-like feature and wouldn't then require this pitch.

Second best would be to leave it to the user but not require them to write it from scratch—that would require a public default init-like feature and this pitch. Your point is well taken that, if a macro requires configurability of the access level because there is not an obviously correct one, this would be a "first best" solution for such macros if we prize composability, as opposed to a macro argument. (I am not so sure that such macros are such the norm that a spelling like @Draftable(initializer: .public) would be that ridiculous when it's relevant, but that can be debated another day. Suffice it for now that I buy your argument that a public default init-like feature doesn't obviate this pitch.)

Third best would be to have this pitch only and not public default init. For a user-written type, having public default init is "merely" an ease-of-use win, and in this age of AI miracles arguably not much of a win at that. But for a type that's generated by a macro, the stored properties of which would not then be visible by default, without public default init the user would have to inspect the generated code as the source of truth as to how to write the implementation body. And worse, if a later version of the macro does something slightly different, the user's implementation could break loudly or silently without any changes in their source.

So having thought this through in the context of macros, I feel a little leery about only having this pitch—I worry that it does make some things possible but at the cost of correctness issues down the line.

1 Like

I agree with the other commenters that such a feature is compelling, and I'm here for it, but I don't think it makes this pitch more or less compelling. I think the purpose of this feature is adjacent and useful on its own, with public default init as a clearly stated future direction.

I mentioned it in previous comments, but even with public default init, you would still want the ability to define it in an extension, which effectively is this pitch today. That is:

  • You wouldn't always want the macro to automatically apply it. You probably want to give the user control over access control
  • Making access control a configuration option of every macro that needs it is overly specific, while a language generalization avoids the churn in both macro creator and macro user code

So a general language feature would avoid these issues:

@MyMacro(generateMemberwiseInit: true)
struct MyType { /* ... */ }

// vs.

@MyMacro
struct MyType { /* ... */ }

extension MyType.Nested {
  public default init
}

I think you agree with this and are onboard with this pitch, just not necessarily without some kind of parallel public default init feature?

I don't think this is quite true. Today, a macro can generate this type, which is documented by the library, and a user can quite easily create instances of the type with the synthesized memberwise initializer. No need for the user to expand the macro-generated code (though they are free to): it is discoverable via completion. And when they want to extend this initializer outside the module, they are able to define it themselves (if this proposal were to land). I definitely agree that a general public default init would make this much easier and less error prone, but I don't think that should limit folks from achieving this functionality today.

This is unfortunately true of any macro code, no? And really true of any 3rd party library code?

Can you explain more on these "correctness" issues? Are they solely related to potential source-breaking changes in libraries that ship macros (which seems like a general/existing library maintenance issue), or is there wider surface area to consider?

My point is that this pitch is explicitly setting up a situation where a single concern, how to initialize otherwise-encapsulated members of a macro-generated type, is sometimes handled by implicit init and sometimes leaked to the end user, such that it becomes the end user's responsibility to ensure correctness—but only sometimes.

This is not generally true of just any code or even any macro, and it is not true of manually written code where both halves of the responsibility lie with the end user. And it would not be true in a language with a public default init feature. (Nor, mind you, would it be conditionally true with my private init(__suppress_default_init: Never) workaround where at least it becomes unconditionally the end user's responsibility—and therefore also unambiguously obvious to the macro author that if they change the generated type's members they are breaking all users, not just those who choose to write an optional extension.)

Based on this analysis, I could get on board with this pitch if we have a public default init-like feature or if we (somehow) excluded macros—for instance, if we considered macro-generated code to be "not in the same file" for these purposes. For indeed, my concern with the macro use case is essentially of-a-kind with the salient reasons that occasion same-file versus different-file distinctions in the first place: without public default init or something like it, the relevant code may be compiled in the same file but, to the end user, actually lives in separate modules! (Again, yes, true in general for any macro-generated code, but most macro-generated code isn't meant to engage with same-file rules, and of those that do most are not in turn meant explicitly to be completed with handwritten code in that same file.)

I might be missing something obvious, but isn't this the status quo for non macro-generated types? There already is the situation where a struct's init is sometimes handled by implicit init, and sometimes leaked to the end user (when they need to make it public or package). And even in this situation it is the end user's responsibility to ensure correctness. I really don't see the pitch changing anything here, just making it possible for the user to do the same thing with macro-generated types that they can do today with non-macro-generated types.

Alright, so let me try to understand what I think are the issues you're bringing up:

  • Init is sometimes handled implicitly, and sometimes becomes the end user's responsibility—I don't see how this pitch changes things at all here. Can you help me understand?
  • When init becomes the end user's responsibility, there are potential correctness issues in the manual implementation, and a public default init feature would help here—again, this feels like an existing issue, unrelated to the pitch.
  • A macro author could change how the macro generates and break existing code. Also true today and so I'm not sure what changes with this pitch. A macro today can already generate these types, and already surface these synthesized initializers, which are already callable by macro users today. This means with or without this pitch, a macro author can change the members of generated type and break user code. I think this is a general problem with library evolution that API authors must contend with, no?