SE-0266 — Synthesized Comparable conformance for enum types

Having all the enums default to source ordering and requiring what’s likely a one-liner to define < to use your enum’s rawValue if there is one seems another reasonable approach to me, provided synthesis is dependent on opting in by stating conformance to Comparable (which I’m unsure of from reading the proposal and nobody answered my question about it). If all enums are implicitly Comparable, then that’s a whole other ballgame and erring on the side of caution is understandable and preferable.

3 Likes

The “proposed solution” section states:

Enumeration types which opt-in to a synthesized Comparable conformance

and the examples in that section all include an explicit conformance, so I’m fairly certain the author’s intent is to opt-in and not diverge from established practice of other protocols.

5 Likes

Ok that makes sense. I only saw speculation on what might be the reason, but no "official" explanation.

If/when this is implemented I anticipate the restriction being pretty unintuitive for end users, so a specific error message would be much appreciated.

Also, if this goes through as-is I can't see value-backed-enums diverging from the new established expectation, but if that decision is to take place in a later pitch I'll hold off on elaborating :slightly_smiling_face:

@Ben_Cohen, what are the core team's thoughts on the situation with the two semantically equivalent representations of the raw-representable enum I gave an example of in this reply upthread?

If we don't support raw value enums, users who want a raw-value enum to be Comparable have two options:

  • Have the enum derive from the raw type to receive synthesized RawRepresentable, then conform to Comparable and implement < manually.
  • Have the enum omit the raw type, opt in to synthesized Comparable, then conform to RawRepresentable and implement init?(rawValue:) and var rawValue manually.

Unfortunately, either of these options still leaves the user forced to write quite a bit of boilerplate code, especially for large enums.

The concern about user confusion make complete sense, but looking at the entire picture, is what we lose worth the trade-off?

8 Likes

I'm not sure this is quite a bit of boilerplate. I think you can do something like

