Synthesizing methods, but for classes?

First, sorry if I should have posted this in an existing thread instead of making a new one. It seems that the topics related to this are either at least several years old or not completely related, so I couldn't find one particularly good place to ask this.

I'm looking for information for why we don't synthesize certain methods and protocol conformances for classes like we largely do for structs today. In the original proposal for Equatable/Hashable generation in structs, the following was to used to justify that the same couldn't be done for classes:

  • Subclassing makes it complicated
  • Even if it's final, superclasses would make the conditions unclear

This makes sense. But if we're dealing with a final class that has no superclass and no Obj-C bridging, is there any reason why we can't synthesize those conformances the exact same way we do for structs today? The proposal does make a point that in reference types memberwise equality may not necessarily imply that two instances are equal, but that is something that can also happen in structs and is fixed today by having the dev opt-out of the automatic conformance for the particular type where that would be an issue.

Are there other reasons that would make this not possible?

3 Likes

I still think this would be a great feature to add to Swift, so I'm bumping it as I didn't get a response back then.

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

Typically reference types are compared with ===. NSObject uses a similar approach. Do you have a good example when you need EQ/Hashable and you can't use structs?

This sounds like yet another good use case for indirect structs.

3 Likes

Yeah, if we didn’t already have indirect for enums I’d be more in favor of “final class deserves enhancements”, but since we do, it seems like that’s a smaller change, and one that’s more flexible to boot (it maintains value semantics for mutation, at the cost of a copy).

7 Likes

The issue for me is not that they "can't" be structs, but rather that structs are sometimes simply not the right solution to the problem, yet developers are drawn to them simply because they don't want to write these conformances by hand, leading to apps that are 1) designed incorrectly, and 2) wildly inefficient as Karl demonstrated above

1 Like

Combine the two?

class C: ClassWrapperOverStruct {
    struct S: Hashable {
        var x = 0
        var y = "hello"
    }
    var value = S()
}
where the boilerplate is written once and hidden in some library
protocol ClassWrapperOverStruct: AnyObject, Hashable {
    associatedtype Value: Hashable
    var value: Value { get set }
    static func == (lhs: Self, rhs: Self) -> Bool
    func hash(into hasher: inout Hasher)
}

extension ClassWrapperOverStruct {
    public static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.value == rhs.value
    }
    public func hash(into hasher: inout Hasher) {
        hasher.combine(value)
    }
}

The (hopefully minor) inconvenience: you'd have to write C.value.x instead of C.x.


Do you have a good example handy where you need classes with EQ/Hashable and classes are the right approach because you need their specific traits (deinit, or class hierarchy, or reference semantics, etc)?

Here's one example, in our project we have a lot of these:

final class ClassThatDefinitelyNeedsToBeClass {
    struct Dependencies {
        let a: A
        let b: B
        let c: C
        let d: D
        let e: E
    }

    let dependencies: Dependencies

    init(dependencies: Dependencies) {
        self.dependencies = dependencies
    }
}

There's no design reason for the dependencies to be tucked away into another type like this, they do it simply because they don't want to write/maintain the inits by hand. So they circumvent this by putting everything into structs, causing us issues with app size and performance. In this case, since this is a final class with no superclass or bridging, it seems that we could generate the initializer here too.

1 Like

Memberwise inits is a whole different story (they are not normal methods). I'd prefer them being more flexible and explicit, e.g. this (which could be applied to classes as well).

Interesting, thanks for linking that! I was not aware of this old proposal.