[Pitch] Synthesize init of structs

Hi community, I would like to propose a new feature in Swift.

Synthesize init of structs

Introduction

The feature adds an automatic and controlled way of generating structs' initializers.

Motivation

Very often our applications are divided into different layers which are implemented as modules/packages, in that situation Swift's implicit initializers for structs are useless because of the default access level which forbids initialization of instances from other modules if the initializers are not marked as public. We can use an IDE (most often Xcode) to generate the initializers but the problem is that if we wanted to add another field, we would have to use the IDE to generate the initializer again, then reformat it, copy our implementation, and so on.

Proposed solution

A new keyword known from Objective-C: synthesize. The construction would look like this

public struct SearchParams {
    // some fields...

    public synthesize init   // 👈
}

Detailed design

The keyword would be able to receive a parameter that describes the lowest access level of fields which would be included in the initializer, the default level would be public, for example

public struct SearchParams {
    public let apiToken: String
    public let sortOrder: SortingOrder?
    public let page: Int
    internal var resultsPerPage: Int?
    internal var startDate: Date?
    private var endDate: Date?
    private var queryPhrase: String?

    public synthesize init
}

would be equivalent to

public struct SearchParams {
    public let apiToken: String
    public let sortOrder: SortingOrder?
    public let page: Int
    internal var resultsPerPage: Int?
    internal var startDate: Date?
    private var endDate: Date?
    private var queryPhrase: String?

    public init(apiToken: String,
                sortOrder: SortingOrder?,
                page: Int)
    {
        self.apiToken = apiToken
        self.sortOrder = sortOrder
        self.page = page
    }
}

In order to include all the fields in the initializer there would be an ability to specify the lowest access level, for example

public struct SearchParams {
    public let apiToken: String
    public let sortOrder: SortingOrder?
    public let page: Int
    internal var resultsPerPage: Int?
    internal var startDate: Date?
    private var endDate: Date?
    private var queryPhrase: String?

    public synthesize(private) init
}

that means all the fields with an access level of at least the one specified in synthesize would be in the initializer, so the code above would behave as

public struct SearchParams {
    public let apiToken: String
    public let sortOrder: SortingOrder?
    public let page: Int
    internal var resultsPerPage: Int?
    internal var startDate: Date?
    private var endDate: Date?
    private var queryPhrase: String?

    public init(apiToken: String,
                sortOrder: SortingOrder?,
                page: Int,
                resultsPerPage: Int?,
                startDate: Date?,
                endDate: Date?,
                queryPhrase: String?)
    {
        self.apiToken = apiToken
        self.sortOrder = sortOrder
        self.page = page
        self.resultsPerPage = resultsPerPage
        self.startDate = startDate
        self.endDate = endDate
        self.queryPhrase = queryPhrase
    }
}

The examples above imply that this code should generate a compilation error "redeclaration of init" :x:

public struct SearchParams {
    public let apiToken: String
    public let sortOrder: SortingOrder?
    public let page: Int
    internal var resultsPerPage: Int?
    internal var startDate: Date?
    private var endDate: Date?
    private var queryPhrase: String?

    public synthesize init

    public init(apiToken: String,
                sortOrder: SortingOrder?,
                page: Int)
    {
        self.apiToken = apiToken
        self.sortOrder = sortOrder
        self.page = page
    }
}

Source compatibility

The solution should not influence the existing code, it is rather the facilitation of existing mechanisms.

ABI compatibility

The change does not have an impact on ABI compatibility.
The compiled code should be similar to a manual use of init, as it works currently.

Alternatives considered

Built-in functions in IDEs or scripts, but these solutions require manual work and the need of repeating it every time when the dependent code changes. The tools generate real code, which sometimes can count many lines, but it does nothing more than just expose the initializer to other packages outside. The proposed solution is nicer and would be a part of the language.

5 Likes
5 Likes

This should now be possible (with a slightly different syntax) with the new macro system. (see Harlan Haskins: "having some fun making macros" - Mastodon)

