What is the best approach when we have an enum where we want an associated value common across all enum cases?
Obvious solution is to wrap it in a struct but I wonder if there is a better approach.
Example
enum Element {
case circle(x: Double, y: Double, radius: Double)
case line(x1: Double, y1: Double, x2: Double, y2: Double)
}
Now if I want each object to have an id:
struct ElementBox {
let id: String
var element: Element
}
Another approach could be
enum Element {
case circle(id: String, x: Double, y: Double, radius: Double)
case line(id: String, x1: Double, y1: Double, x2: Double, y2: Double)
var id: String {
switch self {
case let .circle(id, _,_,_): return id
case let .line(id, _,_,_,_): return id
}
}
}
I think this is the best approach. In your example, an “id” is not an inherent property of a circle or a line, it is a separate thing, so it makes sense for it to be its own data type that wraps a shape and adds an “id”.
What is the goal of storing the properties of Element in the enum's associated values? That would help a lot in thinking of alternatives. Without other context, I would have written it without using enums at all:
protocol Element: Identifiable {
var id: UUID { get }
}
struct Circle: Element {
let id = UUID()
let x: Double
let y: Double
let radius: Double
}
struct Line: Element {
let id = UUID()
let x1: Double
let y1: Double
let x2: Double
let y2: Double
}
And rely in type information instead of the enum case to know the type of element. Why wouldn't this be a fit for what you're trying to do? What properties of enum do you need to leverage that a struct wouldn't have?
IMHO, the ergonomics of enums with associated values get awkward when you have more than a couple values. In fact (hope this doesn't come off as too opinionated) I think that's a clear sign that a different data structure could do better at modeling the concept at hand.
For example, getting the radius of a Element with your proposed code also feels 'clumsy':
Nicolai's example is very much in line with what I would do. I use enums with associated values a lot, and I think they are one of the best features of Swift.
They let you define use case-specific data structures, very much like a set of different structs, but under one common static type umbrella, that doesn't require downcasting or runtime type checking.
With enums, you can switch over the cases, use pattern matching, or use case let to check specific cases, and the compiler forces you to handle all cases, so you can't inadvertently miss cases.
In general, I think enums are preferable over a protocol-based design in cases like this, because you as a type designer define the problem space, you clearly stake out the boundaries of the design, and often this is all it needs, and easier to reason about than open-ended designs (using protocols).
A getRadius() method that expects the input be a specific case is itself an anti-pattern. Operations should be exhaustive, so computing the area, drawing a shape, etc.
Ah, don't take me wrong, I love enums in Swift! What I'm arguing is that if you end up having to bundle several associated types, both common (like id) and unrelated (like radius) in the same enum... you're better off with a struct or a class.
I believe this is exactly what you'd want to use generics for. You should be able to do pretty much everything that the enum-based Elements does using generics, and you wouldn't need to downcast or do runtime checking at all.
In fact, I'd argue the opposite is true: while with enums you would end up having lots of switch statements scattered throughout the code just to extract the properties of each enum case (which has a runtime cost, just like runtime type checking), if you use generics, you'll be exposing the specific "kind" of object (Circle, Line...) at compile time, instead of at runtime, avoiding any runtime checks for the "kind" of object (like those switches).
The obvious loss here is the ability to iterate over all "kinds" of elements if you don't use an enum. But in my experience, having access to the concrete "kind" of Element removes a lot of the use cases of that switch, and you may not need to iterate over the possible element types that often anymore.
Again, that depends on the use case in particular. There's obviously use cases for enums with associated values (I use them a lot for errors, and I love it!). I just don't think it's wise to use them as a sort-of-replacement of the type system.
That was kind of my point, sorry if I wasn't clear. I was arguing that if you use enums to model the wrong kind of thing, it's very easy to run into antipatterns. Continuing with the previous radius example: let's say I create a .circle enum element:
let circle: Element = .circle(id: UUID(), x: 1, y: 2, radius: 3)
How would you go about accessing its radius after it's been created?
let radius = circle.radius // ❌ This obviously doesn't compile
You're pretty much forced to use an antipattern / unsafe operation:
let radius = if case .circle(_, _, _, let radius) = circle {
radius
} else {
// ⚠️ Can crash at runtime, compile-time guarantees are lost...
fatalError("Expected a circle!")
}
If what you're pointing out is that you shouldn't need to explicitly access radius outside a function that handles all enum cases, like a computeArea function:
func computeArea(of element: Element2) -> Double {
switch element {
case .circle(_, _, _, let radius):
// ⚠️ IMHO having to write those unused _, _, _, is already a
// 'smoking gun' that there should be better ways to do this
return Double.pi * radius * radius
case .line:
return .zero
}
}
I agree in theory. But in practice, I think you'll often run into edge cases where you need to access a specific value of an enum case outside the scope of a function that accesses all cases.
And since you can easily do this with a struct based model by extending the Element protocol anyway...
protocol Element: Identifiable {
var id: UUID { get }
var area: Double { get }
}
struct Circle: Element {
let id = UUID()
let x: Double
let y: Double
let radius: Double
var area: Double {
return Double.pi * radius * radius
}
}
struct Line: Element {
let id = UUID()
let x1: Double
let y1: Double
let x2: Double
let y2: Double
let area: Double = .zero
}
Why not use it instead of enums? It's just as easy, and you have much better expressivity than with an enum with half a dozen associated values.
there’s a huge downside to this approach, in that you can’t express the union of both types without going through any Element, which i expect would allocate in this example, as neither type fits into the inline storage of an existential.
An enum type defines one concrete type, while a protocol defines an open space of adopters. I think the restrictiveness of a closed set of options is often sufficient, simpler, and thus preferable.
The primary use case I see for generics is as a way to implement algorithms operating on an open, yet constrained set of data types (via type constraints on argument placeholders). Yes, there's generic data types, but the algorithms are what make up the interesting bits.
You have exactly the same problem with a protocol-based design:
let radius = if let c = circle as? Circle {
c.radius
} else {
fatalError()
}
If you’re asking for a case-specific property, it doesn’t matter whether you represent your cases with a single enum type or a set of protocol-conforming types; you have to deal with the possibility of having the wrong case regardless.
If you need to do this, you should declare a Circle type and then the Element.circle case can store a Circle value instead of directly storing its center and radius.
I suspect this means you're storing too much "stuff" directly in your enum. Declare more types
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 Elements 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.