Synthesizing Comparable, for enums

sometimes, you want to create an enum where the cases have an inherent ordering:

enum Pass:Comparable 
{
    case priority 
    case preferred 
    case general
}

// .priority < .preferred == true 
// .priority < .general == true 
// .preferred < .general == true

right now, there's two options:

  1. declare it as a Raw Enum, with an Int backing, and compare using .rawValue
enum Pass:Int, Comparable 
{
    case priority 
    case preferred 
    case general
    
    static 
    func < (lhs:Self, rhs:Self) -> Bool 
    {
        return lhs.rawValue < rhs.rawValue
    }
}

this has a couple drawbacks:

  • you can't use associated values with such an enum
  • the integer values themselves are actually meaningless since you're just using the numbers to generate an ordering
  1. manually implement the < operator
enum Pass:Comparable 
{
    case priority 
    case preferred 
    case general
    
    static 
    func < (lhs:Self, rhs:Self) -> Bool 
    {
        switch (lhs, rhs) 
        {
            case    (.priority, .preferred), 
                    (.priority, .general):
                return true 
            case    (.preferred, .general):
                return true
            default:
                return false 
        }
    }
}

this also has drawbacks:

  • verbose and error prone
  • doesn't scale well: you need N^2 cases to implement < for an enum with N cases

From an API standpoint, option 2 is better, since we don't expose a meaningless rawValue implementation detail, and the enums can carry payloads. but from an implementation standpoint, option 1 is easier to write and harder to mess up. Why then, can't we have the compiler synthesize < for enums so they behave like option 2, but don't actually require you to write out all the comparison cases?

3 Likes

Can you still get free Comparable conformance if you manually implement RawRepresentable with Int values? That way you can use associated types at the expense of manually numbering your cases.

you still have to write a switch case for every enum case, and if you add a new case, you have to renumber the whole switch block

During SE-0185 we deliberately subset Comparable out of the discussion because it was less obviously correct, but I suppose since you still have to opt into itā€¦

The original discussion thread also included Comparable as a candidate for automatic generation. Unlike equatability and hashability, however, comparability requires an ordering among the members being compared. Automatically using the definition order here might be too surprising for users, but worse, it also means that reordering properties in the source code changes the code's behavior at runtime. (This is true for hashability as well if a multiplicative hash function is used, but hash values are not intended to be persistent and reordering the terms does not produce a significant behavioral change.)

3 Likes

well now's as good a time as any to build off of SE-185, and add synthesizable Comparable. i think most of the whole "reordering fields shouldn't affect the behavior" opposition was people worried that it would make the property declaration order in structs and classes meaningful, but I think with enums, making the case ordering matter makes more sense than with structs and classes, and we already do that in the language with synthesized rawValues: changing the order of the cases in an enum with an integer raw backing already changes its runtime behavior.

8 Likes

*shrug* I'm mildly in favor. :-)

5 Likes

I solve this problem using CaseIterable. No leaking raw values into the API. No boilerplate. (It is probably slower at runā€time though.) See here.

5 Likes

Without commenting on the pitch, I just want to point out that you can do this with N cases:

static func minimum(lhs: Self, rhs: Self) -> Self {
  switch (lhs, rhs) {
  case (.priority,  _), (_, .priority) : return .priority
  case (.preferred, _), (_, .preferred): return .preferred
  case (.general,   _), (_, .general)  : return .general
  }
}

static func < (lhs: Self, rhs: Self) -> Bool {
  return (lhs != rhs) && (lhs == minimum(lhs, rhs))
}
9 Likes

I would like this. Iā€™ve wanted it a couple of times.

Yes. Given that CaseIterable already provides an implicit ordering that ā€” even though that ordering isnā€™t guaranteed stable ā€” could be source-breaking, it doesnā€™t seem like a leap to say that enums explicitly marked Comparable would have semantically significant case ordering in code.

2 Likes

I think the : Int solution wouldn't be as terrible if we had internal-only conformances, so you could use the integer values within your own module but not have them leak to clients. I've wanted to do this a number of times (mostly when wrapping C types), in situations both involving ordering and not. I'm less concerned about the associated value restriction because chances are if your enum has them, your comparison logic is going to involve something more than just the case anyway.

But for what it's worth (especially given the observation about CaseIterable), I could see derivation of Comparable for enums without associated values based on source order as being fairly uncontroversial.

Doing the same for enums with associated values or for structs feels a bit more wishy-washy, but maybe if it's well-documented it wouldn't be too bad...

This is kind of an aside, but...

Yes, I know. AllCases is only a Collection and could theoretically be reimplemented someday as a Set by defaultā€”hence the caveat in the documentation. Even then, a manual implementation can still override it as an Array specifying the order. It would still be the option with the least boilerplate (that Iā€™m aware of), and provides enough information not just for <, but also things like next and previous.


Back on topic:

Regardless, I already use a form of it. Ergo, I think Comparable synthesis like proposed here is definitely useful.

1 Like

i think not having the associated values would be a huge loss,, i can think of lots of situations where youā€™d want to have associated values but not use them in comparison, for example you might want to associate ID numbers or metadata records

This is a contradiction: if Comparable were implemented this way, then all instances of the same case with different associated values must compare as equal. But Comparable inherits from Equatable, whose derived definition of == is that instances are equal based on the pairwise equality of their associated values (when they are all Equatable).

6 Likes

fair enough,, i do wish there was a way to generate ā€œpureā€ enums from enums with associated values, and have a way to upcast a payload-bearing instance to a pure type without having to write the whole thing out, but thatā€™s way beyond the scope of this pitch.

3 Likes

+1 for sure on Comparable-backed enums,
neutral on enums with no associated values
-0.5 for enums with associated values.

But...that got me thinking.

Is there a reason to not add it (opt-in) to RawRepresentable generally?

I've done this before:

extension RawRepresentable where Self: Comparable, RawValue: Comparable {
    static func < (lhs: Self, rhs: Self) -> Bool {
        return lhs.rawValue < rhs.rawValue
    }
}

And it allows:

enum ComparableIntEnum: Int, Comparable {
    case first
    case second
}

print(ComparableIntEnum.first < ComparableIntEnum.second) // true

enum NonComparableIntEnum: Int {
    case first
    case second
}
 // *Error*
print(NonComparableIntEnum.first < NonComparableIntEnum.second) // *Error*
// *Error*

This is something I expected to already be possible as it feels like the naturally expected behavior.

1 Like

I don't think it's the case that everything that's RawRepresentable should sort the same way as the underlying values. Is it okay if it defaults to sorting that way? Probably not for string-backed enumsā€¦

Hence the reason it should be opt in and overridable like Codable

I can see restricting it to numerical Types, but as a default, opt-in behavior I can see use cases for Strings as well. And Strings are already comparable, so IMO it would just make things consistent.

The problem is that that would shadow any enum-case-ordering-based implementation. (We actually have this problem already with Hashable, where it prefers to hash the raw value.)

The RawValue being the canonical thing from which to order makes perfect sense, but I am a heavy user of enums so maybe thats why. :relaxed:

However, I can understand it potentially confusing new users in cases like String. But in the case of a numerically backed enums, the only barrier to understanding I see is understanding how the values are already calculated.

The choice to be made between code order and raw value order is effectively this:

enum Month: String, Comparable {
    case january
    case february
    case march
    case april
    // ...
}
print(Month.january < .february) // True or false? What should it do?
// True if derived from code order.
// False if derived from raw values.
7 Likes