I would personally love something like this in the standard library. I would say that I would prefer it to be a macro than a bespoke language feature, though.

12 Likes

I agree that I would prefer a macro or going back to the memberwise proposal.

John_McCall brought up his desire to see automatic memberwise initializers for structs in the @OptionSet pitch. I'd be curious what his ideas are. Maybe the same as "Flexible Memberwise Initialization", but that proposal is really old.

Memberwise Structs

Memberwise structs allow for more then just reducing boilerplate. It could work well with pattern matching and destructuring since it allows for a guaranteed ordering of members.

I think memberwise pattern matching and memberwise destructuring would be great motivation for NOT making memberwise initializers a macro (although it could be an attribute with some limitations).

I think it is helpful to think of memberwise structs as half-tuple and half-struct.

struct Point {
  var x: Double = 0.0
  var y: Double = 0.0
  var z: Double = 0.0

  memberwise init(...) {}
}

// supports optional args
let point = Point(x: 1.0, y: 2.0) 

// same syntax for pattern matching memberwise struct
switch point {
case Point(x: 0.0, y: 0.0, z: 0.0): print("origin")
case Point(x: 1.0): print("x is 1")
case Point(y: 0.0): print("y is 0")
default: print("what's the point")
}

// destructuring
let Point(x: x, y: y, z: z) = point
// allow dropping labels when all arguments are present
let Point(x, y, z) = point

Edit: several iterations

2 Likes

This is supported today when point is equatable.

IMHO this one looks very strange... besides you are repeating "point" twice. For destructuring I'd prefer a less cryptic form, e.g. one of these:

let (x, y, z) = point
let (x, y, z) = point.members // looks best
let (x, y, z) = .init(point)

Only for the very narrow case of testing to see if the struct is exactly the same as another one. Swift doesn’t currently support pattern matching and destructuring struct members like Rust does for example. In the early days of Swift they were planning to revisit pattern matching and destructuring to add more types after the language stabilized, it just hasn’t happened yet. Dictionaries and arrays likely will be supported some day too.

This is just two examples. The second one is a tuple-like destructuring for these very small structs representing points or vectors where you always destructure all of the arguments. The first one wasn’t the greatest real world example, but shows the standard form of destructuring. The idea is to destructure structs almost the same as tuples. Therefore you can drop labels just like tuples when it isn’t ambiguous.

This is close to the same syntax Rust uses for destructuring structs and similar to how Swift destructures tuples. Sure, in simple cases you could convert a struct to a tuple and then destructure it as you show (with a lot of boilerplate). That doesn’t work in most scenarios. For instance it doesn’t work if you have more than a few variables since you would need a ton of underscores for omitted fields. It also doesn’t work if you are only interested in a subset of members which is the most common use case.

I see, in that case I'd use this:

switch point.members {
case (0, 0, 0): print("origin")
case (1, _, _): print("x is 1")
case (_, 0, _): print("y is 0")
default: print("what's the point")

As you wrote it:

case Point(x: 1.0): print("x is 1")

looks like you are just using a point value of Point(x: 1, y: 0, z: 0), where the second and third parameter is taken as a default. Perhaps your version would be more clear if that was Point(x: 1, y: _, z: _). Otherwise it's not clear (even to the compiler) that you mean a partial match rather than a comparison with exact value (this compiles and works now if point is Equatable, so you'd first need to somehow opt-out of that current behaviour).

Make a more real example? Otherwise it's not clear how what you are suggesting is better than, simply:

let v = point
let (a, b) = (v.x, v.z) // no y

I don’t really want to get in to the weeds on pattern matching. This is basically the same as Rust and what Swift eventually wanted to get back to in the original language design. I’d suggest reading about structs in the Rust language guide to understand what I’m talking about.

I’ll leave you with one better example because I think you are getting confused by tuple-like structs.

public struct SearchParams {
    public let apiToken: String
    public let sortOrder: SortingOrder?
    public let page: Int
    internal var resultsPerPage: Int?
    internal var startDate: Date?
    private var endDate: Date?
    private var queryPhrase: String?

