Search code examples
swiftgenericsprotocolstype-systems

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


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!


Solution

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