Dangerous default implementation of `==` under `Strideable`

I have created a custom type DayInterval:

public struct Day: Hashable {
    public let year:  Int
    public let month: Int
    public let day:   Int
}
public struct DayInterval: Hashable {
    public let first: Day
    public let last:  Day
}

to handle calendar ranges safely in my app (I find the standard types quite lacking, Date is not a calendar type and DateComponents are.. not helpful).

My expectation is that since this is a Hashable type with all Hashable properties, Swift will auto-synthesize == and hash(into:), both for Day and DayInterval. This all seems to work great.

Later on in the app's lifecycle, the type was evolved and conformance to Comparable was added:

extension DayInterval: Comparable {
    public static func < (lhs: Self, rhs: Self) -> Bool {
        lhs.last < rhs.first
    }
}
extension DayInterval: Strideable {
    ... // delegates to Calendar APIs
}

Granted, this type is slightly ambiguous in its comparability, since we need to be able to compare intervals of different lengths with each other.

Generally, this all works well, however it was later discovered that the app has started crashing randomly.

There was very little to indicate the source of the crash, all we saw is:

Fatal error: Duplicate keys of type 'AnyHashable' were found in a Dictionary.
This usually means either that the type violates Hashable's requirements, or
that members of such a dictionary were mutated after insertion.

No indication of what dictionary or hashable values are involved, no backtrace to indicate the code path at fault.
Since this crash was relatively random, it took us a lot of effort to trace down the cause until we finally landed on SwiftUI using a DayInterval as the tag of a bunch of Views in a ForEach. There doesn't appear to be any duplication in the actual day intervals involved; they are clean linear 7-day intervals.

However, it turns out that adding this code in prevents the crashing from reproducing:

extension DayInterval: Comparable {
    public static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.first == rhs.first && lhs.last == rhs.last
    }

Now, I have some serious questions:

  1. Why has adopting : Comparable voided the synthesized == conformance to Hashable?
  2. Why has it done so entirely silently?
  3. How is Comparable changing my default == if I don't specify it explicitly in the exact same way that Hashable would synthesize it?
  4. Why does Comparable's documentation not indicate/warn about this?
  5. Why are we allowing such a dangerous situation to develop entirely hidden from developer involvement?
1 Like

The behavior you're seeing doesn't have to do with conforming to Comparable.

Rather, it's Strideable that has a default implementation of == (and <), and the presence of any non-synthesized implementation (default or not) takes precedence over the synthesized one (or rather more accurately, disables synthesis). Strideable's default implementation of equivalence boils down to lhs.distance(to: rhs) == 0.

The presence of these defaults is documented in Strideable, and it's even put in a box marked "Important":

The Strideable protocol provides default implementations for the equal-to (==) and less-than (<) operators that depend on the Stride type’s implementations.

The issue has been discussed here before, and there's even a hokey workaround I've suggested in the past to get the default synthesized implementation back.

2 Likes

Interesting. It feels dubious to default == based on a Strideable function, especially one that explicitly mentions that it may result in an approximation.

It seems like there ought to be some kind of developer opt-in involved when there is a competition between multiple default implementations (granted, Hashable's == is probably somewhat more complex than a default implementation), if only to avoid a scenario where a type's existing contracts suddenly change by simply adding a new conformance - never mind the potential for a change that results in runtime crashes like this.

There is.

A synthesized implementation, by contrast, isn't a default implementation; it's a concrete implementation on the conforming type—that is, it's equivalent to extension Day { static func == /* etc. */ }—and it is synthesized only in the absence of a viable non-synthesized implementation.

(Yes, the result here is unfortunate, and we should at minimum strive for better diagnostics.)

1 Like

Just to be explicit, this isn’t “Hashable’s ==”; it’s “the compiler-synthesized ==”. It’s still available even if your type doesn’t conform to Hashable.

Thanks. For context:

Users must opt-in to automatic synthesis by declaring their type as Equatable or Hashable without implementing any of their requirements. This conformance must be part of the original type declaration or in an extension in the same file (to ensure that private and fileprivate members can be accessed from the extension).

Any type that declares such conformance and satisfies the conditions below will cause the compiler to synthesize an implementation of ==/hashValue for that type.

What would be the collateral damage of removing Equatable conformance from Strideable implementation and make it using some:

extension Strideable /* : Equatable */ {
    func equal_for_strideable(_ other: Self) -> Bool {
        distance(to: other) == 0
    }
}