[New Old Idea] Enum Case Blocks

6 years ago I had a pitch that went to proposal, but that just didn't fit with Swift 4 / ABI stability. I'd love to revisit it now.

Instead of having lots of computed properties using switch, allow each case to declare its property value in "enum case blocks". This shuffles the data, pulling them all into the same place, making the entire file simpler to understand. There are some edge cases, so you'll want to read the original proposal. Here's the PR:

And here's the formatted view of the proposal text:

And the final closing comment on the ticket is this:

"The core team discussed this proposal and determined that it doesn't fit in the scope of Swift 4: it is purely additive (and could be introduced at any time), is not source-breaking (so it doesn't need to be front-loaded), and does not affect ABI or the standard library."

So I wonder if it's good to bring up again. What thoughts does everyone have after a lot of time using Swift?

The original discussions (email, not forum):

Week 1: The swift-evolution The Week Of Monday 2 January 2017 Archive by thread
Week 2: The swift-evolution The Week Of Monday 9 January 2017 Archive by thread

Newer Example

Here's a Moya enum in the new syntax. See the proposal text "mixed use" for placing common properties outside case blocks:

enum MarketingApi: TargetType {
    var baseURL: URL {
        URL(string: "https://api.example.com")!
    }

    case getMarketingitems(company: String, audience: String, region: String) {
        var method: Moya.Method { .get }

        var path: String { "v2/marketing/items" }

        var headers: [String: String]? {[
            "channel": "ios",
            "x-request-id": UUID().uuidString,
            "x-region": region
        ]}

        var task: Task {
            .requestParameters(parameters: [
                "company": company,
                "audience": audience
            ], encoding: JSONEncoding.default)
        }
    }


    case getMarketingContentitems(company: String, currentDeviceRegion: String = "") {
        var method: Moya.Method { .get }

        var path: String { "v2/marketing/content-items" }

        var headers: [String: String]? {[
            "channel": "ios",
            "x-request-id": UUID().uuidString,
            "x-region": currentDeviceRegion
        ]}

        var task: Task {
            .requestParameters(parameters: [
                "company": company,
            ], encoding: JSONEncoding.default)
        }
    }
}

Original

enum MarketingApi {
    case getMarketingitems(company: String, audience: String, region: String)
    case getMarketingContentitems(company: String, currentDeviceRegion: String = "")
}

extension MarketingApi: TargetType {

    var baseURL: URL {
        URL(string: "https://api.example.com")!
    }

    var path: String {
        switch self {
        case .getMarketingitems:
            return "v2/marketing/items"
        case .getMarketingContentitems:
            return "v2/marketing/content-items"
        }
    }

    var headers: [String: String]? {
        switch self {
        case let .getMarketingitems(company, audience, region):
            return [
                "channel": "ios",
                "x-request-id": UUID().uuidString,
                "x-region": region
            ]
        case let .getMarketingContentitems(company, currentDeviceRegion):
            return [
                "channel": "ios",
                "x-request-id": UUID().uuidString,
                "x-region": currentDeviceRegion
            ]
        }
    }

    var task: Task {
        switch self {
        case let .getMarketingitems(company, audience, region):
            return .requestParameters(parameters: [
                "company": company,
                "audience": audience
            ], encoding: JSONEncoding.default)
        case let .getMarketingContentitems(company, currentDeviceRegion):
            return .requestParameters(parameters: [
                "company": company
            ], encoding: JSONEncoding.default)
        }
    }

    var method: Moya.Method {
        switch self {
        case .getMarketingitems:
            return .get
        case .getMarketingContentitems:
            return .get
        }
    }
}
3 Likes

I heavily disagree.

To me, the result is quite the contrary: the data gets distributed throughout an entire file, making it far less local. If you have a switch statement, everything is essentially in the same place — both the cases you switch over and the results they generate — whilst in the proposed syntax, only the correlation "case -> result" is apparent, but not the reverse.

