Search code examples
swiftdecimalhashable

Swift - A Decimal's hashValue is the same for X == -X, cannot be used for comparing hashValues


We found out that you cannot distinguish two Decimals by their hashValue if one is the negative of the other. We use Decimals as a field in a struct and that struct implements Hashable to be able to be put in a set. Our business logic then requires all fields to be unique, so all fields and combined for the hashValue. Meaning that two structs where our decimal field is the negative of the other and the rest of the fields are in fact equal, then the whole struct is considered equal. Which is not what we want.

Playground code:

for i in 0..<10 {
    let randomNumber: Int = Int.random(in: 0..<10000000)

    let lhs = Decimal(integerLiteral: randomNumber)
    let rhs = Decimal(integerLiteral: -randomNumber)

    print("Are \(lhs) and \(rhs)'s hashValues equal? \(lhs.hashValue == rhs.hashValue)")
    print("Are \(randomNumber) and \(-randomNumber)'s hashValues equal? \(randomNumber.hashValue == (-randomNumber).hashValue)\n")
}

The same happens when testing with doubleLiteral instead of integerLiteral.

The work around is to compare the Decimals directly, and optionally include it in the hashValue if required by other parts.

Is this behaviour intended? The mantissa is the same, so I guess the reason they're not considered equal is because the sign is not included in the Decimal's hashValue?


Solution

  • Identical objects must have the same hash value, but not the other way around: Distinct objects can have the same hash value. Testing for equality must be done with == and never rely on the hash value alone.

    In this particular case note that there are more than 264 Decimal values, so that it would actually be impossible to assign different hash values to all of them. (Similarly for strings, arrays, dictionaries, ...).

    If you have a custom struct containing Decimal (and possibly other) properties then the implementation of the Equatable and Hashable protocol should look like this:

    struct Foo: Hashable {
    
        let value: Decimal
        let otherValue: Int
    
        static func == (lhs: Foo, rhs: Foo) -> Bool {
            return lhs.value == rhs.value && lhs.otherValue == rhs.otherValue
        }
    
        func hash(into hasher: inout Hasher) {
            hasher.combine(value)
            hasher.combine(otherValue)
        }
    }
    

    Note that if all stored properties are Hashable then the compiler can synthesize these methods automatically, and it is sufficient to declare conformance:

    struct Foo: Hashable {
        let value: Decimal
        let otherValue: Int
    }
    

    Remark: I assume that the behaviour is inherited from the Foundation type NSDecimalNumber. With Xcode 11 beta (Swift 5.1) x and -x have different hash values as Decimal, but the same hash value as NSDecimalNumber:

    let d1: Decimal = 123
    let d2: Decimal = -123
    
    print(d1.hashValue) // 1891002061093723710
    print(d2.hashValue) // -6669334682005615919
    
    print(NSDecimalNumber(decimal: d1).hashValue) // 326495598603
    print(NSDecimalNumber(decimal: d2).hashValue) // 326495598603
    

    (Your values may vary since hash values are randomized as of Swift 4.2.) But the above still applies: There can always be collisions, and one cannot rely on different values having different hashes.