More Thoughts from the OP
So I see a pattern here: enums let you fake subclasses with a value type.
None of the suggestions here quite give me the right power to do what I'm asking about (which is simply the same as what I have now with enums with calculated properties filled with switch statements, but simply cleaner, IMO).
Enums provide a very unique concept in Swift: they are value types which almost allow subclassing by virtue of their associated types, yet they provide uniformity of creation by their being a single type. What's more they can conform to protocols which push the requirement on all cases to provide values or function implementations for the protocol, in much the same way that abstract methods require concrete subclasses to provide method implementations.
Simpler Example
Let's take a simpler version to see how this works.
enum MyThing: SomeProtocol {
case first(String)
case second(other: Int)
case third
var path: String { /* switch self ...*/ }
// advances through some state machine, by altering the value of `self`
mutating func advance() {
switch self {
case .first(let input):
self = .second(Int(input) ?? -1)
case .second:
self = .third
case .third:
break
}
}
}
-
This enum w/ protocol creates two generic ways to see cases: the first is as a MyThing and the second is as a SomeProtocol. Allows Lists of different cases when SomeProtocol references Self
-
An enum allows each case to store unique data for later retrieval by code that receives the case.
-
This enum w/ protocol requires every case to provide a value for someProtocolField, guaranteeing we can access that data on any variable of type MyThing; no need to use case matching.
-
An enum is a single type that consolidates case creation (with unique stored data): MyThing.first("whatever") and MyThing.third.
-
An enum can replace itself with another case via a mutating function through self assignment.
-
An enum is a value type.
Alternative Examples Review
This list lets us examine how each of the counter examples stand as alternatives.
Single Struct, Conforms to SomeProtocol, Static Creation Functions
@mbrandonw gave great examples of a struct with static functions for creation. It was a single struct, and it allowed for named static methods to create instances (keeping the feel of enums). This hits most points, but misses the associated values, and those are super useful downstream to allow other code to access them as if they were public properties of a subtype.
- Good: The
Request can be see as a Request or as a SomeProtocol.
- Miss: The
Request struct does not allow for associated value data to be stored anywhere.
- Good: Because
Request is only one type. By conforming to the protocol it will have a someProtocolField.
- Good: Because
Request is only one type, it obviously has all creation code (including the static creation methods in the example)
- Miss: A struct can have a mutating method which uses self assignment to replace itself with a different instance, but that instance can't be an entirely different struct with new fields.
- Good: A struct is a value type.
Score: 5/7
Multiple Structs, All Conform to SomeProtocol
@wtedst gave the example of multiple structs which each conform to the protocol. This hits indeed would give us the extra properties that are afforded enums through associated values. This example removes the uniform approach to instance creation; there is no static "dot syntax" uniting the creation. Creating an instance must instead use the actual struct type which stores the previous associated values.
- Miss: The
*Thing types (I'll make up that name for the outlined idea) conform to SomeProtocol, but they don't have a 2nd generic way to unify them. Just one.
- Good: The extra data can be stored in different types:
FirstThing, SecondThing and ThirdThing.
- Good: Each
*Thing implements SomeProtocol so it has to provide a value for someProtocolField.
- Miss: There is no single type joining
*Thing type defintions and providing a central creation point. We could create a new protocol MyThing, and have all of them implement it, but that unifying type does not allow for static methods for creation. (I'll address @mbrandonw's comments in #4 below).
- Miss: You definitely don't get self assignment across disjointed type definitions; you'd have to opt for a protocol method which returned a protocol type, allowing you to create an instance of another struct.
- Good: A struct is a value type.
Score: 4/7
Abstract Classes With Subclasses
We want a value type, so I'm skipping detailed analysis.
Why Does It Matter?
Trait #1: Lists With Protocol Refererencing Self
Last I knew, trying to make heterogeneous list of types that all implement a protocol that references Self just doesn't work. It's a mess. There needs to be second consolidating type entity that can act as the list type, allowing heterogeneous instances. Abstract classes work, so do enums. Both allow instances holding heterogeneous data within them to be treated like the same thing, even if those things also implement a protocol referencing Self. This is one way that enums "fake" subclasses for value types: they provide a single consolidating type name.
Trait #2: Variables Communicate Data
We often create types to transfer data to other places in the program. Enum associated values are used for that all the time.
Trait #3: Accessing Protocol Fields Requires Zero Case Matching at the Call Site
Again, variables communicate data. Variables of a common type should communicate the same types of data. Protocol fields (and functions) unify the treatment of all cases without requiring switch / case or if case semantics. Just call the function or access the data directly by virtue of it conforming to the protocol.
At the call site I can ignore the actual case if I want, and just ask for the data with the switch hidden in the enum.
So juxtaposing #2 and #3
Conformance to the protocol is inherent to the enum, and so the switch code belongs inside the enum. Reactions to each case with its unique associated data is inherent to the external object, and so the switch belongs outside the enum.
Enums have an inside voice and an outside voice, as my fatherly habits might say.
The difference between them boils down to the first are properties inherent to the "super type" or overall semantics of the enum or protocol (logic is inside the enum), while the second reflects the calling code as holding logic which reacts to the cases or "subtypes" (logic needs different data to act for different cases, and therefore needs associated properties).
In both views, the enum holds the data. In the first, all cases are seen as one type (the protocol), while in the second, all cases are seen as unique subtypes (the cases with various associated data).
Trait #4: Enums Feel Like a Factory
In the "Multiple Structs" counter example we could create a MyThing struct which only contained the creation methods. In that case we should probably call it MyThingFactory. That would bring all the creation logic into one type...but it wouldn't solve that example's miss of #1: the unifying type for Lists.
@mbrandonw mentioned the extended static member lookup, hoping it would solve the "creation methods under one umbrella type". When I read that proposal it specifically says that it does not create static members directly under a protocol itself, stating that would be too much of a change to the compiler. Instead, if I'm reading correctly, you must have some way to use a generic function whose return value is akin to any View in order to allow the compiler to use the details of that function call to infer the actual struct implementing the protocol and then find the static member extension that provides the new static property/function.
Those requirements boil down to you still can't pull creation under a single protocol type (i.e. MyThing.first("whatever").
Trait #5: Changing Value While Also Changing Implementation Type
Normally we would need to use a variable like var state: SuperType to allow us to dynamically switch which implementation is active at any given time (state.advance() with NewSubType this.state = NewSubType()). But classes are reference types. A suite of structs can't accomplish this, but enums can also do this using self assignment. var state: MyThing allows us to state.advance() and our current state now has entirely different types of associated data...but it's still a value type.