A lot of people use protocols for the same purpose, and here the analogy is essentially in that each case is a separate "struct" conforming to the overall "protocol of the whole enum". I personally never found this easier to read or reason about; moreover, the tendency is that such "conformances" tend to get dispersed over multiple files for the sake of such "encapsulation" alone. I always found it to be just such a huge mental toll to construct a mind map of correspondences between the "types" (here: cases) and the "conformances" (here: computed properties) that I don't see any immediate, provable benefit to the alternative syntax.

Oh and yes, if the result so much resembles the way you would work with structs, why is this an enum in the first place?

5 Likes

I'm not sure if this was mentioned in the previous thread, but there is a way to flip the example you have provided that gives you the syntax you want. It just doesn't use an enum anymore.

Instead of designing an enum whose primary purpose is to be transformed into a request, you can instead start with a request and define static methods on it for constructing the request:

struct Request {
  var method: Method
  var path: String
  var headers: [String: String]?
}

extension Request {
  static func getMarketingItems(
    company: String, 
    audience: String, 
    region: String
  ) -> Self {
    Self(
      method: .get, 
      path: "v2/marketing/items",
      headers: [
        "channel": "ios",
        "x-request-id": UUID().uuidString,
        "x-region": region
      ]
    )
  }

  static func getMarketingContentitems(...) { ... }
}

This allows you to bundle all of the work that each case is doing into a single "block", and there's no need to manage an enum. The syntax can even look enum-ish, such as api.request(.getMarketingItems(...)), but you just don't have the ability to exhaustively switch over the API endpoints.

I think this is the best way to accomplish what you want. An enum is not really needed, and I think the biggest indicator that an enum isn't needed is that most likely you aren't switching on the enum outside the MarketingApi type, and so the power of exhaustive destructuring isn't really needed.

This also applies to some of the examples in your proposal. For example, UserMessage could be refactored like this:

struct UserMessage {
  let description: String 
  let render: (UIViewController) -> Void
  let tinColor: UIColor
}

extension UserMessage {
  static func expired(date: Date) -> Self { ... }
  static func invalid(...) -> Self { ... }
  static func validated(token: String) -> Self { ... }
}

And if the "struct with static functions" style of this makes you feel unconformable, you can also do it with a protocol and a conformance for each case. And in fact, extended static member lookup makes this style look just like the enum and struct w/ static functions style.

14 Likes

Welcome back!

It would indeed be interesting to see whether folks have found strong use cases for such a feature in the intervening years. I see nothing obviously wrong with the idea, and the proposed syntax is rather elegant. However, the proposal text and discussion are light on motivation: yes, this syntax might be “easier” and “valuable” for some enums, but these are assertions with which someone might simply disagree—to make the case for new syntax, one would need to find examples that persuasively back up the assertion.

10 Likes

I've seen a need for this as well, for essentially the same use case (type-safe representations of remote APIs). I'll try to think of some different uses, because I do like this idea.

I wonder if it would be possible to define a case block property in an extension later in the file -- or in a different file entirely? If not, a different syntax might allow a little more modularity:

enum MarketingAPI {
    case retrieveMarketingItems
    case retrieveMarketingContentItems

    var path: String { fatalError("Missing Implementation") } 
}

extension MarketingAPI.retrieveMarketingItems {
    // override only allowed if `\.path` is defined on the base enum
    var path: String { "v2/marketing/items" }
}

This would be reassembled by the compiler as:

enum MarketingAPI {
    case retrieveMarketingItems
    case retrieveMarketingContentItems

    var path: String {
        switch self {
            case .retrieveMarketingItems:
                return "v2/marketing/items"
            default:
                fatalError("Missing Implementation")
        }
    }
}

Sure. Different people have different ideas about what is readable. Often times when I go to code like this, I'm curious about the entire setup of a "case" and not concerned with the variety of values across all cases. It's akin depth-first vs breadth-first; both traverse all the data. The proposal allows for mixed-use scenarios, and of course it's all entirely optional syntax. Not a fundamental change to the enum syntax.

I like the ideas of structs and protocols, like others mentioned. It's good feedback to both broaden and narrow the motivation. Sure this can be done with those others...but is there benefit to approaching them as an enum? Clearly that needs to be thought through and called out. If there isn't much benefit it's likely a bad idea. :slight_smile:

