Is this possible while inserting a String into Set<String>
I’m not 100% sure. Historically it might’ve been possible to get into this situation if you had a custom subclass of NSString
, but I tried that here in my office today and I’m not able to trigger it any more [1].
The most common source of errors like this is folks not maintaining the Hashable
invariant. I explain that in detail below. Are you sure that frame 4 is working with Set<String>
rather than some custom type?
Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"
[1] For custom NSString
subclasses, the compiler seems to eagerly bridge the contents over to a native String
.
One of the fundamental requirements of Hashable
is that, if you values are equal, they must have the same hash [1]. This is what allows containers like Set
and Dictionary
to use hashing to speed up things up:
- They put each value in a bucket based on its hash.
- To search for a value, they hash the value to find the right bucket and then just search that bucket.
If your implementation of Hashable
doesn’t meet this requirement then you’ll see a variety of weird behaviours. For example, consider this code:
import Foundation
struct Person {
var id: String
}
extension Person: Equatable {
}
extension Person: Hashable {
}
This code works as expected, relying on the compiler to synthesise the implementation of Equatable
and Hashable
. Now imagine that you want to ignore case when checking a person’s ID. So you add your own implementation of Equatable
:
extension Person: Equatable {
static func ==(_ lhs: Person, _ rhs: Person) -> Bool {
lhs.id.caseInsensitiveCompare(rhs.id) == .orderedSame
}
}
This seems to work; the following code never traps:
let u = UUID().uuidString
let p1 = Person(id: u)
let p2 = Person(id: u.lowercased())
assert(p1 == p2)
However, it’s not correct, because it’s possible for two equal values to have different hashes. This code will likely print hash mismatch
:
if p1.hashValue != p2.hashValue { print("hash mismatch") }
Note I say “likely” because in the Swift standard library hashing include some degree of randomness to prevent collision attacks.
You’ll also see this bug cause problems with Set
. For example, this code will sometimes print count mismatch
:
var s = Set<Person>()
s.insert(p1)
s.insert(p2)
if s.count != 1 { print("count mismatch") }
And if you run a full test in a loop, it’ll eventually trap. For example this code:
func test() {
let u = UUID().uuidString
let p1 = Person(id: u)
let p2 = Person(id: u.lowercased())
assert(p1 == p2)
if p1.hashValue != p2.hashValue { print("hash mismatch") }
var s = Set<Person>()
s.insert(p1)
s.insert(p2)
if s.count != 1 { print("count mismatch") }
}
for _ in 1...10_000 {
test()
}
printed this:
hash mismatch
count mismatch
hash mismatch
hash mismatch
hash mismatch
hash mismatch
hash mismatch
hash mismatch
count mismatch
hash mismatch
count mismatch
hash mismatch
hash mismatch
Fatal error: Duplicate elements of type 'Person' were found in a Set.
This usually means either that the type violates Hashable's requirements, or
that members of such a set were mutated after insertion.
and then trapped with this backtrace:
(lldb) bt
…
frame #0: … libswiftCore.dylib`_swift_runtime_on_report
frame #1: … libswiftCore.dylib`_swift_stdlib_reportFatalError + 176
frame #2: … libswiftCore.dylib`closure #1 (Swift.UnsafeBufferPointer<Swift.UInt8>) -> () in Swift._assertionFailure(_: Swift.StaticString, _: Swift.String, flags: Swift.UInt32) -> Swift.Never + 140
frame #3: … libswiftCore.dylib`Swift._assertionFailure(_: Swift.StaticString, _: Swift.String, flags: Swift.UInt32) -> Swift.Never + 140
frame #4: … libswiftCore.dylib`Swift.ELEMENT_TYPE_OF_SET_VIOLATES_HASHABLE_REQUIREMENTS(Any.Type) -> Swift.Never + 4896
frame #5: … libswiftCore.dylib`Swift._NativeSet.insertNew(_: __owned τ_0_0, at: Swift._HashTable.Bucket, isUnique: Swift.Bool) -> () + 616
frame #6: … libswiftCore.dylib`Swift.Set._Variant.insert(__owned τ_0_0) -> (inserted: Swift.Bool, memberAfterInsert: τ_0_0) + 1024
* frame #7: … test`test() at main.swift:24:7
frame #8: … test`main at main.swift:29:5
frame #9: … dyld`start + 6076
In general, the fix for this is obvious: Always update your Equatable
and Hashable
conformances in unison to maintain this invariant. However, to offer specific advice about your code I’d need more details about how it’s implementing Hashable
.
[1] Note that the reverse is not true.