private static let ordering: [MyEnum: Int] = {
  let ordered = [.first, .second, .third, ...]
  return Dictionary(ordered.enumerated().map({ ($0.1, $0.0) })
}()
static func < (lhs: MyEnum, rhs: MyEnum) -> Bool {
  return ordering[lhs]! < ordering[rhs]!
}

and some bits at the beginning can be put in common code. This might be considered quite a bit though.
There's also the issue that this code doesn't verify you cover all cases, you'd need a switch for that (which is quite a bit of code)


Edit:

Or even better you can conform to CaseIterable and implement it as allCases.firstIndex(of: lhs)! < allCases.firstIndex(of: rhs)!

Neither of those options are as space- or time-efficient as a simple ordinal comparison would be, which is all that's needed here. This operation should only need minimal space and constant time (not amortized), so even if those examples give correct results, I wouldn't consider them to be "correct" implementations.

2 Likes

What if we only allowed the synthesis of Comparable for a raw-type enum if the source order is consistent with its rawValue? That avoids the confusion, and pushes the cases where you have to write the Comparable (or RawRepresentable) implementation manually into a narrower corner. Personally, I find using source order instead of rawValue order for Comparable very surprising for a raw-representable enum. I would be particularly concerned if we did that for types imported from C, where the order would then differ between the languages.

3 Likes

One thing that I don't understand from latest discussion is why the synthesis for raw representable enums should be harmful!?

This proposal communicates that the default for the synthesis will be the declaration order, which by now wasn't possible. That means that all enums even if they have implicit or explicit conformance to RawRepresentable protocol will have a custom implementation for the static < function if that enum happens to conform to Comparable. That however means that the user already would >>opt-out<< from the default declaration order. And last but not least this implies that there won't be any source breakage here.

So where is the harm with that?

My (totally unfounded) hunch is that that wouldn't cover very many enums. Integer-based ones, perhaps, but not very many string-based ones.

I could imagine someone wanting to have easy Comparable support on something like this, where, as a strawman, the raw value is a localization key used for string lookup:

enum Month: String, Comparable {
  case january = "month_january"
  case february = "month_february"
  // ...
}

I don't believe types imported from C would be affected because they can't opt-in to Comparable synthesis via conformance on the type declaration or an extension in the same file, right?

2 Likes

Good point!

I'm really sorry I haven't had a chance to read every message here but based on my reading of the proposal and skimming this thread, I want to float an alternative idea that aims to avoid the conflicting ambiguities of source order vs raw values.

If you want to manually implement Comparable for a RawRepresentable enum, you can simply write

static func <(lhs: EnumType, rhs: EnumType) -> Bool {
    return lhs.rawValue < rhs.rawValue
}

What if the compiler offered a way to get the source order as an integer? Maybe with #sourceOrder(value) magic 'macro' or a magic computed property. Then, the programmer could declare the < conformace explicitly, with an equally simple one-liner.

static func <(lhs: EnumType, rhs: EnumType) -> Bool {
    return #sourceOrder(lhs) < #sourceOrder(rhs)
}

This essentially automates the private integer approach, which is traditionally unwieldy as discussed in the proposal. The desired semantics are clearly declared in the codebase, addressing the possible ambiguities that arise from fully automatic synthesis, but without the annoying warts of having to write and maintain a private comparisonValue property or whatever.

In general, I feel like automatic synthesis should only be deployed when there is a very self-evident default implementation. Enums are used in so many different ways that I don't think an obvious automatic conformance of Comparable exists, especially one that relies on a concept (source ordering) that is otherwise eschewed by other types.

I'd definitely +1 this proposal if the only alternative is to do nothing, but it doesn't feel in the spirit of Swift to me as is.

5 Likes

A couple options I've used:

//PROPERTY APPROACH

protocol PropertyComparable: Comparable {
    associatedtype ComparisonType: Comparable
    var propertyToCompare: ComparisonType { get }
}
extension PropertyComparable {
    static func <(left: Self, right: Self) -> Bool {
        return left.propertyToCompare < right.propertyToCompare
    }
}
enum ComparableEnum: Int, PropertyComparable {
    case first
    case second
    var propertyToCompare: Int { rawValue }
}
// Usage
let values: [ComparableEnum] = [.second, .first, ]
let sorted = values.sorted() // [first, second]


//KEY PATH APPROACH

protocol KeyPathComparable: Comparable {
    associatedtype ComparisonType: Comparable
    static var keyPathToCompare: KeyPath<Self, ComparisonType> { get }
}
extension KeyPathComparable {
    static func <(left: Self, right: Self) -> Bool {
        left[keyPath: Self.keyPathToCompare] < right[keyPath: Self.keyPathToCompare]
    }
}
enum SecondComparableEnum: Int, KeyPathComparable {
    case first
    case second
    static var keyPathToCompare: KeyPath<SecondComparableEnum, Int> { \.rawValue}
}
// Usage
let secondValues: [SecondComparableEnum] = [.second, .first, ]
let secondSorted = secondValues.sorted() // [first, second]

Or if you want it simply declaration opt-in for raw values, something like this:

protocol RawValueComparable: RawRepresentable, Comparable where RawValue: Comparable { }
extension RawValueComparable {
    static func <(left: Self, right: Self) -> Bool {
        return left.rawValue < right.rawValue
    }
}
enum RawValueEnum: String, RawValueComparable {
    case first = "A"
    case second = "B"
}
// Usage
let rawValueEnums: [RawValueEnum] = [.second, .first, ]
let rawValueSorted = rawValueEnums.sorted() // [first, second]

(sorry for the code dump, made it as small as made sense)

I do like the general idea to have the compiler synthesize opt-in Comparable for enums. It seems like a welcome addition to Swift, in line with other synthesized conformances for enums. The idea is also in line with similar concepts from other languages (i.e. Java). That being said, there are some aspects of this proposal that seem a bit odd and unintuitive.

Firstly, the comparison of enum cases with associated values attached seems weird. If I were to have a case with multiple associated values, who is the compiler to say that I want it to be ordered in that specific manner. Associated values of a case may be placed in an order to match a convention or to improve readability alongside their labels. For example, with or without labels, case power(base: Int, exponent: Int) by convention places the base before the exponent. While allowing enums with associated values to qualify for synthesized Equatable conformance is pretty straightforward, I would argue that it is much less so for Comparable. While one could indeed argue that the same behaviour is present for the comparison tuples, I would say that enum cases are different, as—at least for me—the ordering of associated values for a case usually has nothing to do with the order of its comparison. I think it would be much more straightforward for enums that have cases with associated values not to qualify for synthesized Comparable conformance, requiring the user to define the < operator explicitly.

Secondly, disqualifying enums with raw values from synthesized Comparable conformance seems confusing and needlessly prohibitive. People use the raw values of their enums for all sorts of things (i.e. case codes, identification, etc.). I feel it would make more sense to allow any enum (excluding those with associated values) to qualifty for opt-in synthesized Comparable conformance. If one would like to use the rawValue of their enum instead of the source ordering of their cases for comparison, they can always simply add in:

static func <(lhs: Self, rhs: Self) -> Bool {
    return lhs.rawValue < rhs.rawValue
}

This seems to reduce the amount of boilerplate code required for both cases. For enums with raw values that want their comparison to be based off of their source order (irrespective of their raw values), they get that for free when opting in, while enums that want their comparison to be indicative of their raw values, only require a few lines to state their intended behaviour. The aforementioned default behaviour seems much more obvious to me over the behaviour outlined in the proposal. And from what I have read, it seems that others too feel that this behaviour is prohibitive.

Overall, while this concept is definitely a +1 for me and a generally welcomed addtion to Swift, I do feel that its default behaviour, in its current state, is somewhat unintuitive and restrictive in some cases.

2 Likes

Can you think of a single other use for #sourceOrder or would it pretty much solely be for comparing enums?

1 Like

Hmm, I can't think of anything else really, which is definitely a point against it. You could use it as a (bad) discriminant value lol. Ideally, source order would be a property accessible through a reflection system but obviously designing that is way out of scope here.

1 Like

@bzamayo Anything with a sort order property.

struct Book:  Comparable {
    #sourceOrder(sortOrder)
    let title: String
    let sortOrder: Int
}

The core team haven’t discussed this. I can see pros/cons of all various approaches, and the best way to handle this is to start a separate pitch thread to discuss them, that could result in a follow-on proposal to this one covering raw value enums.

From a review manager perspective: I’d like to try and keep the discussion focused on this proposal (or possible alternatives), and defer discussion of features subsetted out (i.e. how structs and raw-representable enums could be handled). The most important part of this discussion is making sure what is proposed is correct and future-proof. I'd hate to see us miss something because the thread instead was spent designing a different feature.

The exception to this would be if ideas for that other feature conflicted with this proposal. But still, the discussion should be around that conflict rather than potential designs for the other feature.

4 Likes

Sounds reasonable, thanks! Raw value enums seemed closely related enough to the rest of the proposal to make sense to also consider them here, but I'd also be happy to see the current proposal accepted as currently written (i.e., it's not actively harmful), so a follow-up discussion is fine.

