Search code examples
iosswiftswiftuistateprotocols

Array of protocols using type erasure cannot conform to Hashable or Equatable protocols


I'm running into an issue wherein the pattern I am attempting to implement does not play nicely with SwiftUI statefulness and refuses to update structs that conform to my TestProtocol when they are in an array.

I am chalking this up to my fundamental misunderstanding so would appreciate any guidance on the matter.

Here is a sanitized code snippet

protocol TestProtocol: Identifiable, Hashable, Equatable  {
    var id: any Identifiable { get }
}

struct TestStruct: Identifiable, Hashable, Equatable {
    let id: UUID = UUID()

    let testArray: [any TestProtocol]

    public func hash(into hasher: inout Hasher) {
        hasher.combine(id)
        hasher.combine(testArray) /// ERROR: Type 'any TestProtocol' cannot conform to 'Hashable'
    }

    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.id == rhs.id && lhs.testArray == rhs.testArray /// ERROR: Type 'any TestProtocol' cannot conform to 'Equatable'
    }
}

class TestObservable: ObservableObject {
    static let shared = TestObservable()

    @Published var filters: [TestStruct]

    init() {
        self.filters = [
            TestStruct(
                testArray: [
                    ...
                ]
            )
        ]
    }
}

EDIT: Thank you for the responses so far, I'm starting to understand that the structure I am aiming for may be breaking some rules. If so, here is the goal I was driving for in the hopes that providing additional clarity might help in suggesting a possible solution.

protocol TestProtocol: Identifiable, Hashable, Equatable  {
    var id: UUID { get }
}

protocol ATestProtocol: TestProtocol  {
    var aValue: String { get set }
}

protocol BTestProtocol: TestProtocol  {
    var bValue: Bool { get set }
}

struct ATestStruct: ATestProtocol {
    let id = UUID()
    var aValue: String = ""
}

struct BTestStruct: BTestProtocol {
    let id = UUID()
    var bValue: Bool = false
}

struct TestStruct: Identifiable, Hashable, Equatable {
    let id: UUID = UUID()

    let testArray: [any TestProtocol]

    public func hash(into hasher: inout Hasher) {
        hasher.combine(id)
        hasher.combine(testArray)
    }

    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.id == rhs.id && lhs.testArray == rhs.testArray
    }
}

class TestObservable: ObservableObject {
    static let shared = TestObservable()

    @Published var filters: [TestStruct]

    init() {
        self.filters = [
            TestStruct(
                testArray: [
                    ATestStruct(),
                    BTestStruct()
                ]
            )
        ]
    }
}

Solution

  • What you are building looks fine. You only need to manually build your methods to generate hash and to compare values. The default implementations simply will not do. You need to iterate through each of your items within the array.

    It should be simple for generating a hash:

    public func hash(into hasher: inout Hasher) {
        hasher.combine(id)
        testArray.forEach { item in
            hasher.combine(item)
        }
    }
    

    Now the comparator becomes a bit more complex. We first need to check if array sizes are the same. We are pretty sure if one item has fewer elements than the other then they are not the same. Then we need to compare each element with one another. To do so we need to attempt to convert one object into type of the other and then compare them. And we need to do it both ways because if A is convertible to B it does not mean that B is convertible to A due to subclassing.

    I managed to build the following. Not the best chunk of code but it should be best to explain what needs to be done:

    static func == (lhs: Self, rhs: Self) -> Bool {
        guard lhs.id == rhs.id else { return false }
        
        let leftArray = lhs.testArray
        let rightArray = rhs.testArray
        
        guard leftArray.count == rightArray.count else { return false }
        
        for index in 0..<leftArray.count {
            let leftItem = leftArray[index]
            let rightItem = rightArray[index]
            
            func checkIsOriginalEqualToTarget<OriginalType: Equatable, TargetType: Equatable>(original: OriginalType, to target: TargetType) -> Bool {
                guard let converted = original as? TargetType else { return false }
                return converted == target
            }
            
            if checkIsOriginalEqualToTarget(original: leftItem, to: rightItem) {
                continue // Looks good
            } else if checkIsOriginalEqualToTarget(original: rightItem, to: leftItem) {
                continue // Looks good
            } else {
                return false
            }
        }
        return true
    }
    

    Answering old part of the question and explaining why it can not work as easily

    To completely strip down your problem imagine the following method:

    func areTheseItemsTheSame(_ items: [any Equatable]) -> Bool {
        guard items.isEmpty == false else { return true }
        let anyItem = items[0]
        return !items.contains(where: { $0 != anyItem })
    }
    

    It looks useful. I could easily check things like areTheseItemsTheSame(["1", "2", "1", "1", "1"]) or areTheseItemsTheSame([true, true, false]) and so on.

    But what if I would use it like areTheseItemsTheSame(["1", true])? Both values are equatable but they can not be equated amongst themselves.

    So we need to restrict this method with generic:

    func areTheseItemsTheSame<ItemType: Equatable>(_ items: [ItemType]) -> Bool {
        guard items.isEmpty == false else { return true }
        let anyItem = items[0]
        return !items.contains(where: { $0 != anyItem })
    }
    

    This now compiles and makes sense. Calling it with different types will produce an error that it has conflicting types. There is even a shorthand for this using some keyword. So you can do

    func areTheseItemsTheSame(_ items: [some Equatable]) -> Bool {
        guard items.isEmpty == false else { return true }
        let anyItem = items[0]
        return !items.contains(where: { $0 != anyItem })
    }
    

    To apply the same solution a bite more information would be nice on what your result should look like but something like the following should work:

    protocol TestProtocol: Identifiable, Hashable, Equatable  {
        
    }
    
    struct TestStruct<ItemType: TestProtocol>: Identifiable, Hashable, Equatable {
        let id: UUID = UUID()
    
        let testArray: [ItemType]
    
        public func hash(into hasher: inout Hasher) {
            hasher.combine(id)
            hasher.combine(testArray)
        }
    
        static func == (lhs: Self, rhs: Self) -> Bool {
            lhs.id == rhs.id && lhs.testArray == rhs.testArray
        }
    }
    

    And I can now use pretty much anything to insert into this structure. For instance

    extension String: TestProtocol {
        
        public var id: String { self }
        
    }
    
    class TestObservable: ObservableObject {
        static let shared = TestObservable()
    
        @Published var filters: [TestStruct<String>]
    
        init() {
            self.filters = [
                TestStruct(testArray: ["String", "Test"])
            ]
        }
    }
    

    I hope this sets you on the right path.