Synthesizing Comparable, for enums

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

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

2 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. :slight_smile:

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.
5 Likes

Right. I guess I see it as the expected behavior to be sorting based on the value of a case, not the order. Just as it is implicitly in (most) C-derived languages. Yes that's a tad weird with Strings, but I don't see any other way to make the behavior feel consistent.

e.g. you wouldn't this:

enum Day: Int {
    case two = 2
    case one = 1
}

to default to .two < .one being true

As long as it's, again, opt-in and overridable.

It's not satisfactory just because it's opt in. The problem is that what you're opting into is ambiguous (or rather, undesirable/confusing/error-prone in the case of string-based enums).

1 Like

If you dont like like the auto-generated implementation, you do have the option of writing your own.

To me, using the comparison of the rawValue is the behavior I expect to already be available. I can understand it being confusing for new users, but I guess I don't see this as a "new user" feature to begin with, much like Codable. However, I can understand (and support) more clarity around this kind of "magic".

One option would be something like

protocol RawValueComparable: Comparable { }

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

I'm unsure that's the best direction as far as Foundation APIs go, (that's a separate question), but it might be a good compromise between magic and clarity.

1 Like

To me, something like this seems like a code smell. Why would anyone write case two before case 1?

Alternatively if you wanted an enum that was like your Day example, it's much more likely that you would write this. Note also that an incremented value is already inferred by the ordering of the elements.

enum Day: Int { 
   case one = 1
   case two
 // case noWay = 2 (this generates an error: 'Raw value for enum case is not unique')
}

print("\(Day.two) -> \(Day.two.rawValue)") // two -> 2

Personally in my own coding, I would expect comparable to follow the source order of the cases. An int or string rawValue could represent some sort of json value or key to a backend or device:

Consider a device that has some sort of brightness that can be controlled:

enum ScreenBrightness: String {
   case low
   case medium
   case high
}

There are definitely reasons why you might want a string rawValue, but you would never want this sorted by its raw value.

Additionally, it's a lot easier to get the rawValue sorting order from a source defined order than the other way around.

enum BestLetterAcronym: String, CaseIterable {
   case bravo
   case alpha
   case charlie

   static let ordered = BestLetterAcronym.allCases.sortedBy { $0.rawValue < $1.rawValue }
}

print(BestLetterAcronym.ordered.map { $0.rawValue }) // ["alpha", "bravo", "charlie"]

I'm not sure how you would convert a RawValue ordered enum to a user defined ordering with adding a switch case with an order value int.

1 Like

It's an example, not a real world use case. But I will say I've seen stranger things :smile:. A more realistic example would be adding a new case to an already existing API

enum LocationAccuracy: Float {
    case decameter = 10
    case kilometer = 1000

    /// new option added
    @available(iOS 11.0, *)
    case meter = 1
}

Never's a strong word, but again this is a question of default, not capability. If someone wants custom sorting, they're free to do so. To me it seem obvious that for numerical enums, sorting should be based on the underlying value just as it would be in Objective-C or C/C++.

If this behavior is so undesired that it shouldn't be done for Strings, then IMO it would be better to not have it at all rather than it work inconsistently depending on RawValue's Type.

It should be pretty easy. This is about Comparable, not changing the generation of allCases (though I believe the ordering is technically undefined?):

static func < (lhs: YourEnum, rhs: YourEnum) -> Bool {
    return allCases.firstIndex(of: lhs)! < allCases.firstIndex(of: rhs)!
}

Adding more derived conformance implementations to the compiler isn't going to scale in the long run. I'm also mildly in favor of synthesizing Comparable, but if this gets past the pitch phase and someone starts to prototype it, it might be good to think about what a more general solution would look like. Even if not exposed as a user-visible feature, I'd love for some or all of the complexity behind the current Equatable/Hashable/Codable synthesis to be moved to the standard library.

12 Likes

i’m having a hard time imagining what a “general solution” would even be like, since “synthesized” conformances have to drill down into the actual member table of a type

This idea is by no means completely fleshed out, but one could imagine synthesized == for a type being replaced by a default implementation implemented in terms of something like KeyPathIterable:

// pseudo-ish code
static func == (lhs: Self, rhs: Self) -> Bool {
  for keyPath in Self.allKeyPaths {
    guard lhs[keyPath: keyPath] == rhs[keyPath: keyPath] else { return false }
  }
  return true
}

There's still a ton of unknown details and unanswered questions here though, such as:

  • How do we express the condition that this conformances should only apply to types whose properties are all Equatable? Right now there's no way to write that constraint.
  • If KeyPathIterable can be customized by users, how would that impact a proposed implementation based on it?
  • Can the compiler optimize the loop out to make it just as efficient as a sequence of conditional expressions?
  • What about enums? How would we express the same concept over their associated values?

The trick seems to be figuring out how to get the compiler to expose the metadata that's needed using a convenient set of metaprogramming primitves so folks can write general algorithms in terms of those. (So, hygienic macros, I guess?)

3 Likes

Tony mentions a lot of the current questions towards this sort of thing, but I went a different route in terms out implementation :stuck_out_tongue:

I'm just spitballing...

// Extend all types who declare conformance to Equatable
extension<T: Equatable> T {
  @ifNotAlreadyImplementedByTheActualType
  @diagnoseIfNotAllPropertiesConformToEquatable
  static func ==(lhs: T, rhs: T) -> Bool {
    for i in Reflect(T.self).propertyCount {
      guard Reflect(lhs).properties[i] ==
            Reflect(rhs).properties[i] else { return false }
    }

    return true
  }
}
1 Like