I like your emphasis on "strong use cases" and the proposal text being "light on motivation". It's excellent feedback, and a good litmus test. When there are clear examples which delineate when this approach is clearly preferable to both the struct/protocol idea and the enum/switch idea, then it will be more desirable to affect change at the language level. Good feedback indeed.

1 Like

Stellar example. Love it. Like others, I think it highlights where we've moved to in the intervening years and puts pressure (and rightly so) on finding a clearly, and likely narrowly, delineated area of benefit not covered in such an approach. If we find one, this example should be included to show how existing Swift can be used, therefore highlighting the truly unique part of enum case blocks. This is great food for thought. Thank you!

Yeah, that's why I submitted my counterexample as a means to counter an opinionated claim with an opinionated claim, and this is where I appreciate @xwu's reply: the readability/simplicity discussion on its own almost always lacks a definitive metric, so it could go all possible ways unless one starts to provide much more concrete and rigorous examples.

I think your use of keypaths is a very interesting addition to the discussion! I'm cognizant of the core idea that enums are not intended to be like super/subclasses, and so the extension idea is interesting, but I don't know enough about it to know its impact on what the compiler does and how to treat it. I'm hoping that this proposal stays close to "construct the switch statement under the covers, so this has the exact same compiled output as our current enum/switch approach," but the ideas may be so close to class/subclass that it's only efficiently implemented that way. Such a result should probably diminish the appeal of this idea.

Absolutely! A counter example is great to show variety of opinions exist, but it does not negate the other opinion (since this is optional syntax, and wouldn't require you to conform to my syntactic sugar).

But your preference is currently available, and mine is not. /shrug And it turns out that the world needs 3 kinds of spaghetti. So I believe it's still worth pursuing a solution even though it isn't your particular thing. And it's important for that discussion to acknowledge divergent opinions, though without evidence either way of either the variety or relative sizes groups of opinions on readability. So I totally find your feedback useful!

1 Like

I'm not a compiler wizard by any means, but I can imagine an implementation where the overridden properties all resolve to the current switch-within-a-single-property implementation, as long as all of the property "overrides" are defined within the same module. (I've updated my original example to show this.) So even though all of these property definitions appear as extensions on specific enum values, it's all just syntactical sugar for the switch-based property we have today.

Maybe this is somewhat mitigated by SE-0299, where one could follow the patterns popularized by SwiftUI where one implements the "cases" as implementation of a protocol, and then used dot-syntax to access each implemented concrete "case" in a visually identical way as enum cases?

2 Likes

I take it you don't like a multitude of switch statements. Here's a version of your code that only has one switch statement:

enum MarketingApi: TargetType {
    var baseURL: URL {
        URL(string: "https://api.example.com")!
    }
    
    case getMarketingitems(company: String, audience: String, region: String)
    case getMarketingContentitems(company: String, currentDeviceRegion: String = "")

    var params: (path: String, headers: [String: String]?, task: Task, method: Moya.Method) {
        switch self {
        case let .getMarketingitems(company, audience, region):
            return (
                path: "v2/marketing/items",
                headers: [
                    "channel": "ios",
                    "x-request-id": UUID().uuidString,
                    "x-region": region
                ],
                task: .requestParameters(
                    parameters: [
                        "company": company,
                        "audience": audience
                    ], encoding: .default
                ),
                method: .get
            )
        case let .getMarketingContentitems(company, currentDeviceRegion):
            return (
                path: "v2/marketing/content-items",
                headers: [
                    "channel": "ios",
                    "x-request-id": UUID().uuidString,
                    "x-region": currentDeviceRegion
                ],
                task: .requestParameters(
                    parameters: [
                        "company": company
                    ], encoding: .default
                ),
                method: .get
            )
        }
    }
}

Same number of lines. It is also kinda "less redundant" as you don't have to repeat several times that method is of type Moya.Method, headers is of type [String: String]?, etc.

1 Like

If Swift had a built-in user-accessible source transformation/metaprogramming facility*, every discussion like this would be transformed from back-and-forth on a feature request to the happy announcement of a new library or sharing of a snippet.

