Strange behaviour with dictionary + DateComponents keys in iOS17

A couple of unit tests for an application failed on a iOS 17.2.1 device and I could break down the problem to some strange behaviour when applying a dictionary + DateComponents keys. The tests had been running fine with iOS 16.x

Here is some simplified code to reproduce the behaviour in Playground:

let date1 = try! Date("2023-12-31T00:00:00Z", strategy: .iso8601)
let date2 = try! Date("2024-01-31T00:00:00Z", strategy: .iso8601)
let date3 = try! Date("2024-02-28T00:00:00Z", strategy: .iso8601)

let date1dc = Calendar.current.dateComponents([.year, .month], from: date1)
let date2dc = Calendar.current.dateComponents([.year, .month], from: date2)
let date3dc = Calendar.current.dateComponents([.year, .month], from: date3)

let dc1 = DateComponents(year: 2023, month: 12)
let dc2 = DateComponents(year: 2024, month: 01)
let dc3 = DateComponents(year: 2024, month: 02)

let data: [DateComponents: String] = [
    dc1: "One", dc2: "Two", dc3: "Three"
]

print(date1dc == dc1)
print(date2dc == dc2)
print(date3dc == dc3)
print("--------------------------------")
print(data[dc1])
print(data[dc2])
print(data[dc3])
print("--------------------------------")
print(data[date1dc])
print(data[date2dc])
print(data[date3dc])

The output for date1dc, date2dc and date3dc now is random:

true
true
true
--------------------------------
Optional("One")
Optional("Two")
Optional("Three")
--------------------------------
Optional("One")
nil
Optional("Three")

or

true
true
true
--------------------------------
Optional("One")
Optional("Two")
Optional("Three")
--------------------------------
nil
nil
nil

or

true
true
true
--------------------------------
Optional("One")
Optional("Two")
Optional("Three")
--------------------------------
nil
nil
Optional("Three")

For me it looks like a serious foundation bug, but maybe I'm missing something.

Answered by Scott in 776452022

Yep it appears that DateComponents no longer implements Hashable correctly. In the Swift REPL on Sonoma I get this:

  1> import Foundation
  2> var dc1 = Calendar.current.dateComponents([.year, .month], from: Date())
  3> var dc2 = DateComponents(year: 2024, month: 1)
  4> print(dc1 == dc2)
true
  5> print(dc1.hashValue == dc2.hashValue)
false

The rule is: if two values are equal (according to their Equatable implementation) then their hashValue must also be equal. As seen here, this doesn’t hold. Here’s a clue to the cause:

  6> print(dc1.isLeapMonth)
Optional(false)
  7> print(dc2.isLeapMonth)
nil

Looking at the source code here shows that hash(into:) does include isLeapMonth in the hash calculation. And now we can test that theory:

  8> dc2.isLeapMonth = dc1.isLeapMonth
  9> print(dc1 == dc2 && dc1.hashValue == dc2.hashValue)
true
Accepted Answer

Yep it appears that DateComponents no longer implements Hashable correctly. In the Swift REPL on Sonoma I get this:

  1> import Foundation
  2> var dc1 = Calendar.current.dateComponents([.year, .month], from: Date())
  3> var dc2 = DateComponents(year: 2024, month: 1)
  4> print(dc1 == dc2)
true
  5> print(dc1.hashValue == dc2.hashValue)
false

The rule is: if two values are equal (according to their Equatable implementation) then their hashValue must also be equal. As seen here, this doesn’t hold. Here’s a clue to the cause:

  6> print(dc1.isLeapMonth)
Optional(false)
  7> print(dc2.isLeapMonth)
nil

Looking at the source code here shows that hash(into:) does include isLeapMonth in the hash calculation. And now we can test that theory:

  8> dc2.isLeapMonth = dc1.isLeapMonth
  9> print(dc1 == dc2 && dc1.hashValue == dc2.hashValue)
true

Thanks for digging into this; I filed my own bug about it (r. 120834909).

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Strange behaviour with dictionary + DateComponents keys in iOS17
 
 
Q