Inability to express the union without going through any Element
is a fair concern, hence why I asked what the ultimate case is, trying to evaluate why that flexibility is needed. But if you don't need to create a collection of elements of different concrete types of Element
, you can leverage generics, use some Element
everywhere, and have a more performant and ergonomic version.
And if you do need to mix elements of different concrete types and go throuhg any Element
, well, I'm not sure if the existential type would fare much worse than enum
+ switch
. That'd be an interesting thing to benchmark (not sure if it's already been done).
No, no, but my point is that by using an enum:
let circle: Element = .circle(id: UUID(), x: 1, y: 2, radius: 3)
// ⚠️ You need to do this now to access radius after circle has been
// created, compiler doesn't know that circle has a radius as its type
// is just "Element"
let radius = if case .circle(_, _, _, let radius) = circle {
radius
} else {
fatalError("Expected a circle!")
}
You always need to check the "kind" information (Circle/Line/...) at runtime. The whole point about using the protocol is that such information becomes part of the type system an thus trivial to check at compile time. So this works:
let circle = Circle(x: 1, y: 2, radius: 3)
// Compiler already knows that circle has a radius property, because
// its type is "Circle"
let radius = circle.radius
Precisely because the compiler is aware that circle is not any Element
, but rather a concrete type, Circle
. This information is obscured (in the sense that you'll need to check for it at runtime) if you use an enum
, and there's no easy way to tell the compiler "this function should only accept values with this specific characteristics" as you can do with protocols.
Circling (hehe) back to the specific example of the getRadius
function, what you'd do with protocols is change the signature to only accept Circle
(and not any kind of element):
// Compiler enforces that this never receives a Line
func getRadius(of circle: Circle) -> Double? {
return circle.radius
}
And the compiler guarantees that getRadius
is only called when the type is Circle
. Obviously the example above is trivial (you'd just access the property directly), but if you had other types with radius (for example, Sphere
), you could create:
protocol Circular {
var radius: Double { get }
}
func getRadius(of circular: some Circular) -> Double? {
return circular.radius
}
And the compiler would guarantee for you that only types with radius
are fed to the function. This is the kind of expressivity that you'd be missing when using enum
. You might not need it (it will depend on the specific use case) but having a case with more than a few associated values hints that you do.
The one case where you could run into the invalid value again is if you needed to use any Element
as the argument:
// ❌ Ideally, don't do this
func getRadius(of element: any Element) -> Double? {
if let circle = element as? Circle {
return circle.radius
} else {
fatalError("Expected circle")
}
}
But you are unlikely to need to do that, logically. The reason you can run into such cases with enum
is because the compiler can't reason about the "kind" of object (Circle/Line...) at compile time.
So even if you have, for example, an array of Element
s where all the items are of the enum's case .circle
, the compiler doesn't know that those all have a radius (as the type it sees is just [Element]
). With the struct
+protocol version you can create a [Circle]
array. So it protects you from running into the runtime issue of encountering objects of unexpected "kind".
And if your elements are all of (logically) different types and need to use any Element
, that means that you don't expect all elements to be circles, and thus you don't expect all elements to have a radius
either.