Synthesizing Comparable, for enums

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

sure, what you're describing would probably work with enough design effort, but i'd be really disappointed if this simple and useful feature succumbed to mission creep like so many other pitches here. let's not peg the future of enum ranking to the development of a language-wide macro system.

1 Like

On the other hand, making those metaprogramming capabilities available makes this and many other related features more approachable. One of the reasons features like this fall to the wayside is because the implementation in the compiler is fairly complex. Another example I'll point at is the request for tuples to conform to various protocols—I took a stab at that once and it ended up requiring a great deal more refactoring than I had the bandwidth for.

If we lower the barrier of entry to getting these features implemented from "core compiler contribution" to "standard library contribution", then many more folks in the community could get involved and flesh out solutions to these problems, and we don't add a bunch of technical debt to the compiler that might slow down other work. That kind of foundational stuff isn't glamorous compared to the resulting features people want, but if someone was willing and able to put the grunt work in for that, we'd be in great shape.

(Of course, I'm speaking somewhat hypocritically, as someone who does not have the time to put in that grunt work. :frowning_face: )

tbh the solution for this is self-hosting the compiler in swift, but that’s already been discussed to death on this site. i’m pretty skeptical of the whole “lets take the compiler and push it into the standard library” thrust because then you basically end up lots and lots of subsets of the language where different features are allowed in different flavors of swift, kind of like the situation with Builtin right now between the standard library and user-written swift, but with more layers. an example of a language which took this to the ideological extreme is k, where the standard library is the entire language. don’t get me wrong, k is great for prototyping DSLs, but it’s an absolute nightmare to try and understand anything anyone writes in k, because everything can be overriden by the user. (think of #define-ing new in C++, but instead of just new, it’s everything)

i also suspect exposing synthesized conformances as a metaprogramming capability is going to involve a substantial amount of compiler-side work anyway, for example, the @ifNotAlreadyImplementedByTheActualType and @diagnoseIfNotAllPropertiesConformToEquatable attributes in azoy’s example. for user-defined synthesized conformances to be useful, we would also have to do a lot of dirty work to make sure the compiler, the real compiler, actually understands the user-written metaimplementation, and can generate efficient IR out of it,, the last thing anyone wants is for compiled programs to actually execute synthesized conformances using runtime introspection.

I think moving certain things like literal parsing into the standard library makes a lot of sense, since literal parsing is kind of a “run once and done” kind of thing where we only care about semantics, and actual codegen is irrelevant, since there’s no code to be generated. on the other hand, with synthesized Comparable, we care a lot about codegen,, that’s what “synthesized” means.

A decimeter is 1/10th of a meter.

2 Likes

I don't think this is a good idea for enums with associated values or structs, for the same reasons it was left out of SE-0185 after being discussed during the pitch. If it is restricted to enums without associated values or raw values then I'm more comfortable. Though that makes things less uniform and it is also low priority for me because you can just use the rawValue as you say, or define your own private equivalent if you don't want to expose a rawValue, e.g.

enum Pass: Comparable
{
  case priority, preferred, general
  private var comparisonValue: Int { switch self {
    case .priority:  return 1
    case .preferred: return 2
    case .general:   return 3
  }}
  static func < (lhs: Pass, rhs: Pass) -> Bool { lhs.comparisonValue < rhs.comparisonValue }
}

if you want to add a new case preferredPlus ranked between .priority and .preferred, how do you do it without renumbering all the other cases? do you switch the key to a Float? that would be pretty silly.

enum Pass: Comparable
{
  private 
  var comparisonValue:Float 
  { 
    switch self 
    {
    case .priority:      return 1
    case .preferredPlus: return 1.5
    case .preferred:     return 2
    case .general:       return 3
    }
  }
}

this is why i don’t use metric

Actually it’s 3.3 decifeet—but some people find 62 micromiles easier to remember. :laughing:

4 Likes

It's a private implementation detail so do whatever you want I guess. Renumber all the cases, switch to a float, do it “BASIC line number style” and number them 10, 20, 30… in case you need to insert some in between, etc.

:face_with_hand_over_mouth: deca....decameter. I knew I should've looked it up :smile:

I've previously done something like this and I'm wondering it might be a more generalized (and clear) way to deal with this:

protocol ValueComparable: Comparable { 
    associatedValue ComparableValueType: Comparable
    var comparableValue: ComparableValueType { get }
}

extension ValueComaprable {
    static func < (lhs: ComparableValueType, rhs: ComparableValueType) {
       return lhs.comparableValue < rhs.comparableValue
    }
}

Which allows you to do:

enum PassType: ValueComparable {
    case priority, preferred, general

    var comparableValue: Int { 
        // Use larger numbers so values can be added between
        switch self {
        case .priority:  return 100
        case .preferred: return 200
        case .general:   return 300
        }
    }
}

It would also make value backed conformance easier:

enum PassType: Int, ValueComparable {
    case priority, preferred, general

    var comparableValue: Int  { rawValue }
}

It would also be trivial to add generalized extensions for those that want it.

Terms of Service

Privacy Policy

Cookie Policy