I'd encourage anyone who has ideas for different ways to write or organize their Swift code to think about the strategic goal of enabling many such ideas all at once, not just each individual one as it comes up.


*Sometimes called "macros", but like the robust Lisp feature, not the impoverished textual kind from C.

2 Likes

I strongly agree that the struct + static factory methods approach is a much more appropriate design. I think the issue with the original, enum based design can be summed up as follows: it attempts to construct a product type (struct like thing that holds multiple values at once) from a sum type (single value from a set of mutually exclusive cases).

If a request object needs many fields, eg path, method, etc, we should model it with a struct which was specifically designed for that. With an enum, we end up writing a ton of switch statements to synthesize each of those fields from the single case value, which makes it much harder to read and prevents you from being able to collocate the related bits of data (OP’s original complaint).

I think the best solution is to remodel your data using the appropriate language construct. New language features and/or macros should not be a solution to improper data modeling.

6 Likes

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
    }
  }
}
  1. 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

  2. An enum allows each case to store unique data for later retrieval by code that receives the case.

  3. 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.

  4. An enum is a single type that consolidates case creation (with unique stored data): MyThing.first("whatever") and MyThing.third.

  5. An enum can replace itself with another case via a mutating function through self assignment.

  6. 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.

  1. Good: The Request can be see as a Request or as a SomeProtocol.
  2. Miss: The Request struct does not allow for associated value data to be stored anywhere.
  3. Good: Because Request is only one type. By conforming to the protocol it will have a someProtocolField.
  4. Good: Because Request is only one type, it obviously has all creation code (including the static creation methods in the example)
  5. 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.
  6. 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.

  1. 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.
  2. Good: The extra data can be stored in different types: FirstThing, SecondThing and ThirdThing.
  3. Good: Each *Thing implements SomeProtocol so it has to provide a value for someProtocolField.
  4. 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).
  5. 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.
  6. 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.

Yes, but it would also lead to a lot code that most developers wouldn't be able to readily understand.

This reminds me of Java enums, and how they can have stored properties rather than requiring a switch in every method. Here's an example from the documentation:

public enum Planet {
    MERCURY (3.303e+23, 2.4397e6),
    VENUS   (4.869e+24, 6.0518e6),
    EARTH   (5.976e+24, 6.37814e6),
    MARS    (6.421e+23, 3.3972e6),
    JUPITER (1.9e+27,   7.1492e7),
    SATURN  (5.688e+26, 6.0268e7),
    URANUS  (8.686e+25, 2.5559e7),
    NEPTUNE (1.024e+26, 2.4746e7);

    private final double mass;   // in kilograms
    private final double radius; // in meters
    Planet(double mass, double radius) {
        this.mass = mass;
        this.radius = radius;
    }

    /* ... methods that use mass and radius ... */
}

As much as I generally dislike Java, the ability to declare properties for each case rather than having to switch is one thing I liked.

1 Like

As the title said, not a new idea. I hope something like that since swift 1.
Other solutions:

enum Planet: (mass: Double, radius: Double) {  // named tuple only
    case mercury = (3.303e+23, 2.4397e6)
    case venus = (4.869e+24, 6.0518e6)
}

// auto generated properties by the compiler
let aPlanet = Planet.mercury
print(aPlanet.mass)
print(aPlanet.radius)

The tuple is not a rawValue but could contains the rawValue as the first parameter

enum HTTPError: (rawValue: Int, label: String) {
    case normal = (200, "OK")
    case badRequest = (400, NSLocalizedString("BadRequest", comment: "Bad Request")
...
}

if let error = HTTPError(rawValue: errorCode) {
    print(error.label)
}

An other idea, replacing the tuple by a Struct that could be access by a property value

struct StateDef {
    let index: Int
    let color: UIColor
}

enum State: StateDef {
    case normal = StateDef(index: 0, color: UIColor.green)
    case error = StateDef(index: 1, color: UIColor.red)
}

let state = State.error
let color = state.value.color

If the struct StateDef is RawRepresentable

struct StateDef: RawRepresentable {
    let rawValue: Int
    let color: UIColor
}

state.rawValue      // equivalent to state.value.rawValue
let state2 = State(rawValue: 1)
2 Likes