Heterogeneous Collection using protocol

Overview:

  • I have a Shape protocol
  • I have Circle and Square that conform to Shape
  • I would like to store different Shapes in an array
  • I would like a way to compare the elements of the array for equality

Problem:

  • I could make them conform to Equatable but then I wouldn't be able to store the Heterogeneous collection.
  • In the real project, I am using CoreData, so couldn't use enum with associated values for the Shape and use it for comparison.

Questions:

  • I have made an attempt, just wondering if there was a better solution?
  • Is there a way to use == or any other operator? (based on my attempt, seemed like all of them required to be aware of Self)
  • Is my attempt reasonable?

My Attempt

protocol Shape {
    var name : String { get }
    var size : Int { get }
}

extension Shape {
    func isEqual(to rhs: Shape) -> Bool {
        name == rhs.name && size == rhs.size
    }
    
    static func == (lhs: Shape, rhs: Shape) -> Bool {
        lhs.name == rhs.name && lhs.size == rhs.size
    }
}

struct Circle : Shape {
    let name = "Circle"
    var size : Int
}

struct Square : Shape {
    let name = "Square"
    var size : Int
}

let s1 = Square(size: 3)
let c1 = Circle(size: 3)

var shapes : [Shape] = [s1, c1]

s1.isEqual(to: c1) //works ok

s1 == c1 //compilation error: Generic parameter 'Self' could not be inferred

it works if you make it a free function instead of a static method

func == (lhs: Shape, rhs: Shape) -> Bool {
    lhs.name == rhs.name && lhs.size == rhs.size
}

Are you okay with the fact that two different types, but with the same name and size would be considered equal?
Are you okay with the fact that if you add a third property to a Circle it wouldn't be considered when comparing two circles?
Are you okay with Shape not being equatable itself (for example you cannot compare two arrays of Shapes)

If you're okay with all these disadvantages, then it's reasonable.

Usually the solution is to make a type-erasing wrapper, check out AnyHashable, or AnyView in SwifUI (There are multiple ways to write a type-erasing wrapper, using a closure is the easiest one)

protocol Shape: Equatable {
    var name : String { get }
    var size : Int { get }
}

struct AnyShape: Shape {
    let base: Any // you don't have to provide this property, but it's often handy
    let name: String
    let size: Int

    private let compare: (Any) -> Bool
    init<T: Shape>(_ shape: T) {
        self.base = shape
        self.name = shape.name
        self.size = shape.size
        self.compare = {
            guard let other = $0 as? T else { return false }
            return shape == other
        }
    }

    static func == (lhs: AnyShape, rhs: AnyShape) -> Bool {
        lhs.compare(rhs.base)
    }
}
struct Circle : Shape {
    let name = "Circle"
    var size : Int
}

struct Square : Shape {
    let name = "Square"
    var size : Int
}

let s1 = Square(size: 3)
let c1 = Circle(size: 3)

var shapes = [AnyShape(s1), AnyShape(c1)]
shapes == shapes
AnyShape(s1) == AnyShape(c1)
4 Likes

If you only compare a fixed subset of variables (that every Shape shares), I'd suggest that you make an id property for that comparison.

extension Shape {
  struct ID: Hashable {
    var name: String
    var size: Int
  }

  var id: ID { .init(...) }
}

shape1.id == shape2.id
3 Likes

Thanks a lot @cukr

Pretty cool that you can create a stand alone == function and that would eliminate the problem of needing to be aware of Self

I think you made valid points about the limitations with my approach, I think the biggest limitation would be array comparison, I might need to build a == function for Sequence.

I am hardcoding name and making it let so that part is ok.

1 Like

Thanks a lot @Lantua

I think that is a good idea to compare based ID, makes it a lot simpler to compare without the burden of being aware of the type of Shape.