Swift: Is there still a use case for type erasure since the introduction of primary associated types?
Asked Answered
C

3

6

Swift 5.7 introduced primary associated types. While experimenting with this feature, I was wondering if there is still a use case for type erasure types like AnySequence or if primary associated types make those fully obsolete?

For example, if we have the following code:

protocol Car<Fuel> {
    associatedtype Fuel
    func drive(fuel: Fuel)
}

struct Electricity {}
struct Petrol {}

struct SportsCar: Car {
    func drive(fuel: Petrol) { print("🏎️") }
}

struct FamilyCar: Car {
    func drive(fuel: Electricity) { print("πŸš—") }
}

struct WorkCar: Car {
    func drive(fuel: Electricity) { print("πŸš™") }
}

We can now make an array with only electric cars:

let electricCars: [any Car<Electricity>] = [FamilyCar(), WorkCar()]

Previously I would have written something like this:

struct AnyCar<Fuel>: Car {
   //Implementation
}

let electricCars: = [AnyCar(FamilyCar()), AnyCar(WorkCar())]

Are there still cases where a custom struct "AnyCar" would make sense?

Thank you!

Cenis answered 11/6, 2023 at 8:38 Comment(0)
D
7

Although primary associated types do help smooth out many of the edges of using certain existential types, there are still use-cases for manual type erasure using concrete Any… types.

Existential types dynamically dispatch methods which their interface declares down to the underlying value, but critically, they cannot themselves:

  1. Conform to protocols
  2. Implement methods
  3. Satisfy static type requirements

A very common example of this is Equatable conformance. We can update the Car protocol to adopt Equatable conformance, to indicate that Cars should be able to be equated:

protocol Car<Fuel>: Equatable {
    associatedtype Fuel
    func drive(fuel: Fuel)
}

struct SportsCar: Car { … }
struct FamilyCar: Car { … }
struct WorkCar: Car { … }

However, although you can check for whether two Car values are equal if you know their static types, you cannot check two any Car values for equality:

WorkCar() == WorkCar() // βœ… true

let electricCars: [any Car<Electricity>] = [WorkCar(), WorkCar()]
electricCars[0] == electricCars[1]
// πŸ›‘ Type 'any Car<Electricity>' cannot conform to 'Equatable'
//    Only concrete types such as structs, enums, and classes can conform to protocols
//    Required by referencing operator function '==' on 'Equatable' where 'Self' = 'any Car<Electricity>'

Equatable has a Self requirement which any Car cannot satisfy; however, you could do this if you wrote your own AnyCar type:

struct AnyCar<Fuel>: Car {
    private let inner: any Car<Fuel>
    private let isEqual: (AnyCar<Fuel>) -> Bool

    // The key to the `Equatable` conformance is capturing the _static_ type
    // of the value we're wrapping.
    init<C: Car<Fuel>>(_ car: C) {
        inner = car
        isEqual = { anyCar in
            guard let otherCar = anyCar.inner as? C else {
                return false
            }

            return car == otherCar
        }
    }

    func drive(fuel: Fuel) {
        inner.drive(fuel: fuel)
    }

    static func ==(_ lhs: Self, _ rhs: Self) -> Bool {
        lhs.isEqual(rhs)
    }
}

With this wrapper, you can then check two arbitrary AnyCar values for equality:

let electricCars: [AnyCar<Electricity>] = [AnyCar(FamilyCar()), AnyCar(WorkCar()), AnyCar(WorkCar())]
electricCars[0] == electricCars[1] // βœ… false
electricCars[1] == electricCars[2] // βœ… true

This approach may look familiar to you in the usage of AnyHashable as a generalized key type for dictionaries which can contain any types of keys. You could not implement the same with any Hashable:

let d: [any Hashable: Any] = ["hi" : "there"] // πŸ›‘ Type 'any Hashable' cannot conform to 'Hashable'

As opposed to AnyCar, AnyHashable has the benefit of being so prevalent and necessary that the compiler automatically wraps up types in AnyHashable so you don't need to do it yourself, making it largely invisible.

Doubleton answered 11/6, 2023 at 14:32 Comment(0)
T
5

Existential (any) types do not conform to their protocol, so any Car<Electric> is not itself a Car.

Consider a Garage that can only hold a specific type of Car:

struct Garage<GarageCar: Car> {
    init(car: GarageCar) {}
}

It's legal to make a Garage<FamilyCar>:

let familyCarGarage = Garage(car: FamilyCar())

But it's not legal to make a Garage<any Car>:

let anyCar: any Car = FamilyCar()
let anyCarGarage = Garage(car: anyCar) // Type 'any Car' cannot conform to 'Car'

In this case, however, you can build an AnyCar to deal with it. Garage<AnyCar> is valid even though Garage<any Car> is not.

struct AnyCar<Fuel>: Car {
    func drive(fuel: Fuel) {}
    init(_ car: some Car<Fuel>) {}
}

let explicitAnyCar = AnyCar(FamilyCar())
let anyCarGarage = Garage(car: explicitAnyCar)

This situation shouldn't come up very often, and you should generally be avoiding any when you can (preferring some where possible). And any types are now much more powerful then they were in earlier versions of Swift, and you should prefer them to explicit type-erasure. But, there are still corner cases where they fail.

It is possible in future versions of Swift that any types will be able to conform to certain protocols (particularly ones without static or init requirements, which is the main sticking point). But we're not there yet. For more about future directions, see:

(And the various things linked from within them. There's a lot of stuff in there.)

Teratogenic answered 11/6, 2023 at 15:6 Comment(3)
Thanks for the clear example. After many hours of reading I am still struggling to understand the inherent reason for why existential types do not conform to the protocol with the same name or even why such a concept exists in the first place. The anyCar variable is of existential type but surely the concrete type it points to is FamilyCar - what is stopping the type inference determine that the Garage instance is in fact initialized with FamilyCar ? – Patino
Type inference happens at compile-time, not runtime. The compiler cannot look into the existential at compile-time and make use of what the concrete type will be in the future when the program runs (it may be many things, and many different things during the run of the program). For example, when calling init(), the compiler must know the size of the thing to be created in order to allocate memory. In languages like ObjC and Java, every (class) type is exactly one word long (it's a pointer). So that's doable. But in Swift, structs can be arbitrary sizes (which is better for performance). – Teratogenic
As several of the links above discuss, these aren't completely "inherent" restrictions. There are ways that a future Swift may be able to deal with them. It could have a "default" type to use. Or it could even perhaps synthesize a trivial version of the protocol. Or, as you suggest, there may be some way to make use of the runtime type (though I expect the performance impact will make that impractical). There are lots of things that might be done some day. But they are complex to implement without breaking Swift's optimizations, and Swift does not handle them today. – Teratogenic
O
-1

The thing is Type Erasure is a concept, which means that we shouldn't pass the exact implementation details of object to the user.

This concept was used by creating a AnyCar custom type.

But with introduction of any keyword, we are still making use of same concept only the way is different.

We can still use custom struct type if the code gets "too complicated" with the use of any <Protocol>

Outlandish answered 11/6, 2023 at 11:53 Comment(0)

© 2022 - 2024 β€” McMap. All rights reserved.