SE-0266 — Synthesized Comparable conformance for enum types

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

I'm +0 on this proposal.

On the one hand, when you want declaration-order ordering, this would save you a lot of boiler-plate (and possibly improve performance - aren't the discriminator bits of enums also declaration-order numbers?).

On the other hand, I'm not sure that declaration order is the obvious order.

Something like this would make me more comfortable. I said the same thing for Codable/Equatable/Hashable synthesis, and even though the sky hasn't collapsed, I still believe the language would be better if we made the implementation strategy explicit for synthesised conformances. You could extend this argument to @memberwise for the Differentiable protocol.

1 Like

I like the idea of being able to compare elements of an enumerated type but I wonder if there is a better approach. The programming language Pascal has the concept of Ordinal types. An Ordinal is essentially a type that has a straightforward mapping of its values to the numbers 0, 1, 2, etc. All enumerated types in Pascal are considered Ordinal types. Swift could provide an Ordinal protocol defined something like this:

protocol Ordinal : Strideable
    {
    // Number of elements in the type
    static var count : Int { get }

    // Position of a given element: 0, 1, 2, etc
    var ordinal : Int { get }

    // Access to the ith element
    static subscript( i:Int ) -> Self { get }
    }

It is easy to make a given enum type conform to Ordinal (even if a bit tedius). Here is an example of a “DayOfWeek” enumerated type conforming to Ordinal.

enum DayOfWeek : Ordinal
    {
    case monday, tuesday, wednesday, thursday, friday, saturday, sunday
    
    static let count = 7
    var ordinal : Int
        {
        switch self
            {
        case .monday:    return 0
        case .tuesday:   return 1
        case .wednesday: return 2
        case .thursday:  return 3
        case .friday:    return 4
        case .saturday:  return 5
        case .sunday:    return 6
            }
        }
    static subscript( i:Int ) -> DayOfWeek
        {
        switch i
            {
        case 0: return .monday
        case 1: return .tuesday
        case 2: return .wednesday
        case 3: return .thursday
        case 4: return .friday
        case 5: return .saturday
        case 6: return .sunday
        default: fatalError( "Index out of range" )
            }
        }
    }

If Swift synthesized conformance to Ordinal for enums (opt in), then programmers could get a number of useful functions for free:

  1. Automatic conformance to Comparable (a < b would be the same as a.ordinal < b.ordinal).
    Eg., DayOfWeek.tuesday < .wednesday // true

  2. .first and .last properties to get access to the first and last elements (in this case
    .monday and .sunday)

  3. An .allValues property to allow iteration over the values of DayOfWeek.

  4. Subranges.
    Eg. DayOfWeek.monday ... .friday

  5. Add or subtract an Int to access to neighboring values.
    Eg. DayOfWeek.monday + 1 // .tuesday

  6. Take the difference between elements.
    Eg. DayOfWeek.friday - .monday // 4

  7. Array subscripting

    var dayOfWeekCounts = Array<Int>( repeating:0, count:DayOfWeek.count )
    for transaction in transactionList
      {
      dayOfWeekCounts[transaction.dayOfWeek] += 1
      }
    for dayOfWeek : DayOfWeek in .monday ... .friday
      {
      print( "\(dayOfWeek) count = \(dayOfWeekCounts[dayOfWeek])" )
      }

I think there's been some confusion, since your implementation defines < in terms of raw value, whereas I've been advocating in this thread for declaration order. But as I said in the post you quoted and per Ben's recommendation, we should save this discussion for a separate thread (probably dependent on the outcome of this proposal).

I think this is an interesting idea that has potential (and generalizes better than the #sourceOrder idea proposed above), though some of the functionality (like #2 and #3) is already covered by the CaseIterable protocol, so there would be some overlap there.

However, I think other limitations in the Swift language make me uncomfortable with it. While it's true that mathematically, a finite set of elements with a total order can be mapped to the natural numbers, that's a different question that saying that you want that mapping to be exposed as public API. Since Swift doesn't support internal-only conformances for public protocols, there would be no way for a type to say "I want this type to hide its Ordinal conformance but expose the Comparable conformance it gets based on it."

Because of that limitation, can we say that we'd be comfortable coupling and exposing all of the operations above for any enum that we wanted to be comparable? The ability to compute a subrange probably makes sense as a general operation, but the ordinal itself, as well as the integer based addition and difference operations, are implementation details that would be forced to be exposed as public API.

This solution also doesn't work for enums with associated values; would those not have a way to get Comparable synthesis?

3 Likes

would this become the first part of Swift where the hygiene of the source code itself affects the runtime?

as developers were all conditioned to "it compiles", but with this proposal you can't just refactor, rejigger, modify, etc and assume your code is the same at runtime, which is going to be incredibly surprising.

No, there are already existing situations where changing the order of enum cases would change runtime values/behavior.

IMHO it’s unlikely that people care in what order allCases iterates, and the implied raw value of cases is fairy visible on its own.

(Both are just my personal gut reaction... I have no data to back up either claim)

I was merely answering the question that was asked, not applying any additional subjective filters.

Regarding allCases, the behavior of its synthesized implementation is documented:

The synthesized allCases collection provides the cases in order of their declaration.

So whether or not people "care", it's a property that people can rely on if they wish. Given that Comparable and CaseIterable both effectively would define total orders among the cases, I think it would be actively harmful for those orders to be different.

9 Likes

There are many many examples in Swift that require you to do more than just make sure your source compiles. Random access support is the poster child for this: declaring a linked list to conform to RandomAccessCollection will compile. It will not be correct.

When you conform to a protocol, you are required to understand what that means for your type. In this case, conforming to Comparable without knowing how that conformance comes about, but then being unhappy because you add or reorder cases and that changes the conformance behavior you did nothing to implement, seems entirely unreasonable to me.

14 Likes

Oh, my mistake, I hadn't realized that the order for that was documented behavior. Also, I agree that it would be bad if they were ordered differently.

1 Like

I’m not an expert here. Does the following code provide the conformance requirement for CaseIterable enums to be Comparable using source order? My rudimentary testing in Playgrounds looked like it worked.

func < <T: CaseIterable & Comparable> (lhs: T, rhs: T) -> Bool {
        if let leftIndex = T.allCases.firstIndex(of: lhs),
            let rightIndex = T.allCases.firstIndex(of: rhs) {
            return leftIndex < rightIndex
        } else {
            return false
        }
}

I was negative on this proposal because it feels incomplete only providing synthesis for a small subset of enums. If it really is this simple to create conformance to Comparable then I’m fine with this proposal in spite of those limitations. Hopefully the excluded enums will get a useful error more specific than a complaint that it doesn’t conform to Comparable and maybe even an offer to write this conformance automatically with a FixIt if it is CaseIterable? Hopefully a reasonable default can be agreed upon for the excluded enums and a future proposal can finish the job.

We shouldn't strive for an implementation written in terms of CaseIterable; as I mentioned previously, it's neither time- nor space-efficient.

If an enum has no associated values, comparison of values should be a simple constant time operation based on their ordinals; it does not require a heap allocation of the array of values nor a linear-time scan of that array for the indexes. If manually written, the < function could look like this:

extension Priority: Comparable {
    static func < (lhs: Priority, rhs: Priority) -> Bool {
        let lhsOrdinal: Int
        let rhsOrdinal: Int
        switch lhs {
        case .low: lhsOrdinal = 0
        case .medium: lhsOrdinal = 1
        case .high: lhsOrdinal = 2
        }
        switch rhs {
        case .low: rhsOrdinal = 0
        case .medium: rhsOrdinal = 1
        case .high: rhsOrdinal = 2
        }
        return lhsOrdinal < rhsOrdinal
    }
}

which compiles simply to this in an optimized build:

static output.Priority.< infix(output.Priority, output.Priority) -> Swift.Bool:
        push    rbp
        mov     rbp, rsp
        cmp     dil, sil
        setb    al
        pop     rbp
        ret

An enum with associated values would involve a bit more conditional logic since it needs to compare each associated value in sequence, but those wouldn't be supported by a CaseIterable implementation anyway.

4 Likes

Late to the party, but this brings up a potential footgun not discussed yet:

If the user has elected to provide a custom implementation of CaseIterable conformance, potentially reordering the cases, then it can be unexpected if Comparable is synthesized using source order, introducing two (or if RawRepresentable, potentially three) definitions of total order among cases without warning.

It may be reasonable to consider not synthesizing Comparable for enums with custom CaseIterable implementations.

7 Likes

Proposal Accepted

The review period has ended and the proposal has now been accepted. Thanks to everyone who participated!

6 Likes
Terms of Service

Privacy Policy

Cookie Policy