Background
There is some overlap between the functionality of polymorphism and that of enums.
Example
As a contrived example:
enum AnimalEnum {
case dog
case cat
func makeNoise() {
switch self {
case .dog: print("Woof")
case .cat: print("Meow")
}
}
}
vs.
protocol AnimalProtocol {
func makeNoise()
}
struct Dog: AnimalProtocol {
func makeNoise() { print("Woof") }
}
struct Cat: AnimalProtocol {
func makeNoise() { print("Meow") }
}
(In this case AnimalProtocol
is almost a certainly a better choice, but this is a synthetic example for demonstration only.)
The both provide the ability to express a "one choice out of many" situation, but with different trade-offs.
Trade-offs
There are some pros/cons to each:
Enums
-
Exhaustivity vs Extensibility:
-
Enums prevent others from adding new cases, so client code can exhaustively handle all cases (particularly for
@frozen
enums). - Enums aren't extensible, so they restrict possibilities for client code to add their own behaviour.
-
Enums prevent others from adding new cases, so client code can exhaustively handle all cases (particularly for
-
Syntax:
- Enums explicitly state all cases in one place.
-
Practically every method of an
enum
will need to do some kind of pattern matching (equality checking,switch
cases,if case
,guard case
, etc.) statements to be able to know what to do. Enums force you to glue all method implementations together. E.g.makeNoise()
contains themakeNoise()
implementations of both thedog
andcat
case. There's no way to say "put all thedog
implementations here, all thecat
implementations there, etc.
-
Performance
- Enum have a really efficient memory layout (assuming the cases have similar sizes), directly on the stack.
- Calls directly to methods on enum method can always be statically dispatched, which unlocks a bunch of other optimizations (most importantly: inlining).
-
Cases of an enum aren't their own unique type. E.g. if you have an enum value whose value you know is
.cat
(because you checked it), you can't pass it to a function as aCat
, only as anAnimalEnum
, where that knowledge of it necessarily being a.cat
is lost.
Polymorphism:
- Exhaustivity vs Extensibility:
- Exhaustivity of "cases" (subtypes) can't be enforced, apart from the comparatively broad granularity of control that access levels grant you.
-
Very extensible. It's the go-to tool for introducing seams for extensibility.
- (in the case of subclasses) "cases" can be subclassed even further. So new "cases" that you define don't just have to be brand new types, they can be refinements of existing types.
- Syntax:
- Finding all conforming types can be tricky. But luckily, it's seldom required.
-
Syntax: "Cases" (what I'll call protocol-conforming types and subclasses from now on) can have standalone implementations, implemented across separate files, folders or even modules.
- Implementations of methods (and initializers, properties, subscripts) for every type are independent, so they're short, sweet and highly focused
- Performance
-
Protocols have a comparatively inefficient memory layout. Non-"class" protocols require boxing values into existential containers (which are a fixed size of 5 words). If the conforming values are structs that are small enough (of size
x
), they can be stored inline in the existential container (with3 - x
words wasted). Worse, if they're too large, they have to be heap allocated with a reference stored in the container. This wastes 2 words of storage, and can thrash the CPU cache and increase the latency of look-ups. - Polymorphism requires dynamic dispatch. There are many cases that can be optimized, but not the general case of a subclassing a class for conforming to a protocol, from a different module.
-
Protocols have a comparatively inefficient memory layout. Non-"class" protocols require boxing values into existential containers (which are a fixed size of 5 words). If the conforming values are structs that are small enough (of size
-
Cases are modelled by types, which can be passed around. Once I know I have a
Cat
, I can pass aCat
, and have full access to the API ofCat
(not just those required by theAnimalProtocol
).
The problem:
Point 2.2 in the trade-offs above. There's practically no way to write a useful method on an enum without switch
ing on it, or doing some other kind of pattern matching. Syntactically, your enum method blocks become huge, your method implementations don't have a single responsibility.
Case Study: Java
Java case study
As a little known feature, Java's enums support abstract methods. Enums define abstract
methods, which must be implemented by every case. This allows short, sweet and focused method bodies: if you see a method body in the DOG
case, you know you're working with a DOG
. No need to check for CAT
, and clutter the implementation for DOG
with a cohabiting implementation for CAT
.
public enum Level {
DOG {
@Override public void makeNoise() {
System.out.println("Woof");
}
},
CAT {
@Override public void makeNoise() {
System.out.println("Meow");
}
};
public abstract void makeNoise();
}
Questions:
- Is this enough of a problem that we should do something about it? I think so.
- How should it be designed? idk.