Synthesizing methods, but for classes?

I don't think there is a reason other than what is described in the proposal, and I'd be +1 to allowing it.

One thing that peeves me about the technical design of a lot of apps, is that they can have enormous model types. For instance, I was recently working on a codebase with model types like this:

struct Product {
  let id: Int
  let name: String
  let price: String
  let imageURLs: [String]
  let description: String
  let sellerDetails: SellerDetails // Another enormous struct
  // ...etc
}

I think it had like 20 or 30 stored properties, including strings, arrays, dictionaries, nested structures with their own strings, arrays, and dictionaries.

The problem is enormous structs like this are not very efficient. Due to the way structs work, as you pass them around your application, you make a lot of implicit copies. For instance, initialiser parameters are implicitly consuming. So let's say you have an [Product] which you received from a server and you want to show them in a list; you'll generally have some kind of ProductRow view and write something like this:

ForEach(products) { product in
  ProductRow(product) // <- Initialiser. Consumes 'product', so implicitly copies.
}

To examine what the cost of copying such a large struct is, we can try the following in Godbolt:

public func doSomething(_ input: Product) {
    someNonInlineableFunction(input) // implicit copy happens here
}

@inline(never) @_optimize(none)
func someNonInlineableFunction(_: consuming Product) {
    // Some function, doesn't matter what it does.
}
output.doSomething(output.Product) -> ():
        push    rbx
        mov     rbx, rdi
        call    (outlined retain of output.Product)
        mov     rdi, rbx
        pop     rbx
        jmp     (output.someNonInlineableFunction(__owned output.Product) -> ())

outlined retain of output.Product:
        push    r15
        push    r14
        push    r12
        push    rbx
        push    rax
        mov     rbx, rdi
        mov     rdi, qword ptr [rdi + 16]
        mov     r14, qword ptr [rbx + 32]
        mov     r15, qword ptr [rbx + 40]
        mov     r12, qword ptr [rbx + 56]
        call    swift_bridgeObjectRetain@PLT
        mov     rdi, r14
        call    swift_bridgeObjectRetain@PLT
        mov     rdi, r15
        call    swift_retain@PLT
        mov     rdi, r12
        call    swift_bridgeObjectRetain@PLT
        mov     rax, rbx
        add     rsp, 8
        pop     rbx
        pop     r12
        pop     r14
        pop     r15
        ret

So what happens is that the copy performs 4 retains - on the name, price, imageURLs and description fields. And I don't think this is a problem of the optimiser not being smart enough; those are almost always going to be necessary retains, because the strings and arrays stored in those properties may share storage with other strings and arrays floating around the program.

And this is just a small example; as I said, I've very big, complex model structs with 20 - 30 fields, most of which have refcounted storage.


So how do we solve this? Well, many of these big model types are actually immutable - the App gets some data from a server, and never directly modifies it. Modifications happen through sending an event to the server, which responds with a new copy of the model data. It is better for responsiveness and power efficiency to send slightly more data in a single request than to perform lots of little requests, which tends to encourage this kind of design where you end up with large model objects.

But anyway, since the data isn't mutable, we should be able to use a final class rather than a struct without introducing any of the familiar problems around shared mutable state, but giving us much faster copies (just a single retain).

Unfortunately that becomes impractical, because as soon as we make that change, we lose the generated member-wise initialiser, and synthesised conformances to Equatable/Hashable/Codable, and writing that stuff out (and maintaining it) for a large data type is extremely annoying and error-prone.

public final class Product: Equatable {
// error: type 'Product' does not conform to protocol 'Equatable'
// note: automatic synthesis of 'Equatable' is not supported for class declarations

What I have done in the past as a workaround is to introduce a Box<T> type, with conditional conformances that forward these protocol conformances, and dynamicMember subscripts to try hide the fact that the box exists. But that also kind of sucks, because now the codebase is full of functions which accept and return a Box<Product> rather than a Product. It's harder to read, and difficult for other developers to understand and remember.

It's fair enough that we don't want to synthesise these things where subclassing is involved, but when it isn't involved, I think we should make those classes as ergonomic to define as structs.

6 Likes