    public memberwise init(…) {}
}

// note that we can just destructure what we need
let SearchParams(
  apiToken: token, 
  queryPhrase: query
) = request.query

print(“Performed query \(query!) with token \(token)”)

Briefly, Rust took a little different approach to structs. They have memberwise initializers automatically in Rust. Because of that it was easy to add destructuring and pattern matching. This works because the order of the members matters in Rust (or with memberwise structs in Swift). Rust has both a standard struct and a tuple struct. For Swift it makes more sense to combine that in to one concept for destructuring purposes since Swift tuples have optional labels unlike Rust (so it isn’t weird to remove the labels in tuple-like destructuring of structs). Rust doesn’t have Swift-style initializers, so they can get away with automatic memberwise initializers that work better for pattern matching and destructuring. Memberwise structs in Swift would be like a step toward what Rust does and allow these things too. It makes a lot of sense to do that for model structs that don’t carry business logic. Basically the structs you use more declaratively.

Rust is an excellent case study for Swift. The two languages are very similar in how types, pattern matching, and destructuring work. Overall, I like Swift’s approach to structs better. It is just missing a few bits that they never got around to. I’m also not proposing anything novel here. Just trying to show how memberwise initializers allow the original language pattern matching goals to be implemented. The plan was to allow pattern matching and destructuring on dictionaries, arrays, and structs.

Which in Swift (until we have anything better) I'd write as:

let v = request.query
let (apiToken, queryPhrase) = (v.token, v.query)

A separate assignment, but not too bad, I'd say.

Swift also has memberwise initialisers, albeit in a limited form (e.g. you have to have other initialisers out of the main type body otherwise their presence would suppress generation of memberwise init).

Would you support the idea of this syntax to make struct pattern matching working similar to that of tuples?

switch point {
case Point(x: 0): print("origin") // x:0, y:0, z:0 is used here
case Point(x: 1, y: _, z: _): print("x is 1")
case Point(x: _, z: _): print("y is 0") // y:0 is used here (default param)
case Point(x: _, y: _, z: _): print("some other point")
// no need for default, as all possibilities are covered
```

That could be a start.
1 Like

I agree, but if I had to choose I would definitely pick the memberwise option, not macros.

1 Like

Re the pitch itself: +1 from me. This was suggested many times in various forms and shapes. Your's truly suggested something similar, albeit with more bells and whistles some time ago.

Yeah, I think it makes sense to destructure or pattern match on tuple-like structs this way since that is a common case. I didn’t want to get too in the weeds on this aspect though because there are a lot of options on the specifics of how it might work.

This would certainly be a staged process if it were picked up again in evolution. I’m sure it would start by just allowing memberwise initializers and not any of this other stuff. I really just wanted to make the point that macros are probably not sufficient because ideally memberwise structs may adopt other language features that couldn’t be expressed in a macro.

Basically memberwise strictly orders a structs members and allows for tuple-like pattern matching like what you show here. Technically Rust-like struct pattern matching could be done without memberwise, but I think it is more elegant since it would have symmetry with initialization.

Another reason macros wouldn’t work is you may want to customize initialization if you were hiding some struct members behind internal or private.

On existing memberwise structs, yeah they exist but are too limited and don’t work with public interfaces.

I’m basically +1 on this pitch, but I think the naming should be memberwise and I think it should support a body to the initializer so you can initialize private or internal members.

struct Foo {
  memberwise init(...) {}
}
2 Likes

I love the idea. The weird thing for me is that the memberwise aspect is more about the implementation of the method than its interface (aka, knowing that the method does this isn't really useful to callers, unlike mutating or nonisolated, etc; it also wouldn't affect ABI if memberwise was added or removed).

Could it be placed in the method itself rather than a decorator on the interface? Maybe something like:

public init(...) { 
    #memberwiseInit        // maybe it's a "macro"-y thing?
}

I don't really love how it looks, so maybe the proposal is fine as-is. Either way, thanks for the proposal!

2 Likes