Add default implementation of == to Comparable?

Currently Comparable inherits from Equatable, but does not provide a default implementation for ==, so the compiler tries to synthesize one composed of member-wise ==s. This leads to a problem where if a type's < is not composed of member-wise inequalities, then <, >, and == all evaluate to false for some pairs of values. For example:

struct Length {
    enum Unit: Double, Comparable {
        case mm = 0.001
        case m = 1
        case km = 1000
        case banana = 0.178 // according to bananaforscale.info
    }
    let magnitude: Double
    let unit: Unit
    
    static func < (lhs: Self, rhs: Self) -> Bool {
        lhs.magnitude * lhs.unit.rawValue < rhs.magnitude * rhs.unit.rawValue
    }
}

let aBanana = Length(magnitude: 1, unit: .banana)
let anotherBanana = Length(magnitude: 0.178, unit: .m)

print(aBanana < anotherBanana) // prints 'false'
print(aBanana > anotherBanana) // prints 'false'
print(aBanana == anotherBanana) // prints 'false'

Logically, a = b iff a ≮ b and a ≯ b, so one would reasonably think that aBanana == anotherBanana, because neither the < nor the > evaluates to true. This is also the source of a SwiftPM bug where one package version can be neither equal to nor greater/lesser than another at the same time.

Additionally, the current behaviour does not match Comparable's documentation:

Types with Comparable conformance implement the less-than operator (<) and the equal-to operator (==). These two operations impose a strict total order on the values of a type, in which exactly one of the following must be true for any two values a and b:

  • a == b
  • a < b
  • b < a

I tried to think of an example where the status quo is preferred, but can't come up with a good one.

Adding a default implementation of == to Comparable like this:

extension Comparable {
    @inlinable
    static func == (lhs: Self, rhs: Self) {
        !(lhs < rhs || lhs > rhs)
    }
}

is silently source breaking, but likely the sources it breaks are already incorrect.

What does everyone think?

2 Likes

That is true only for totally ordered sets.

As an example, Double, which conforms to Comparable, is not totally ordered. 3.0 > .nan is false, 3.0 < .nan is false, but 3.0 == .nan is false.

6 Likes

That's interesting. I didn't know that. It makes sense too, because .nan is "not a number", and you can't compare a number with a not-a-number.

Exceptions like .nan allowed for in the note at the bottom of the docs:

Note

A conforming type may contain a subset of values which are treated as exceptional—that is, values that are outside the domain of meaningful arguments for the purposes of the Comparable protocol. For example, the special “not a number” value for floating-point types ( FloatingPoint.nan ) compares as neither less than, greater than, nor equal to any normal floating-point value. Exceptional values need not take part in the strict total order.

2 Likes

But maybe we should shouldn't (EDIT) auto synthesize equatable conformance, when comparable is manually implemented?

1 Like

Maybe we should stop auto synthesising func == / func < when one of them is implemented manually.

3 Likes

That's what I meant (and thought I wrote). Updated my comment. :-)

2 Likes

See the related [SR-11588]Warn about derived Hashable implementation if there’s a custom Equatable by JGiola · Pull Request #27801 · apple/swift · GitHub / [SR-11588] Warn about derived Hashable implementation if there's a custom Equatable · Issue #53993 · apple/swift · GitHub which covers the same basic issue for Hashable / Equatable.

It's not quite as clear-cut that a type with a custom Comparable conformance requires a custom Equatable, but a type with a custom Equatable conformance almost certainly needs a custom Comparable conformance.

4 Likes

I think you're right. In fact, I thought == was implemented according to your proposed default before I read this post.

Thanks for posting the links to SR-11588 and the related pull request. I read them and just opened SR14665 for this.

Maybe instead of either adding a default == or disabling synthesizing == for Comparable, which are source-breaking, we can add a warning to non-synthesized < that the synthesized == might be incorrect?

5 Likes

Yes, that sounds right to me.

2 Likes