NSDate equality miracle

I wonder what's inside NSDate.isEqual so the following check returns true:

let a = NSDate(timeIntervalSince1970: .nan)
let b = NSDate(timeIntervalSince1970: .nan)
print(a == b) // true
print(a as Date == b as Date) // false

(checked on macOS).

Do they compare bit representations of two floats?

FWIW I do not see anything suspicious in the open source version of foundation here – it compares float normally so I'd expect to get Swift Date behaviour and false result with this version in this example (didn't try using it).

1 Like

This old version of CFDate.c suggests it is likely a coincidence:

static Boolean __CFDateEqual(CFTypeRef cf1, CFTypeRef cf2) {
    CFDateRef date1 = (CFDateRef)cf1;
    CFDateRef date2 = (CFDateRef)cf2;
    if (date1->_time != date2->_time) return false;
    return true;
}

(obviously, don't make NaN dates)

EDIT: maybe Swift should pick this up though, it seems safer.

I don't understand. if _time here is float/double – that will work correctly and return false for .nan, while NSDate.== returns true, so it must be doing something else. Coincidence?!

And why do you think this is safer than
return timeIntervalSinceReferenceDate == otherDate.timeIntervalSinceReferenceDate ?

â€ĶI confused myself about NaN, you're right. I'm perplexed too, then.


The safety comment (about treating NaN dates as equal) is because people might be less likely to test that a Date is non-NaN before putting it in a Set or Dictionary, which don't really do a good job with non-reflexive equality. The same does apply to arbitrary Floats or Doubles, but I feel like those are less likely to go into Sets or Dictionaries, because you could reasonably get the same timestamps for things but are less likely to get the same arbitrary numeric values, and thus you'd know ahead of time that a Set or Dictionary doesn't make sense. But maybe I'm thinking too hard about it, especially after I got the base thing wrong.

As a side note, I'd love to have nan == nan – then we could use float keys in dictionaries and elements in sets without fear. That would be a departure from IEEE standard and I wonder if there are precedents of languages brave enough doing so.

Certain core Foundation types create tagged pointer objects on initialization for performance, such that pointer equality is enough to check for object equality. IIRC, NSDates can produce such tagged pointers, but they don't appear to in the case of NaN:

let a = NSDate(timeIntervalSince1970: .nan)
let b = NSDate(timeIntervalSince1970: .nan)
print(ObjectIdentifier(a)) // ObjectIdentifier(0x0000600000ee0080)
print(ObjectIdentifier(b)) // ObjectIdentifier(0x0000600000ee0090)

So to figure this out, we can disassemble -[NSDate isEqualToDate:]; on my M1 machine running macOS 14.4.1, Hopper shows me

                     -[NSDate isEqualToDate:]:
000000018051e13c         cbz        x2, loc_18051e198

000000018051e140         pacibsp
000000018051e144         stp        d9, d8, [sp, #-0x30]!
000000018051e148         stp        x20, x19, [sp, #0x10]
000000018051e14c         stp        fp, lr, [sp, #0x20]
000000018051e150         add        fp, sp, #0x20
000000018051e154         mov        x19, x2
000000018051e158         bl         _objc_msgSend$timeIntervalSinceReferenceDate ; _objc_msgSend$timeIntervalSinceReferenceDate
000000018051e15c         fmov       d8, d0
000000018051e160         mov        x0, x19
000000018051e164         bl         _objc_msgSend$timeIntervalSinceReferenceDate ; _objc_msgSend$timeIntervalSinceReferenceDate
000000018051e168         mov        w8, #0x1
000000018051e16c         fcmp       d0, d0
000000018051e170         cset       w9, vs
000000018051e174         fcmp       d8, d8
000000018051e178         csel       w9, wzr, w9, vc
000000018051e17c         fcmp       d8, d0
000000018051e180         csel       w0, w8, w9, eq
000000018051e184         ldp        fp, lr, [sp, #0x20]
000000018051e188         ldp        x20, x19, [sp, #0x10]
000000018051e18c         ldp        d9, d8, [sp], #0x30
000000018051e190         autibsp
000000018051e194         ret

The relevant portion appears to be:

000000018051e16c         fcmp       d0, d0
000000018051e170         cset       w9, vs
000000018051e174         fcmp       d8, d8
000000018051e178         csel       w9, wzr, w9, vc
000000018051e17c         fcmp       d8, d0
000000018051e180         csel       w0, w8, w9, eq

I'm far from an expert in ARM assembly, but my understanding is that the first two lines effectively evaluate

w9 = isnan(d0) // d0 is [a timeIntervalSinceReferenceDate]

The next then

w9 = !isnan(d8) ? 0 : w9 // d8 is [b timeIntervalSinceReferenceDate]
   = isnan(d8) ? w9 : 0

The last two:

w0 = (d0 == d8) ? 1 : w9 // w0 is the return value
   = (d0 == d8) || w9

In other words,

w0 = (d0 == d8) || w9
   = (d0 == d8) || (isnan(d8) ? isnan(d0) : 0))
   = (d0 == d8) || (isnan(d8) && isnan(d0))

i.e.,

- (BOOL)isEqualToDate:(NSDate *)other {
    NSTimeInterval i1 = [self timeIntervalSinceReferenceDate];
    NSTimeInterval i2 = [other timeIntervalSinceReferenceDate];
    return isnan(i1) && isnan(i2) || i1 == i2;
}

So yes, it appears that NSDate is doing NaN checks to ensure that such dates end up equal. Still, not recommended to create such dates.

11 Likes