2 Likes

-1

This feature doesn't feel consistent with the safety-driven nature of Swift. I can't help but see it as a footgun.

When the conformance to Comparable is declared, all is fine and good. A year later, when someone adds an enum case or gardens the enum cases into a different order, watch out! The behavior of the app will change, subtly or radically, and it may be difficult to diagnose the cause.

Perhaps this issue could be alleviated partially by tying the synthesized conformance to a protocol that clearly announces the import of the ordering of the enum's cases: DeclarationOrderComparable. This would have the added benefit of providing easily accessible documentation to address some of the non-obvious nuances discussed in this thread.

/// Invokes a compiler-generated synthesis of `Comparable`
/// conformance for enums, with the comparison order of 
/// cases being the same as their declaration order.
///
/// Applies only to enums.  
/// Does not apply to enums declared with raw values.
/// 
/// Handles associated values by...
/// 
protocol DeclarationOrderComparable: Comparable {}

I've read the proposal and this thread.

Thank you to @taylorswift for the thought and effort that have gone into the proposal. I respect the work, and the idea.

7 Likes

Enums (or structs/classes for that matter) with raw-values are already well-exposed to generic programming. You can use that to avoid the boilerplate of Comparable without needing the compiler to synthesise anything:

protocol ComparableByRawValue: Comparable, RawRepresentable where RawValue: Comparable {}

extension ComparableByRawValue {
    static func == (lhs: Self, rhs: Self) -> Bool {
        return lhs.rawValue == rhs.rawValue
    }
    static func < (lhs: Self, rhs: Self) -> Bool {
        return lhs.rawValue < rhs.rawValue
    }
}

enum HasRawValue: Int, ComparableByRawValue {
    case a = 1
    case b
    case c
    case d
}

HasRawValue.a < HasRawValue.d // true
HasRawValue.b > HasRawValue.c // false
HasRawValue.d > HasRawValue.b // true

Properties of enums (including declaration order and associated values) are not very well exposed at all in the language in a generic/dynamic way, which is what gives value to proposals like this one.

Theoretically, if we had reflection and better layout/object-type constraints, we wouldn't need this synthesis either and it could just be in the standard library.

3 Likes
Terms of Service

Privacy Policy

Cookie Policy