18

I'm going around in circles trying to get Hashable to work with multiple struct that conform to the same protocol.

I have a protocol SomeLocation declared like this:

protocol SomeLocation {
    var name:String { get }
    var coordinates:Coordinate { get }
}

Then I create multiple objects that contain similar data like this:

struct ShopLocation: SomeLocation, Decodable {
    var name: String
    var coordinates: Coordinate

    init(from decoder: Decoder) throws {
        ...
    }
}

struct CarLocation: SomeLocation, Decodable {
    var name: String
    var coordinates: Coordinate

    init(from decoder: Decoder) throws {
        ...
    }
}

I can later use these in the same array by declaring:

let locations: [SomeLocation]

The problem is, I create an MKAnnotation subclass and need to use a custom Hashable on the SomeLocation objects.

final class LocationAnnotation:NSObject, MKAnnotation {
    let location:SomeLocation
    init(location:SomeLocation) {
        self.location = location
        super.init()
    }
}

override var hash: Int {
    return location.hashValue
}

override func isEqual(_ object: Any?) -> Bool {
    if let annot = object as? LocationAnnotation
    {
        let isEqual = (annot.location == location)
        return isEqual
    }
    return false
}

This gives me 2 errors:

Value of type 'SomeLocation' has no member 'hashValue' Binary operator

'==' cannot be applied to two 'SomeLocation' operands

So I add the Hashable protocol to my SomeLocation protocol:

protocol SomeLocation: Hashable {
    ...
}

This removes the first error of hashValue not being available, but now I get an error where I declared let location:SomeLocation saying

Protocol 'SomeLocation' can only be used as a generic constraint because it has Self or associated type requirements

So it doesn't look like I can add Hashable to the protocol.

I can add Hashable directly to each struct that implements the SomeLocation protocol, however that means I need to use code like this and keep updating it every time I might make another object that conforms to the SomeLocation protocol.

override var hash: Int {
    if let location = location as? ShopLocation
    {
        return location.hashValue
    }
    return self.hashValue
}

I have tried another way, by making a SomeLocationRepresentable struct:

struct SomeLocationRepresentable {
    private let wrapped: SomeLocation
    init<T:SomeLocation>(with:T) {
        wrapped = with
    }
}
extension SomeLocationRepresentable: SomeLocation, Hashable {
    var name: String {
        wrapped.name
    }
    
    var coordinates: Coordinate {
        wrapped.coordinates
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(name)
        hasher.combine(coordinates)
    }

    static func == (lhs: Self, rhs: Self) -> Bool {
        return lhs.name == rhs.name && lhs.coordinates == rhs.coordinates
    }
}

however when I try to use this in the LocationAnnotation class like

let location: SomeLocationRepresentable
init(location:SomeLocation) {
    self.location = SomeLocationRepresentable(with: location)
    super.init()
}

I get an error

Value of protocol type 'SomeLocation' cannot conform to 'SomeLocation'; only struct/enum/class types can conform to protocols

Is it possible to achieve what I am trying to do? Use objects that all conform to a protocol and use a custom Hashable to compare one to the other?

Darren
  • 10,182
  • 20
  • 95
  • 162
  • 1
    Your problem stems from trying to use a protocol as an existential (i.e. in place of an object) in some places (e.g. when creating an array `[SomeLocation]`), while also using trying to conform to Hashable, which has `Self` constraints, which prevents it from being used as an existential in Swift. Type-erasure (like you did with `SomeLocationRepresentable` - convention in Swift is to name it `AnyLocation`) is the way to go, so you'd have `var locations: [AnyLocation] = [AnyLocation(CarLocation())]` – New Dev Oct 21 '20 at 18:01
  • Not related to your question but how are you going to check if they are in same location? They will probably be really close but not equal. Btw they might share the same Latitude/Longitude and be different places (i.e different levels) – Leo Dabus Oct 21 '20 at 18:15
  • Thanks @NewDev any idea why may attempt at that failed? – Darren Oct 21 '20 at 18:30
  • @LeoDabus I’ve simplified my code for the question. However in this case, it’s used to compare annotations on a map. So even just comparing the name will do as they’re all unique. – Darren Oct 21 '20 at 18:31
  • 2
    It failed because you tried to init `SomeLocationRepresentable` with a type `SomeLocation`, whereas it expected a type that ***conforms*** to `SomeLocation`. Protocols don't conform to protocols, including themselves. You basically cannot have something that is a `SomeLocation` when you have `Self` constraints (which `Hashable` does). – New Dev Oct 21 '20 at 18:33
  • `SomeLocation` is created from a JSON decoder, how do I put this in a `SomeLocationRepresentable` struct if I can't use init? Or is it just that my init is wrong? `init(with:T) { }` – Darren Oct 21 '20 at 20:48
  • Ok, I think I have it. `locations.compactMap({ $0 as? SomeLocationRepresentable }).map({ SomeLocationRepresentable.init(location: $0 )})` I have to map them collection to `SomeLocationRepresentable` first. – Darren Oct 21 '20 at 21:59
  • Actually no, that doesn't work. It can't cast value of type `SomeLocation` to `SomeLocationRepresentable` :/ – Darren Oct 21 '20 at 22:01
  • Got it: `locations.map({ SomeLocationRepresentable(location: $0) }).map({ LocationAnnotation.init(location: $0 )})` – Darren Oct 21 '20 at 22:11

1 Answers1

8

Deriving the protocol from Hashable and using a type eraser might help here:

protocol SomeLocation: Hashable {
    var name: String { get }
    var coordinates: Coordinate { get }
}

struct AnyLocation: SomeLocation {
    let name: String
    let coordinates: Coordinate
    
    init<L: SomeLocation>(_ location: L) {
        name = location.name
        coordinates = location.coordinates
    }
}

You then can simply declare the protocol conformance on the structs, and if Coordinate is already Hashable, then you don't need to write any extra hashing code code, since the compiler can automatically synthesize for you (and so will do for new types as long as all their properties are Hashable:

struct ShopLocation: SomeLocation, Decodable {
    var name: String
    var coordinates: Coordinate
}

struct CarLocation: SomeLocation, Decodable {
    var name: String
    var coordinates: Coordinate
}

If Coordinate is also Codable, then you also can omit writing any code for the encoding/decoding operations, the compile will synthesize the required methods (provided all other properties are already Codable).

You can then use the eraser within the annotation class by forwardingn the initializer constraints:

final class LocationAnnotation: NSObject, MKAnnotation {   
    let location: AnyLocation
    
    init<L: SomeLocation>(location: L) {
        self.location = AnyLocation(location)
        super.init()
    }
    
    override var hash: Int {
        location.hashValue
    }
    
    override func isEqual(_ object: Any?) -> Bool {
        (object as? LocationAnnotation)?.location == location
    }
}
Cristik
  • 30,989
  • 25
  • 91
  • 127