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

359 views Asked by At

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!

3

There are 3 answers

0
Itai Ferber On BEST ANSWER

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.

0
Abhilash On

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>

3
Rob Napier On

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.)